From de5f204b06fa015dc0918e8373ab8f53be8cc9c1 Mon Sep 17 00:00:00 2001 From: AriusII Date: Tue, 22 Sep 2026 15:25:55 +0200 Subject: [PATCH] Reformat SDK sources and modernize CI pipelines Apply the repository-wide formatting and style normalization already prepared on the SDK worktree, while keeping the public source layout intact. Consolidate the CI workflow changes, restore/package settings, coverage hand-off, SonarQube reusable analysis, and CodeRabbit configuration so validation is reproducible and shared artifacts can be reused across jobs. --- .coderabbit.yaml | 131 + .editorconfig | 5 + .github/PULL_REQUEST_TEMPLATE.md | 15 +- .github/workflows/ci.yml | 170 +- .github/workflows/main-ci.yml | 4 + .github/workflows/pull-request-ci.yml | 37 +- .github/workflows/sonar.yml | 214 ++ AGENTS.md | 27 +- CHANGELOG.md | 4 +- CONTRIBUTING.md | 30 +- CheatEngine.SDK.slnx | 6 +- Directory.Build.props | 4 +- Directory.Packages.props | 3 +- README.md | 52 +- RELEASING.md | 18 +- ROADMAP.md | 119 +- .../Plugin/PluginClassRewriter.cs | 186 +- .../Plugin/PluginClassShapeCodeFixProvider.cs | 401 +-- .../Usage/ExceptionGuardRewriter.cs | 300 +- ...nmanagedCallersOnlyGuardCodeFixProvider.cs | 238 +- .../Diagnostics/DiagnosticCategories.cs | 12 +- .../Diagnostics/DiagnosticIds.cs | 70 +- .../Diagnostics/DiagnosticProperties.cs | 10 +- .../Generation/ContainingTypeProblemText.cs | 49 +- .../Generation/LuaBindingAnalyzer.cs | 339 ++- .../Generation/LuaBindingContractSymbols.cs | 72 +- .../Generation/LuaFunctionDuplicateState.cs | 81 +- .../Generation/LuaFunctionProblemText.cs | 93 +- .../Generation/LuaGlobalProblemText.cs | 117 +- .../Generation/LuaObjectBindingAnalyzer.cs | 1177 ++++---- .../Plugin/CheatEnginePluginAnalyzer.cs | 455 ++-- .../Plugin/PluginClassProblemText.cs | 99 +- .../Plugin/PluginCompilationState.cs | 188 +- .../Plugin/PluginContractSymbols.cs | 36 +- .../Usage/ExceptionGuard.cs | 372 +-- .../PluginLifecycleAndOwnershipAnalyzer.cs | 526 ++-- .../UnmanagedCallersOnlyGuardAnalyzer.cs | 204 +- .../WellKnown/SdkSymbolResolver.cs | 72 +- .../WellKnown/WellKnownTypeNames.cs | 92 +- analyzers/docs/CESDK2004.md | 2 +- .../docs/internal-lua-direct-api-boundary.md | 6 +- eng/Tests.props | 1 + exemples/01-first-plugin/README.md | 3 +- exemples/05-aob-scans/README.md | 31 +- exemples/06-value-scans/README.md | 18 +- exemples/10-logging-and-errors/README.md | 10 +- exemples/api/README.md | 50 +- exemples/recipes/assembly/README.md | 32 +- exemples/recipes/injection/README.md | 17 +- exemples/recipes/speed-and-hashing/README.md | 19 +- libs/CheatEngine.SDK.Abi/AbiArchitecture.cs | 62 +- libs/CheatEngine.SDK.Abi/AbiConstants.cs | 38 +- libs/CheatEngine.SDK.Abi/Bool32.cs | 221 +- libs/CheatEngine.SDK.Abi/Bool8.cs | 222 +- .../Managed/ManagedEntryPoint.cs | 24 +- .../Managed/ManagedExportedFunctions.cs | 112 +- .../Managed/PluginInitRecord.cs | 101 +- .../Native/AddressListPluginInit.cs | 28 +- .../Native/AutoAssemblerPhase.cs | 16 +- .../Native/AutoAssemblerPluginInit.cs | 22 +- .../BoundedDebugEventObservationBuffer.cs | 167 +- .../Native/ClassicDebugEventDispatcher.cs | 690 ++--- .../ClassicDebugEventRegistrationStatus.cs | 12 +- .../Native/ClassicDebugEventReleaseStatus.cs | 8 +- .../ClassicExportedFunctionsPrefixReader.cs | 40 +- .../Native/DebugEventDecision.cs | 22 +- .../Native/DebugEventObservation.cs | 44 +- .../DebugEventObservationOverflowPolicy.cs | 8 +- .../Native/DebugEventPluginInit.cs | 22 +- .../Native/DisassemblerContextPluginInit.cs | 66 +- .../DisassemblerRenderLinePluginInit.cs | 20 +- .../Native/ExportedFunctionsPrefix.cs | 94 +- .../Native/FunctionPointerChangePluginInit.cs | 6 +- .../Native/MainMenuPluginInit.cs | 38 +- .../Native/MemoryViewPluginInit.cs | 52 +- .../Native/NativeExportNames.cs | 12 +- libs/CheatEngine.SDK.Abi/Native/PluginType.cs | 78 +- .../Native/PluginType0Record.cs | 36 +- .../Native/PluginVersion.cs | 35 +- .../Native/ProcessWatcherPluginInit.cs | 12 +- .../Native/RegisterModificationInfo.cs | 192 +- libs/CheatEngine.SDK.Abi/README.md | 3 +- .../RequiresPluginEnabledAttribute.cs | 10 +- .../Lua/LuaClassAttribute.cs | 49 +- .../Lua/LuaFunctionAttribute.cs | 51 +- .../Lua/LuaGlobalAttribute.cs | 50 +- .../Lua/LuaMarshallerAttribute.cs | 23 +- .../Lua/LuaMethodAttribute.cs | 49 +- .../Lua/LuaPropertyAttribute.cs | 49 +- .../Lua/LuaStackEffectAttribute.cs | 11 +- .../Plugin/CheatEnginePluginAttribute.cs | 53 +- .../Threading/MainThreadOnlyAttribute.cs | 12 +- .../AddressList/AddressList.cs | 294 +- .../AddressList/AddressListAccess.cs | 38 +- .../AddressList/AddressListCalls.cs | 205 +- .../AddressList/AddressListMutations.cs | 516 ++-- .../AddressList/MemoryRecord.cs | 414 +-- .../AddressList/MemoryRecordId.cs | 268 +- .../AddressList/MemoryRecordMutationEffect.cs | 12 +- .../MemoryRecordMutationOutcome.cs | 45 +- .../MemoryRecordMutationProblem.cs | 44 +- .../MemoryRecordParentTraversalLimit.cs | 27 +- .../Allocation/AllocatedRegion.cs | 461 ++-- .../Allocation/AllocatedRegionFactory.cs | 2 +- .../ITargetBoundMemoryAllocationOperations.cs | 58 +- .../ITargetMemoryAllocationOperations.cs | 48 +- ...TargetMemoryAllocationOutcomeOperations.cs | 26 +- .../LuaTargetMemoryAllocationOperations.cs | 611 +++-- .../Allocation/TargetAllocationRequest.cs | 69 +- .../Allocation/TargetAllocationSize.cs | 121 +- .../TargetMemoryAllocationOutcome.cs | 95 +- .../Allocation/TargetMemoryAllocator.cs | 465 ++-- .../TargetMemoryOperationOutcome.cs | 167 +- .../TargetMemoryOperationOutcomeKind.cs | 46 +- .../Assembly/AutoAssemblerPatch.cs | 359 +-- .../Assembly/AutoAssemblerPatchFactory.cs | 2 +- .../Assembly/AutoAssemblerPatcher.cs | 569 ++-- .../Assembly/InstructionAssembler.cs | 256 +- .../Assembly/InstructionDisassembler.cs | 250 +- .../Assembly/InstructionDisassembly.cs | 12 +- .../Assembly/InstructionNavigator.cs | 226 +- .../Assembly/InstructionOperationStatus.cs | 53 +- .../Assembly/InstructionProfile.cs | 85 +- .../Assembly/InstructionProfiles.cs | 301 ++- .../Assembly/InstructionTargetProfile.cs | 44 +- .../Enums/BreakpointMethod.cs | 12 +- .../Enums/BreakpointTrigger.cs | 12 +- .../Enums/CEEnumNames.cs | 590 ++-- .../Enums/ContinueMethod.cs | 12 +- .../Enums/DuplicateHandling.cs | 12 +- .../Enums/EnumMarshaller.cs | 158 +- .../Enums/FastScanMethod.cs | 12 +- .../Enums/MemoryProtection.cs | 32 +- .../Enums/RoundingType.cs | 18 +- .../Enums/ScanOption.cs | 44 +- .../Enums/VariableType.cs | 70 +- .../Errors/EngineBindingException.cs | 63 +- .../EngineCapabilityUnavailableException.cs | 63 +- .../Errors/EngineException.cs | 57 +- .../Errors/EngineFailureKind.cs | 80 +- .../EngineGlobalUnavailableException.cs | 65 +- .../Errors/EngineLuaException.cs | 99 +- .../Errors/EngineMarshallingDirection.cs | 8 +- .../Errors/EngineMarshallingException.cs | 141 +- .../Errors/EngineOperationFailedException.cs | 63 +- .../Errors/EngineResourceHandoffException.cs | 59 +- .../Errors/EngineTargetIdentityException.cs | 52 +- .../Inspection/AddressResolutionOptions.cs | 61 +- .../Inspection/EngineInspection.cs | 1504 ++++++----- .../Inspection/InspectionStatus.cs | 24 +- .../Inspection/MemoryRegionInfo.cs | 16 +- .../Inspection/MemoryRegionState.cs | 12 +- .../Inspection/MemoryRegionType.cs | 12 +- .../Inspection/MemorySize.cs | 131 +- .../Inspection/ModuleFileOffset.cs | 95 +- .../Inspection/ModuleInfo.cs | 10 +- .../Inspection/ModuleName.cs | 105 +- .../Inspection/ModuleSectionInfo.cs | 8 +- .../Inspection/SymbolExpression.cs | 105 +- .../Inspection/SymbolInfo.cs | 8 +- .../Inspection/SymbolName.cs | 138 +- .../SymbolRegistrationAcquireOutcome.cs | 28 +- .../Inspection/SymbolRegistrationLease.cs | 113 +- .../SymbolRegistrationReleaseKind.cs | 29 +- .../SymbolRegistrationReleaseOutcome.cs | 31 +- .../Inspection/SymbolRegistry.cs | 391 +-- .../Inspection/TargetProcessId.cs | 91 +- .../Memory/HostAddress.cs | 137 +- .../Memory/HostMemory.cs | 670 ++--- .../Memory/MemoryAccessFailure.cs | 52 +- .../Memory/MemoryLua.cs | 1093 ++++---- .../Memory/TargetMemory.cs | 920 +++---- .../Objects/CEObject.cs | 1113 ++++---- .../Objects/ICEObject.cs | 17 +- libs/CheatEngine.SDK.Engine/Objects/Owned.cs | 379 +-- .../Objects/StringList.cs | 642 ++--- .../Objects/StringLists.cs | 41 +- .../Processes/CurrentProcessObservation.cs | 1 + .../Processes/ProcessOperationStatus.cs | 16 +- .../Processes/ProcessOperationStatusKind.cs | 2 +- .../Processes/RuntimeHostOperations.cs | 35 +- .../Processes/RuntimeProcessOperations.cs | 411 +-- libs/CheatEngine.SDK.Engine/README.md | 52 +- .../Runtime/CheatEngineArchitecture.cs | 20 +- .../Runtime/CheatEngineVersion.cs | 280 +- .../Runtime/PointerSize.cs | 328 +-- .../Runtime/RuntimeArchitectureRequirement.cs | 24 +- .../Runtime/RuntimeArchitectureScope.cs | 12 +- .../Runtime/RuntimeCapabilities.cs | 211 +- .../Runtime/RuntimeCapabilityAvailability.cs | 14 +- .../RuntimeCapabilityAvailabilityState.cs | 12 +- .../Runtime/RuntimeCapabilityContract.cs | 16 +- .../Runtime/RuntimeCapabilityId.cs | 152 +- .../Runtime/RuntimeInfo.cs | 176 +- .../Runtime/RuntimeOwnership.cs | 16 +- .../Runtime/RuntimeReturnSemantics.cs | 20 +- .../Runtime/RuntimeThreadRequirement.cs | 12 +- .../Runtime/TargetAbi.cs | 12 +- .../Scanning/Aob/AobScanOptions.cs | 188 +- .../Scanning/Aob/AobScanOutcome.cs | 217 +- .../Scanning/Aob/AobScanOutcomeKind.cs | 32 +- .../Scanning/Aob/AobScanStatus.cs | 20 +- .../Scanning/Aob/AobScanner.cs | 410 +-- .../Scanning/Values/FirstScanRequest.cs | 264 +- .../Scanning/Values/FoundList.cs | 282 +- .../Scanning/Values/MemScan.cs | 107 +- .../Values/MemoryScanCancellationMilestone.cs | 12 +- .../Values/MemoryScanCreationStatus.cs | 36 +- .../Scanning/Values/MemoryScanException.cs | 29 +- .../Scanning/Values/MemoryScanFailureKind.cs | 24 +- .../Values/MemoryScanInvalidationReason.cs | 20 +- .../Values/MemoryScanMaterializationStatus.cs | 36 +- .../Scanning/Values/MemoryScanResult.cs | 1 + .../Scanning/Values/MemoryScanSession.cs | 1758 ++++++------ .../Values/MemoryScanSessionContext.cs | 29 +- .../Scanning/Values/MemoryScanSessions.cs | 373 +-- .../Scanning/Values/MemoryScanState.cs | 30 +- .../Values/MemoryScanStateException.cs | 26 +- .../Scanning/Values/NextScanRequest.cs | 170 +- .../Tables/CheatTableFiles.cs | 31 +- .../Targets/TargetIdentityCheck.cs | 28 +- .../Targets/TargetIdentityCheckKind.cs | 36 +- .../Targets/TargetIdentityEvidence.cs | 12 +- .../Targets/TargetProcessIncarnation.cs | 107 +- .../Targets/TargetReleaseOutcome.cs | 101 +- .../Targets/TargetReleaseStatus.cs | 32 +- .../Targets/TargetSelection.cs | 247 +- .../Targets/TargetSelectionObservation.cs | 107 +- .../TargetSelectionObservationStatus.cs | 28 +- libs/CheatEngine.SDK.Engine/Values/Address.cs | 1021 +++---- .../Values/IndexBase.cs | 97 +- .../Values/LuaSequence.cs | 175 +- .../Bootstrap/AnsiNameBuffer.cs | 59 +- .../Bootstrap/LuaModuleLocator.cs | 143 +- .../Bootstrap/PluginDescriptor.cs | 12 +- .../Bootstrap/PluginDescriptor`1.cs | 13 +- .../Bootstrap/PluginHost.Lifecycle.cs | 1194 ++++---- .../Bootstrap/PluginHost.cs | 706 ++--- .../Bootstrap/PluginHostLifecyclePhase.cs | 20 +- .../Context/PluginContext.cs | 138 +- .../Diagnostics/DebugOutputLogSink.cs | 60 +- .../Diagnostics/HostLog.cs | 127 +- .../Diagnostics/HostLogLevel.cs | 16 +- .../Diagnostics/IHostLogSink.cs | 10 +- .../Plugin/CheatEnginePlugin.cs | 67 +- .../Plugin/IPluginFactory.cs | 22 +- .../Threading/ActionWorkItem.cs | 22 +- .../Threading/FuncWorkItem.cs | 30 +- .../Threading/MainThread.cs | 328 +-- .../Threading/MainThreadDispatcher.cs | 311 ++- .../Threading/MainThreadWorkItem.cs | 95 +- .../Api/ExportResolver.cs | 45 +- .../Api/LuaApi.Access.cs | 481 ++-- .../Api/LuaApi.Auxiliary.cs | 556 ++-- .../Api/LuaApi.AuxiliaryMacros.cs | 135 +- .../Api/LuaApi.Calls.cs | 230 +- .../Api/LuaApi.Constants.cs | 357 +-- .../Api/LuaApi.Coroutines.cs | 97 +- .../Api/LuaApi.Debug.cs | 419 +-- .../Api/LuaApi.Get.cs | 347 +-- .../Api/LuaApi.Libraries.cs | 179 +- .../Api/LuaApi.Macros.cs | 479 ++-- .../Api/LuaApi.Misc.cs | 142 +- .../Api/LuaApi.Operators.cs | 195 +- .../Api/LuaApi.Push.cs | 271 +- .../Api/LuaApi.Set.cs | 275 +- .../Api/LuaApi.Stack.cs | 241 +- .../Api/LuaApi.State.cs | 187 +- .../Api/LuaApi.Table.cs | 60 +- .../CheatEngine.SDK.Lua.Interop/Api/LuaApi.cs | 256 +- .../Api/LuaApiBindResult.cs | 16 +- .../GlobalUsings.cs | 10 +- .../Loading/LuaModule.cs | 101 +- .../Protected/BridgeBinding.cs | 11 +- .../Protected/LuaBridgeContract.cs | 60 +- .../Protected/LuaProtectedApi.cs | 674 ++--- .../Protected/LuaProtectedExports.cs | 92 +- .../Types/luaL_Reg.cs | 9 +- .../Types/lua_Debug.cs | 63 +- .../Callbacks/LuaCallback.cs | 644 +++-- .../Callbacks/LuaCallbackRegistry.cs | 158 +- .../Callbacks/LuaCallback`1.cs | 15 +- .../Callbacks/LuaHostSubscription.cs | 637 +++-- .../Callbacks/LuaHostSubscriptionRegistry.cs | 268 +- .../Callbacks/LuaNativeFunction.cs | 108 +- .../CheatEngine.SDK.Lua/Callbacks/LuaThunk.cs | 295 +- .../Calls/LuaComparison.cs | 12 +- libs/CheatEngine.SDK.Lua/Calls/LuaError.cs | 168 +- .../CheatEngine.SDK.Lua/Calls/LuaException.cs | 98 +- .../Calls/LuaOperationStatus.cs | 141 +- .../Calls/LuaOperationStatusKind.cs | 24 +- libs/CheatEngine.SDK.Lua/Calls/LuaStatus.cs | 211 +- .../CompilerServices/LuaCallSupport.cs | 230 +- .../CompilerServices/LuaGlobalFunctions.cs | 196 +- .../CompilerServices/LuaGlobalPushOutcome.cs | 83 +- .../CompilerServices/LuaGlobalPushStatus.cs | 12 +- .../Marshalling/AddressMarshaller.cs | 53 +- .../Marshalling/BooleanMarshaller.cs | 41 +- .../Marshalling/DoubleMarshaller.cs | 29 +- .../Marshalling/ILuaMarshaller.cs | 27 +- .../Marshalling/Int32Marshaller.cs | 41 +- .../Marshalling/Int64Marshaller.cs | 29 +- .../Marshalling/SingleMarshaller.cs | 33 +- .../Marshalling/StringMarshaller.cs | 37 +- .../Marshalling/Utf8Marshaller.cs | 29 +- .../Protected/LuaHelper.cs | 42 +- .../Protected/LuaHelpers.cs | 295 +- libs/CheatEngine.SDK.Lua/README.md | 5 +- libs/CheatEngine.SDK.Lua/References/LuaRef.cs | 305 ++- .../References/LuaReferences.cs | 67 +- .../LuaRegistrationCollisionPolicy.cs | 14 +- .../Registration/LuaRegistrationEntry.cs | 46 +- .../Registration/LuaRegistrationFailure.cs | 1 + .../Registration/LuaRegistrationLease.cs | 143 +- .../LuaRegistrationReleaseFailure.cs | 1 + .../LuaRegistrationReleaseKind.cs | 20 +- .../LuaRegistrationReleaseOutcome.cs | 102 +- .../Registration/LuaRegistrationResult.cs | 54 +- .../Registration/LuaRegistrationResultKind.cs | 20 +- .../Registration/LuaRegistrationSet.cs | 764 +++--- .../Runtime/LuaHostBinding.cs | 140 +- .../Runtime/LuaHostServices.cs | 34 +- .../CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs | 1490 +++++----- .../Runtime/LuaRuntimeOperation.cs | 43 +- .../Runtime/LuaStateIdentity.cs | 84 +- .../Runtime/LuaStateResetTransition.cs | 25 +- libs/CheatEngine.SDK.Lua/State/LuaFrame.cs | 73 +- .../State/LuaState.Callbacks.cs | 378 +-- .../State/LuaState.Protected.cs | 829 +++--- .../State/LuaState.Push.cs | 216 +- .../State/LuaState.Read.cs | 304 +-- .../State/LuaState.References.cs | 91 +- .../State/LuaState.Stack.cs | 214 +- .../State/LuaState.Tables.cs | 296 +- .../State/LuaState.Types.cs | 244 +- libs/CheatEngine.SDK.Lua/State/LuaState.cs | 184 +- libs/CheatEngine.SDK.Lua/State/LuaType.cs | 44 +- libs/CheatEngine.SDK.Lua/Text/Utf8Scratch.cs | 120 +- native/cheatengine-sdk-lua-bridge/README.md | 8 +- .../Emit/EngineApiFileEmitter.cs | 699 ++--- .../EngineApiDiagnostics.cs | 65 +- .../EngineApiGenerator.cs | 59 +- .../EngineApiTrackingNames.cs | 34 +- .../Model/SpecCallModel.cs | 16 +- .../Model/SpecContract.cs | 12 +- .../Model/SpecFileContract.cs | 10 +- .../Model/SpecFileModel.cs | 26 +- .../Model/SpecFiles.cs | 436 +-- .../Model/SpecIssue.cs | 8 +- .../Model/SpecIssueKind.cs | 8 +- .../Parsing/SpecFileParser.cs | 2406 +++++++++-------- .../Parsing/SpecIdentifiers.cs | 122 +- .../Parsing/SpecValueKinds.cs | 100 +- .../Emit/BootstrapEmitter.cs | 258 +- .../EntryPointGenerator.cs | 88 +- .../EntryPointTrackingNames.cs | 49 +- .../Model/BootstrapModel.cs | 71 +- .../Model/EntryPointOptions.cs | 15 +- .../Model/PluginModel.cs | 12 +- .../Parsing/EntryPointContractSymbols.cs | 127 +- .../EntryPointDeclaredDiagnosticIds.cs | 165 +- .../Parsing/EntryPointGeneratedIdentity.cs | 43 +- .../Parsing/PluginParser.cs | 97 +- .../Emit/LuaClassFileEmitter.cs | 440 +-- .../Emit/LuaFunctionFileEmitter.cs | 170 +- .../Emit/LuaGlobalFileEmitter.cs | 77 +- .../Emit/LuaObjectMembersFileEmitter.cs | 872 +++--- .../Emit/TypeScaffoldEmitter.cs | 59 +- .../LuaBindingsGenerator.cs | 282 +- .../LuaBindingsTrackingNames.cs | 121 +- .../Model/CompilationFacts.cs | 10 +- .../Model/ContainingTypeModel.cs | 8 +- .../Model/LuaClassModel.cs | 8 +- .../Model/LuaClassTables.cs | 59 +- .../Model/LuaFunctionModel.cs | 24 +- .../Model/LuaFunctionTableModel.cs | 18 +- .../Model/LuaFunctionTables.cs | 133 +- .../Model/LuaGlobalModel.cs | 18 +- .../Model/LuaGlobalTableModel.cs | 20 +- .../Model/LuaGlobalTables.cs | 124 +- .../Model/LuaObjectMembersTableModel.cs | 8 +- .../Model/LuaObjectMembersTables.cs | 173 +- .../Model/LuaObjectMethodModel.cs | 26 +- .../Model/LuaObjectPropertyModel.cs | 24 +- .../Parsing/AttributeArguments.cs | 30 +- .../Parsing/ContainingTypeParser.cs | 112 +- .../Parsing/LuaBindingSymbols.cs | 134 +- .../LuaBindingsDeclaredDiagnosticIds.cs | 183 +- .../Parsing/LuaClassParser.cs | 248 +- .../Parsing/LuaFunctionParser.cs | 96 +- .../Parsing/LuaGlobalParser.cs | 257 +- .../Parsing/LuaObjectMethodParser.cs | 433 +-- .../Parsing/LuaObjectPropertyParser.cs | 384 +-- .../Catalog/CatalogDiagnostic.cs | 10 +- .../Catalog/CatalogModel.cs | 6 +- .../Catalog/CatalogParseResult.cs | 6 +- .../Catalog/LuaBridgeContractDiagnostics.cs | 64 +- .../LuaBridgeContractGenerationPlan.cs | 38 +- .../ProtectedOperationCatalogParser.cs | 1752 ++++++------ .../Emit/LuaProtectedOperationEmitter.cs | 80 +- .../LuaBridgeContractGenerator.cs | 43 +- .../AnnotationsMetadataNames.cs | 94 +- .../BuildProperty.cs | 50 +- .../CSharpLiteral.cs | 218 +- .../EquatableArray.cs | 228 +- .../GeneratedCodeText.cs | 80 +- .../HintNames.cs | 272 +- .../LuaBindings/Model/ContainingTypeIssues.cs | 44 +- .../Model/LuaFunctionShapeIssues.cs | 108 +- .../LuaBindings/Model/LuaFunctionSignature.cs | 20 +- .../LuaBindings/Model/LuaGlobalShapeIssues.cs | 166 +- .../LuaBindings/Model/LuaGlobalSignature.cs | 31 +- .../Parsing/ContainingTypeShape.cs | 91 +- .../LuaBindings/Parsing/Identifiers.cs | 22 +- .../Parsing/LuaClassGeneratedNames.cs | 51 +- .../LuaBindings/Parsing/LuaFunctionShape.cs | 304 ++- .../LuaBindings/Parsing/LuaGlobalShape.cs | 564 ++-- .../Parsing/LuaMarshallerResolver.cs | 264 +- .../LuaBindings/Parsing/LuaValueKindMapper.cs | 136 +- .../LuaEmit/LuaApiNames.cs | 128 +- .../LuaEmit/LuaArgumentModel.cs | 40 +- .../LuaEmit/LuaCallForm.cs | 38 +- .../LuaEmit/LuaCustomMarshallerModel.cs | 8 +- .../LuaEmit/LuaGlobalCallEmitter.cs | 1317 ++++----- .../LuaEmit/LuaGlobalCallModel.cs | 117 +- .../LuaEmit/LuaNames.cs | 73 +- .../LuaEmit/LuaRegistrationEmitter.cs | 446 +-- .../LuaEmit/LuaResultModel.cs | 87 +- .../LuaEmit/LuaResultShape.cs | 22 +- .../LuaEmit/LuaThunkEmitter.cs | 351 +-- .../LuaEmit/LuaThunkModel.cs | 63 +- .../LuaEmit/LuaValueKind.cs | 64 +- .../LuaEmit/LuaValueKinds.cs | 176 +- .../ManagedEntryPointNames.cs | 20 +- .../Shapes/PluginShape.cs | 616 +++-- .../Shapes/PluginShapeIssues.cs | 104 +- .../SourceWriter.cs | 370 +-- .../TrackingNames.cs | 22 +- .../AbiArchitectureTests.cs | 82 +- .../AbiConstantsTests.cs | 30 +- .../AssemblyConformanceTests.cs | 251 +- .../CheatEngine.SDK.Abi.Tests/Bool32Tests.cs | 130 +- tests/CheatEngine.SDK.Abi.Tests/Bool8Tests.cs | 114 +- .../BoolCallBoundaryTests.cs | 229 +- .../Fixture/NativeAbiFixtureContractTests.cs | 128 +- .../Managed/ManagedEntryPointTests.cs | 30 +- .../Managed/ManagedExportedFunctionsTests.cs | 214 +- .../Managed/PluginInitRecordTests.cs | 232 +- .../Native/AutoAssemblerPhaseTests.cs | 40 +- .../ClassicDebugEventDispatcherTests.cs | 588 ++-- ...assicExportedFunctionsPrefixReaderTests.cs | 250 +- .../DebugEventObservationBufferTests.cs | 102 +- .../Native/ExportedFunctionsPrefixTests.cs | 218 +- .../Native/NativeExportNamesTests.cs | 14 +- .../Native/PluginCallbackShapeTests.cs | 317 +-- .../Native/PluginInitStructsTests.cs | 202 +- .../Native/PluginType0RecordTests.cs | 44 +- .../Native/PluginTypeTests.cs | 50 +- .../Native/PluginVersionTests.cs | 69 +- .../Native/RegisterModificationInfoTests.cs | 124 +- tests/CheatEngine.SDK.Abi.Tests/README.md | 10 +- .../Support/AbiShape.cs | 226 +- .../Support/AbiShapeTests.cs | 425 +-- .../Support/Layout.cs | 64 +- .../LuaDirectApiBoundaryGuardTests.cs | 1347 ++++----- .../Diagnostics/DiagnosticCatalogTests.cs | 514 ++-- .../Generation/LuaBindingAnalyzerTests.cs | 996 ++++--- .../LuaObjectBindingAnalyzerTests.cs | 826 +++--- .../Infrastructure/AnalyzerVerifier.cs | 101 +- .../CheatEngineSdkAnalyzerTest.cs | 26 +- .../CheatEngineSdkCodeFixTest.cs | 22 +- .../Infrastructure/CodeFixVerifier.cs | 74 +- .../Infrastructure/ContractStubs.cs | 27 +- .../DirectPackageAnalyzerConfigOptions.cs | 72 +- .../LocalFrameworkReferences.cs | 193 +- .../LocalFrameworkReferencesTests.cs | 101 +- .../Infrastructure/RepositoryLayout.cs | 43 +- .../Infrastructure/TestText.cs | 48 +- .../Plugin/EntryPointSwitchTests.cs | 173 +- .../Plugin/MultiplePluginClassesTests.cs | 370 +-- .../Plugin/PluginClassShapeCodeFixTests.cs | 1053 ++++---- .../Plugin/PluginClassShapeTests.cs | 1674 ++++++------ .../Plugin/PluginShapeParityTests.cs | 451 +-- .../Plugin/ReservedNamespaceTests.cs | 750 ++--- ...luginLifecycleAndOwnershipAnalyzerTests.cs | 240 +- .../UnmanagedCallersOnlyGuardCodeFixTests.cs | 277 +- .../Usage/UnmanagedCallersOnlyGuardTests.cs | 1168 ++++---- .../WellKnown/SdkSymbolResolverTests.cs | 149 +- .../BenchFunctions.cs | 16 +- .../BenchGlobals.cs | 14 +- .../CallbackBenchmarks.cs | 105 +- .../EngineApiIncrementalBenchmarks.cs | 364 +-- .../GlobalCallBenchmarks.cs | 80 +- .../MarshallerBenchmarks.cs | 225 +- .../MemoryScalarBenchmarks.cs | 324 +-- .../ObjectPropertyBenchmarks.cs | 75 +- tests/CheatEngine.SDK.Benchmarks/Program.cs | 12 +- .../Support/FakeHostRuntime.cs | 121 +- .../Utf8MarshallerBenchmarks.cs | 99 +- .../AddressList/AddressListLuaTests.cs | 432 +-- .../AddressList/AddressListMutationsTests.cs | 437 +-- .../AddressList/AddressListValueTests.cs | 108 +- .../Allocation/AllocatedRegionTests.cs | 380 +-- .../Allocation/AllocationOperationsFake.cs | 277 +- .../Allocation/HostAddressTests.cs | 52 +- ...uaTargetMemoryAllocationOperationsTests.cs | 845 +++--- .../TargetAllocationRequestTests.cs | 50 +- .../Allocation/TargetBoundAllocationTests.cs | 554 ++-- .../Allocation/TargetMemoryAllocatorTests.cs | 667 ++--- .../Allocation/TargetMemoryOutcomeTests.cs | 137 +- .../Assembly/AutoAssemblerPatcherTests.cs | 1042 +++---- .../Enums/CEEnumNamesTests.cs | 394 +-- .../Enums/EnumMarshallerTests.cs | 178 +- .../Enums/EnumValueTests.cs | 240 +- .../Errors/EngineExceptionTests.cs | 295 +- .../Inspection/EngineInspectionTests.cs | 740 ++--- .../Inspection/InspectionValueTests.cs | 62 +- .../Inspection/SymbolRegistryTests.cs | 547 ++-- .../Memory/MemoryApiTests.cs | 1007 +++---- .../Objects/CEObjectTests.cs | 956 +++---- .../Objects/CEObjectValueTests.cs | 104 +- .../Objects/HostCallCountTests.cs | 164 +- .../Objects/OwnedTests.cs | 606 ++--- .../Objects/StringListTests.cs | 295 +- .../Objects/ZeroAllocationTests.cs | 281 +- .../RuntimeProcessOperationsTests.cs | 565 ++-- .../Runtime/RuntimeContractsTests.cs | 410 +-- .../Scanning/Aob/AobScanOptionsTests.cs | 62 +- .../Scanning/Aob/AobScannerTests.cs | 688 ++--- .../Scanning/Aob/AobStringListTestHost.cs | 184 +- .../Scanning/MemoryScanSessionFactoryTests.cs | 633 ++--- .../Scanning/MemoryScanSessionTests.cs | 1265 ++++----- .../Support/AllocationGate.cs | 26 +- .../Support/DebugAssertFailedException.cs | 22 +- .../Support/DebugAssertScope.cs | 72 +- .../Support/EngineTest.cs | 143 +- .../Support/FakeHost.cs | 677 ++--- .../Support/HostScope.cs | 32 +- .../Tables/CheatTableFilesTests.cs | 91 +- .../Targets/TargetProcessIncarnationTests.cs | 61 +- .../Targets/TargetReleaseOutcomeTests.cs | 92 +- .../Targets/TargetSelectionTests.cs | 323 +-- .../Values/AddressLuaTests.cs | 297 +- .../Values/AddressTests.cs | 481 ++-- .../Values/IndexBaseTests.cs | 74 +- .../Values/LuaSequenceTests.cs | 144 +- .../Bootstrap/AnsiNameBufferTests.cs | 64 +- .../Bootstrap/GetVersionTests.cs | 204 +- .../Bootstrap/InitializeManagedTests.cs | 361 +-- .../Bootstrap/LuaModuleLocatorTests.cs | 210 +- .../Diagnostics/HostLogTests.cs | 184 +- .../Lifecycle/AdmissionLifecycleTests.cs | 52 +- .../Lifecycle/DisablePluginTests.cs | 799 +++--- .../Lifecycle/EnablePluginTests.cs | 836 +++--- .../Lifecycle/ReentrancyTests.cs | 398 +-- .../Support/AlternatePluginFactory.cs | 10 +- .../Support/CapturingLogSink.cs | 100 +- .../Support/FakeExports.cs | 242 +- .../Support/HostSimulator.cs | 144 +- .../Support/HostingTest.cs | 117 +- .../Support/NonAsciiNamePluginFactory.cs | 12 +- .../Support/NullReturningPluginFactory.cs | 10 +- .../Support/RecordingPlugin.cs | 450 +-- .../Support/RecordingPluginFactory.cs | 12 +- .../Support/ThrowingNamePluginFactory.cs | 10 +- .../Threading/MainThreadTests.cs | 516 ++-- .../CoexistenceDiagnostics.cs | 55 +- .../CoexistencePlugin.props | 24 +- ....SDK.LivePlugin.Coexistence.PluginA.csproj | 2 +- .../PluginA/CoexistencePluginA.cs | 37 +- .../PluginA/CoexistencePluginAFunctions.cs | 16 +- ....SDK.LivePlugin.Coexistence.PluginB.csproj | 2 +- .../PluginB/CoexistencePluginB.cs | 37 +- .../PluginB/CoexistencePluginBFunctions.cs | 16 +- .../README.md | 16 +- .../CheatEngineSdkLivePlugin.cs | 111 +- .../LiveFunctions.cs | 35 +- .../MemoryBindings.cs | 30 +- .../LiveProbeStateTests.cs | 6 +- .../ProbeHostGlobals.cs | 2 +- .../AuthorizationDecision.cs | 34 +- .../HostProfileObservation.cs | 308 ++- .../LiveProbeAuthorization.cs | 433 +-- .../LiveProbeState.cs | 1300 +++++---- tests/CheatEngine.SDK.LiveProbe/README.md | 91 +- .../Program.cs | 1819 ++++++++----- .../Constants/LuaConstantsTests.cs | 228 +- .../Fixture/BundledLuaLibraryTests.cs | 41 +- .../Fixture/NativeLuaProbeTests.cs | 74 +- .../Initialization/LuaApiBoundTableTests.cs | 134 +- .../LuaApiInitializationTests.cs | 90 +- .../Loading/LuaModuleTests.cs | 103 +- .../Macros/PureMacroTests.cs | 31 +- .../LuaBridgeContractBoundaryTests.cs | 463 ++-- .../Protected/LuaBridgeContractTests.cs | 480 ++-- .../Protected/LuaBridgeLoadingPolicyTests.cs | 45 +- .../Protected/LuaProtectedApiTests.cs | 511 ++-- .../RoundTrips/CallTests.cs | 534 ++-- .../RoundTrips/CallbackTests.cs | 593 ++-- .../RoundTrips/CoroutineTests.cs | 98 +- .../RoundTrips/DebugTests.cs | 256 +- .../RoundTrips/LibraryTests.cs | 179 +- .../RoundTrips/OperatorTests.cs | 210 +- .../RoundTrips/ReferenceTests.cs | 140 +- .../RoundTrips/ScalarTests.cs | 328 +-- .../RoundTrips/StackTests.cs | 248 +- .../RoundTrips/StateTests.cs | 334 +-- .../RoundTrips/StringTests.cs | 275 +- .../RoundTrips/TableTests.cs | 350 +-- .../RoundTrips/UserdataTests.cs | 102 +- .../Signatures/LuaApiSignatureTests.cs | 494 ++-- .../Signatures/NativeStructLayoutTests.cs | 72 +- .../Support/IlReader.cs | 107 +- .../Support/LuaTest.cs | 90 +- .../Allocation/ZeroAllocationTests.cs | 606 +++-- .../CallbackLifetimeConcurrencyTests.cs | 553 ++-- .../Callbacks/Counter.cs | 12 +- .../Callbacks/LuaCallbackRegistryTests.cs | 220 +- .../Callbacks/LuaCallbackTests.cs | 1629 +++++------ .../Callbacks/LuaHostSubscriptionTests.cs | 881 +++--- .../Callbacks/LuaNativeFunctionTests.cs | 83 +- .../Callbacks/Thunks.cs | 359 +-- .../Calls/LuaStatusTests.cs | 136 +- .../LuaGlobalFunctionsStateIdentityTests.cs | 261 +- .../Generated/MemoryBindings.cs | 112 +- .../Generated/ReadIntegerBindingTests.cs | 384 +-- .../Generated/StringBindingTests.cs | 241 +- .../Generated/StringBindings.cs | 92 +- .../Marshalling/MarshallerRoundTripTests.cs | 621 ++--- .../CheckStackFailureProcessTests.cs | 130 +- .../Protected/NativeFailureProcessTests.cs | 207 +- .../References/LuaRefEpochTests.cs | 235 +- .../References/LuaRefTests.cs | 391 +-- .../References/ReferenceConcurrencyTests.cs | 323 ++- .../Registration/LuaRegistrationSetTests.cs | 355 ++- .../Runtime/LuaRuntimeDetachedTests.cs | 177 +- .../Runtime/LuaRuntimeTests.cs | 588 ++-- .../Runtime/LuaUniverseQualificationTests.cs | 535 ++-- .../State/LuaFrameTests.cs | 201 +- .../State/LuaStateStackTests.cs | 732 ++--- .../State/LuaStateStringTests.cs | 278 +- .../State/LuaTypeTests.cs | 104 +- .../State/ProtectedOperationTests.cs | 714 ++--- .../Support/AllocationGate.cs | 26 +- .../Support/HostDouble.cs | 142 +- .../Support/LuaTest.cs | 57 +- .../Support/RuntimeScope.cs | 23 +- .../Text/Utf8ScratchTests.cs | 163 +- .../NativeAotLibraryProbeExportNames.cs | 15 +- .../NativeAotLibraryProbeExports.cs | 22 +- .../PortableExecutableExportReader.cs | 206 +- .../Program.cs | 348 +-- .../EndToEnd/MemoryScalarsEndToEndTests.cs | 316 +-- .../Generator/DiagnosticsTests.cs | 295 +- .../Generator/EmissionTests.cs | 462 ++-- .../Generator/IncrementalityTests.cs | 247 +- .../Generator/NoOutputTests.cs | 110 +- .../Infrastructure/Address.cs | 43 +- .../Infrastructure/AllocationGate.cs | 26 +- .../Infrastructure/GeneratedAssembly.cs | 89 +- .../Infrastructure/GeneratorRun.cs | 219 +- .../Infrastructure/HostDouble.cs | 33 +- .../Infrastructure/InMemoryAdditionalText.cs | 20 +- .../LocalFrameworkReferences.cs | 136 +- .../Infrastructure/LuaRuntimeSuite.cs | 4 +- .../Infrastructure/LuaTest.cs | 40 +- .../Infrastructure/ModelGraph.cs | 109 +- .../Infrastructure/RoslynEnvironment.cs | 82 +- .../Infrastructure/RoslynFixture.cs | 85 +- .../Infrastructure/RuntimeScope.cs | 16 +- .../Infrastructure/SpecSources.cs | 276 +- .../Infrastructure/StepAssert.cs | 96 +- .../Parsing/SpecFileParserTests.cs | 1529 +++++------ .../Generator/BootstrapExecutionTests.cs | 201 +- .../Generator/ContractIdentityTests.cs | 353 +-- .../Generator/DefaultVerifierTests.cs | 179 +- .../Generator/IncrementalityTests.cs | 512 ++-- .../Generator/KnownLimitationTests.cs | 114 +- .../Generator/LanguageVersionTests.cs | 34 +- .../LocalFrameworkReferencesTests.cs | 124 +- .../Generator/NameEscapingTests.cs | 140 +- .../Generator/NoOutputTests.cs | 486 ++-- .../Generator/NominalOutputTests.cs | 171 +- .../Generator/PluginLocationTests.cs | 588 ++-- .../Generator/RealAssemblyCompilationTests.cs | 172 +- .../Generator/ValidShapeTests.cs | 378 +-- .../Infrastructure/ContractStubs.cs | 106 +- .../Infrastructure/ExpectedBootstrap.cs | 142 +- .../Infrastructure/GeneratorRun.cs | 157 +- .../Infrastructure/LoadedBootstrap.cs | 127 +- .../LocalFrameworkReferences.cs | 146 +- .../Infrastructure/ModelGraph.cs | 109 +- .../Infrastructure/PluginSources.cs | 78 +- .../Infrastructure/RoslynEnvironment.cs | 197 +- .../Infrastructure/RoslynFixture.cs | 152 +- .../Infrastructure/StepAssert.cs | 96 +- .../TestAnalyzerConfigOptions.cs | 15 +- .../TestAnalyzerConfigOptionsProvider.cs | 62 +- .../Model/BootstrapModelTests.cs | 176 +- .../AnnotationsMetadataNamesTests.cs | 24 +- .../SharedCode/BuildPropertyTests.cs | 98 +- .../SharedCode/CSharpLiteralTests.cs | 201 +- .../SharedCode/EquatableArrayTests.cs | 234 +- .../SharedCode/GeneratedCodeTextTests.cs | 82 +- .../SharedCode/SourceWriterTests.cs | 403 +-- .../SharedCode/TrackingNamesTests.cs | 42 +- .../EndToEnd/LuaFunctionEndToEndTests.cs | 566 ++-- .../EndToEnd/LuaGlobalEndToEndTests.cs | 754 +++--- .../Generator/ContainingTypeTests.cs | 441 +-- .../Generator/DefaultVerifierTests.cs | 91 +- .../Generator/IncrementalityTests.cs | 593 ++-- .../Generator/LuaFunctionOutputTests.cs | 293 +- .../Generator/LuaGlobalOutputTests.cs | 523 ++-- .../Generator/LuaMarshallerOutputTests.cs | 376 +-- .../Generator/LuaObjectOutputTests.cs | 1341 ++++----- .../Generator/NoOutputTests.cs | 1049 +++---- .../Generator/PartialMethodSignatureTests.cs | 248 +- .../Infrastructure/AllocationGate.cs | 26 +- .../Infrastructure/BindingSources.cs | 260 +- .../Infrastructure/ExpectedFiles.cs | 542 ++-- .../Infrastructure/GeneratedAssembly.cs | 113 +- .../Infrastructure/GeneratorRun.cs | 189 +- .../Infrastructure/HostDouble.cs | 33 +- .../LocalFrameworkReferences.cs | 146 +- .../Infrastructure/LuaRuntimeSuite.cs | 4 +- .../Infrastructure/LuaTest.cs | 117 +- .../Infrastructure/ModelGraph.cs | 97 +- .../Infrastructure/RoslynEnvironment.cs | 92 +- .../Infrastructure/RoslynFixture.cs | 118 +- .../Infrastructure/RuntimeScope.cs | 16 +- .../Infrastructure/StepAssert.cs | 96 +- .../Model/LuaFunctionTablesTests.cs | 216 +- .../Model/LuaGlobalTablesTests.cs | 168 +- .../SharedCode/HintNamesTests.cs | 173 +- .../SharedCode/LuaApiNamesTests.cs | 156 +- .../SharedCode/LuaGlobalCallEmitterTests.cs | 406 +-- .../SharedCode/LuaNamesTests.cs | 88 +- .../SharedCode/LuaThunkEmitterTests.cs | 244 +- .../SharedCode/LuaValueKindsTests.cs | 100 +- .../Generator/CatalogDiagnosticsTests.cs | 194 +- .../Generator/CatalogEmissionTests.cs | 238 +- .../Infrastructure/CatalogSources.cs | 56 +- .../Infrastructure/GeneratorRun.cs | 98 +- .../Infrastructure/InMemoryAdditionalText.cs | 20 +- .../Infrastructure/ProductionCatalog.cs | 19 +- .../Infrastructure/RoslynFixture.cs | 79 +- .../NativeLua/NativeLuaLibrary.cs | 71 +- .../NativeLua/NativeLuaProbe.cs | 103 +- .../NativeLua/NativeLuaState.cs | 96 +- .../ProjectDependencyDirectionTests.cs | 724 ++--- .../Infrastructure/EntryPointProbe.cs | 57 +- .../Infrastructure/NupkgInspector.cs | 36 +- .../Infrastructure/PackagedUmbrellaFixture.cs | 1156 ++++---- .../Infrastructure/PackagedUmbrellaSuite.cs | 4 +- .../PortableExecutableInspector.cs | 583 ++-- .../Infrastructure/ProcessResult.cs | 12 +- .../Infrastructure/ProcessRunner.cs | 108 +- .../Infrastructure/RepositoryLayout.cs | 43 +- .../Infrastructure/ThrowawayConsumer.cs | 1492 +++++----- .../Infrastructure/ThrowawayPackageCarrier.cs | 134 +- .../Infrastructure/UmbrellaPackage.cs | 21 +- .../Packaging/BuildPropertyDefaultsTests.cs | 66 +- .../Packaging/DeploymentLayoutTests.cs | 50 +- .../DirectReferenceIsolationTests.cs | 44 +- .../Packaging/EntryPointTests.cs | 248 +- .../NativeBridgePackagingAuditTests.cs | 35 +- .../Packaging/NativeBridgePeAuditTests.cs | 299 +- .../Packaging/NuspecDependencyTests.cs | 28 +- .../Packaging/PackageContentsTests.cs | 122 +- .../Packaging/PackedConsumerBindingTests.cs | 164 +- .../Packaging/PlatformTargetTests.cs | 70 +- .../Packaging/RestoreIsolationTests.cs | 48 +- .../TargetBoundAllocationConsumerTests.cs | 10 +- tests/CheatEngine.SDK.Tests/README.md | 36 +- tests/native-abi-fixture/README.md | 6 +- 775 files changed, 83560 insertions(+), 75228 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .github/workflows/sonar.yml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..f238dc0e --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,131 @@ +# yaml-language-server: $schema=https://storage.googleapis.com/coderabbit_public_assets/schema.v2.json + +language: en-US +early_access: false + +reviews: + # Keep automated review useful for real defects without turning the App into a second formatter or approval process. + profile: chill + request_changes_workflow: false + review_details: false + review_progress: true + commit_status: false + fail_commit_status: false + high_level_summary: true + high_level_summary_instructions: >- + Summarize only material SDK contract, native ABI, generator, test, packaging, or CI/CD effects. Keep it concise. + collapse_walkthrough: true + changed_files_summary: false + sequence_diagrams: false + estimate_code_review_effort: false + assess_linked_issues: false + related_issues: false + related_prs: false + suggested_labels: false + suggested_reviewers: false + auto_apply_labels: false + auto_assign_reviewers: false + in_progress_fortune: false + poem: false + enable_prompt_for_ai_agents: false + abort_on_close: true + slop_detection: + enabled: false + # This repository uses the GitHub App for review, not a CodeRabbit coding agent or autonomous follow-up changes. + pre_merge_checks: + title: + mode: 'off' + description: + mode: 'off' + issue_assessment: + mode: 'off' + docstrings: + mode: 'off' + finishing_touches: + docstrings: + enabled: false + unit_tests: + enabled: false + autofix: + enabled: false + fix_ci: + enabled: false + resolve_merge_conflict: + enabled: false + auto_review: + enabled: true + auto_incremental_review: true + drafts: false + base_branches: [ main ] + # Exclude only generated outputs and non-reviewable binary payloads. Source, specifications, tests, CI and docs stay in scope. + path_filters: + - '!artifacts/**' + - '!TestResults/**' + - '!**/bin/**' + - '!**/obj/**' + - '!**/*.dll' + - '!**/*.exe' + - '!**/*.pdb' + - '!**/*.coverage' + - '!**/*.nupkg' + - '!**/*.snupkg' + path_instructions: + - path: 'libs/CheatEngine.SDK.Abi/**' + instructions: >- + This is the low-level Cheat Engine ABI boundary. Treat layouts, offsets, packing, signatures, calling + conventions and marshalling as compatibility contracts. Require x64 fixture evidence and never infer live-host + qualification from a fixture-only test. + - path: 'native/**' + instructions: >- + Review native bridge changes for exported symbol names, x64 ABI stability, deterministic builds and source + fingerprints. Do not weaken reproducibility or substitute fixture evidence for interaction with a live Cheat + Engine process. + - path: 'libs/CheatEngine.SDK.Lua/**' + instructions: >- + Preserve Lua stack balance, native error boundaries, callback lifetime and explicit ownership. Protected Lua + operation invariants are enforced by C# contract tests; do not propose restoring retired script-only CI gates. + - path: 'libs/CheatEngine.SDK.Lua.Interop/**' + instructions: >- + Preserve generated binding names, P/Invoke shapes, callback lifetime and protected Lua boundary behavior. + Flag changes that silently alter the public native contract or move Cheat Engine mappings into the Client. + - path: 'analyzers/**' + instructions: >- + Analyzer diagnostics and fixes are consumer-facing contracts. Review generated-code compatibility, diagnostic + identifiers, Roslyn version constraints and focused regression tests. + - path: 'source-generators/**' + instructions: >- + Generators define SDK contracts. Require synchronized changes to parsing, emitted source, public API shape and + generator tests; do not accept Client-specific Cheat Engine mappings here. + - path: 'src/**' + instructions: >- + This package is the low-level SDK boundary. Preserve direct-consumer packaging semantics, native bridge assets + and binary compatibility. Fluent developer workflows and application policy belong in CheatEngine.Client. + - path: 'tests/**' + instructions: >- + Tests use xUnit v3 with Microsoft.Testing.Platform. Debug CI rejects skipped tests. Distinguish fixture, + package and NativeAOT probes from actual live Cheat Engine host qualification. + - path: '.github/**' + instructions: >- + Preserve the discover/native/build/test/pack/AOT/gate DAG and its bridge, coverage and NuGet artifact flows. + Require least-privilege permissions and pinned action SHAs. actionlint already runs in pull-request CI; do not + ask for a duplicate CodeRabbit actionlint run. Never use pull_request_target to check out or execute code from + forks. Sonar secrets must remain unavailable to forks. + tools: + # CodeRabbit is used as the GitHub App; pipeline failures are surfaced through its GitHub Checks integration. + github-checks: + enabled: true + # pull-request-ci.yml is the deterministic actionlint owner. + actionlint: + enabled: false + +chat: + auto_reply: true + +knowledge_base: + automatic_linking_mode: disabled + linked_repositories: + - repository: CheatEngineNet/CheatEngine.Client + instructions: >- + This is the high-level developer-focused Client. It depends on this SDK for ABI, native Lua and Cheat Engine + contracts, but owns fluent APIs, application policy, dependency injection and user workflows. Flag accidental + duplication or inversion of that boundary. diff --git a/.editorconfig b/.editorconfig index c1897ef1..22262b1b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -138,6 +138,11 @@ dotnet_naming_style.underscore_camel_case.capitalization = camel_case # IDE0005 requires XML documentation generation to run during a command-line # build. Shipping projects already enable it; tests and samples intentionally # retain it as an editor suggestion rather than acquiring build-only XML output. + + +# CS8762: Parameter must have a non-null value when exiting in some condition. +dotnet_diagnostic.CS8762.severity = none + [libs/**.cs] dotnet_diagnostic.IDE0005.severity = error diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 009ae873..ca5b83e3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,8 @@ Closes ## Scope and architectural ownership -Describe resulting behavior, affected contracts, and exclusions. SDK owns CE integration; Client owns workflows and policy. +Describe resulting behavior, affected contracts, and exclusions. SDK owns CE integration; Client owns workflows and +policy. ## Dependencies and containing artifacts @@ -12,12 +13,12 @@ Link upstream prerequisites without closing them. Identify the SDK package conta ## Validation actually performed -| Check | Command / profile | Actual result | Evidence | -|---|---|---|---| -| Unit / fixture | | Not executed | | -| Packed consumer | | Not executed | | -| Live host | | Not executed | | -| AOT publication | | Not executed | | +| Check | Command / profile | Actual result | Evidence | +|-----------------|-------------------|---------------|----------| +| Unit / fixture | | Not executed | | +| Packed consumer | | Not executed | | +| Live host | | Not executed | | +| AOT publication | | Not executed | | ## Compatibility, lifetime and partial effects diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f7bfe0..cb9e046b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,10 @@ name: CI on: workflow_call: inputs: + collect-coverage: + description: Emit Visual Studio XML coverage from Debug test jobs and upload it as coverage-. + type: boolean + default: false upload-package: description: Upload the packed package as the nuget-package artifact. type: boolean @@ -11,6 +15,10 @@ on: description: Also run every test project against the Release build, which is what ships. type: boolean default: false + secrets: + SONAR_TOKEN: + description: SonarQube Cloud token for protected same-repository analysis. + required: false permissions: contents: read @@ -74,13 +82,9 @@ jobs: with: xmake-version: '3.0.9' - # This is source-only and must precede both native builds: it makes the operation catalogue, C enum/switch, - # managed operation enum/wrappers and contract bitmap one reproducible gate. - - name: Validate protected Lua operation catalogue - run: pwsh -NoProfile -File eng/lua-bridge/Test-ProtectedOperationCatalog.ps1 - - name: Build bridge run: | + $ErrorActionPreference = 'Stop' # xmake 3.0.9 mis-parses an absolute Windows -o path when the target # uses $(builddir); keep both xmake paths relative and resolve them # only for PowerShell validation and cleanup. @@ -105,14 +109,26 @@ jobs: } xmake f -P native/cheatengine-sdk-lua-bridge -o $primaryOutput -p windows -a x64 -m release -y + if ($LASTEXITCODE -ne 0) { + throw "xmake configuration for the primary bridge output failed with exit code $LASTEXITCODE." + } xmake -P native/cheatengine-sdk-lua-bridge -y + if ($LASTEXITCODE -ne 0) { + throw "xmake build for the primary bridge output failed with exit code $LASTEXITCODE." + } $primaryBridge = Join-Path $primaryOutputPath 'cheatengine-sdk-lua-bridge.dll' if (-not (Test-Path -LiteralPath $primaryBridge -PathType Leaf)) { throw "xmake did not produce '$primaryBridge'." } xmake f -P native/cheatengine-sdk-lua-bridge -o $reproducibilityOutput -p windows -a x64 -m release -y + if ($LASTEXITCODE -ne 0) { + throw "xmake configuration for the reproducibility bridge output failed with exit code $LASTEXITCODE." + } xmake -P native/cheatengine-sdk-lua-bridge -y + if ($LASTEXITCODE -ne 0) { + throw "xmake build for the reproducibility bridge output failed with exit code $LASTEXITCODE." + } $reproducibilityBridge = Join-Path $reproducibilityOutputPath 'cheatengine-sdk-lua-bridge.dll' if (-not (Test-Path -LiteralPath $reproducibilityBridge -PathType Leaf)) { throw "xmake did not produce '$reproducibilityBridge'." @@ -154,7 +170,12 @@ jobs: # This compiles the C++ transcription under MSVC x64 and validates its emitted facts. It proves the checked-in # header fixture and managed layout numbers agree; it does not contact or qualify a live Cheat Engine host. - name: Build and validate classic ABI fixture - run: ./tests/native-abi-fixture/build.ps1 -OutputDirectory artifacts/native-abi-fixture + run: | + $ErrorActionPreference = 'Stop' + ./tests/native-abi-fixture/build.ps1 -OutputDirectory artifacts/native-abi-fixture + if ($LASTEXITCODE -ne 0) { + throw "Classic ABI fixture build failed with exit code $LASTEXITCODE." + } # The fixture's fixed facts are first schema-validated in its build script. Build the managed ABI test app and # pass that same file to its direct comparer so C++ x64 measurements are checked against managed sizeof, offset, @@ -170,8 +191,15 @@ jobs: CE77_NATIVE_ABI_FACTS_PATH: ${{ github.workspace }}/artifacts/native-abi-fixture/ce77-native-abi-facts.txt CE77_NATIVE_ABI_REQUIRED: 'true' run: | + $ErrorActionPreference = 'Stop' dotnet build tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj -c Debug --no-restore --disable-build-servers + if ($LASTEXITCODE -ne 0) { + throw "Managed ABI fact comparer build failed with exit code $LASTEXITCODE." + } & ./artifacts/bin/CheatEngine.SDK.Abi.Tests/debug/CheatEngine.SDK.Abi.Tests.exe --fail-skips on + if ($LASTEXITCODE -ne 0) { + throw "Managed ABI fact comparer exited with code $LASTEXITCODE." + } - name: Upload classic ABI fixture facts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -206,16 +234,28 @@ jobs: # Tests run against Debug, where some guards exist only. Release is what ships. - name: Build Debug - run: dotnet build CheatEngine.SDK.slnx --no-restore -c Debug + run: | + dotnet build CheatEngine.SDK.slnx --no-restore -c Debug + if ($LASTEXITCODE -ne 0) { + throw "Debug solution build failed with exit code $LASTEXITCODE." + } # Do not rely only on the per-project matrix below: native MTP must also discover every test executable when # invoked through the solution. The preceding build is deliberately part of this job, so --no-build cannot # silently exercise stale or incomplete test-host output. - name: Test Debug solution discovery - run: dotnet test --solution CheatEngine.SDK.slnx --no-build --no-restore -c Debug --fail-skips on + run: | + dotnet test --solution CheatEngine.SDK.slnx --no-build --no-restore -c Debug --fail-skips on + if ($LASTEXITCODE -ne 0) { + throw "Debug solution test discovery failed with exit code $LASTEXITCODE." + } - name: Build Release - run: dotnet build CheatEngine.SDK.slnx --no-restore -c Release + run: | + dotnet build CheatEngine.SDK.slnx --no-restore -c Release + if ($LASTEXITCODE -ne 0) { + throw "Release solution build failed with exit code $LASTEXITCODE." + } test: name: Test ${{ matrix.label }} @@ -248,10 +288,15 @@ jobs: PROJECT: ${{ matrix.project }} CONFIGURATION: ${{ matrix.configuration }} RESULTS: artifacts/test-results/${{ matrix.artifact }} + COLLECT_COVERAGE: ${{ inputs.collect-coverage }} run: | $options = '--project', $env:PROJECT, '-c', $env:CONFIGURATION, '--report-trx', '--results-directory', $env:RESULTS if ($env:CONFIGURATION -eq 'Debug') { $options += '--fail-skips', 'on' } + if ($env:CONFIGURATION -eq 'Debug' -and $env:COLLECT_COVERAGE -eq 'true') { $options += '--coverage', '--coverage-output-format', 'xml', '--coverage-output', 'coverage.xml' } dotnet test @options + if ($LASTEXITCODE -ne 0) { + throw "Test project '$env:PROJECT' failed with exit code $LASTEXITCODE." + } - name: Summarize results if: ${{ !cancelled() }} @@ -289,6 +334,15 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Upload coverage + if: ${{ inputs.collect-coverage && matrix.configuration == 'Debug' && !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ matrix.name }} + path: artifacts/test-results/${{ matrix.artifact }}/coverage.xml + if-no-files-found: error + retention-days: 7 + pack: name: Pack needs: native @@ -311,7 +365,11 @@ jobs: path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native - name: Pack - run: dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget + run: | + dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget + if ($LASTEXITCODE -ne 0) { + throw "SDK package creation failed with exit code $LASTEXITCODE." + } - name: Verify package run: | @@ -347,8 +405,15 @@ jobs: - name: Restore Native AOT library loader harness run: | + $ErrorActionPreference = 'Stop' dotnet restore tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj + if ($LASTEXITCODE -ne 0) { + throw "Native AOT library probe restore failed with exit code $LASTEXITCODE." + } dotnet restore tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj + if ($LASTEXITCODE -ne 0) { + throw "Native AOT loader harness restore failed with exit code $LASTEXITCODE." + } - name: Use CI-built native bridge uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -359,8 +424,12 @@ jobs: # This executable is an AOT/trim graph probe only. It is not a plugin candidate for Cheat Engine's hostfxr path. - name: Publish and run Native AOT probe run: | + $ErrorActionPreference = 'Stop' $output = Join-Path $PWD 'artifacts/aot-probe' dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output + if ($LASTEXITCODE -ne 0) { + throw "Native AOT probe publish failed with exit code $LASTEXITCODE." + } $probe = Join-Path $output 'CheatEngine.SDK.AotProbe.exe' if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { throw "Native AOT publish did not produce '$probe'." @@ -373,11 +442,18 @@ jobs: # This remains a test-library observation. It never starts Cheat Engine, invokes an export, or unloads a NativeAOT DLL. - name: Publish and inspect Native AOT library probe run: | + $ErrorActionPreference = 'Stop' $profileOutput = Join-Path $PWD 'artifacts/nativeaot-loader-profile' $libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj' $harnessProject = 'tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj' dotnet publish $libraryProject -c Release --no-restore -o $profileOutput + if ($LASTEXITCODE -ne 0) { + throw "Native AOT shared-library publish failed with exit code $LASTEXITCODE." + } dotnet publish $harnessProject -c Release --no-restore -o $profileOutput + if ($LASTEXITCODE -ne 0) { + throw "Native AOT loader harness publish failed with exit code $LASTEXITCODE." + } $library = Join-Path $profileOutput 'CheatEngine.SDK.NativeAotLibraryProbe.dll' $harness = Join-Path $profileOutput 'CheatEngine.SDK.NativeAotLoaderHarness.exe' if (-not (Test-Path -LiteralPath $library -PathType Leaf)) { @@ -391,23 +467,85 @@ jobs: & $harness --load --acknowledge-process-resident-load if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness load exited with code $LASTEXITCODE." } - # The one check to require in branch protection. + sonar: + name: Sonar + needs: [ native, test ] + if: ${{ inputs.collect-coverage }} + uses: ./.github/workflows/sonar.yml + with: + wait-quality-gate: true + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + lint-workflows: + name: Lint workflows + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }} + runs-on: windows-latest + timeout-minutes: 5 + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9 + steps: + - name: Checkout workflow definitions + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: .github + persist-credentials: false + + - name: Run actionlint + run: | + $ErrorActionPreference = 'Stop' + $archive = Join-Path $env:RUNNER_TEMP 'actionlint.zip' + $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_windows_amd64.zip" + Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3 -RetryIntervalSec 5 + $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ACTIONLINT_SHA256) { + throw "actionlint $env:ACTIONLINT_VERSION has SHA-256 $actual, expected $env:ACTIONLINT_SHA256." + } + $destination = Join-Path $env:RUNNER_TEMP 'actionlint' + Expand-Archive -LiteralPath $archive -DestinationPath $destination + & (Join-Path $destination 'actionlint.exe') -color + if ($LASTEXITCODE -ne 0) { + throw "actionlint failed with exit code $LASTEXITCODE." + } + gate: name: Gate if: ${{ always() }} - needs: [ discover, native, build, test, pack, aot ] + needs: [ discover, native, build, test, pack, aot, sonar, lint-workflows ] runs-on: windows-latest timeout-minutes: 5 permissions: { } steps: - name: Check results env: - NEEDS: ${{ toJSON(needs) }} + DISCOVER_RESULT: ${{ needs.discover.result }} + NATIVE_RESULT: ${{ needs.native.result }} + BUILD_RESULT: ${{ needs.build.result }} + TEST_RESULT: ${{ needs.test.result }} + PACK_RESULT: ${{ needs.pack.result }} + AOT_RESULT: ${{ needs.aot.result }} + SONAR_RESULT: ${{ needs.sonar.result }} + SONAR_REQUIRED: ${{ inputs.collect-coverage }} + LINT_RESULT: ${{ needs.lint-workflows.result }} run: | - $jobs = $env:NEEDS | ConvertFrom-Json -AsHashtable - $rows = $jobs.GetEnumerator() | Sort-Object Key | ForEach-Object { "| $($_.Key) | $($_.Value.result) |" } + $results = [ordered]@{ + discover = $env:DISCOVER_RESULT + native = $env:NATIVE_RESULT + build = $env:BUILD_RESULT + test = $env:TEST_RESULT + pack = $env:PACK_RESULT + aot = $env:AOT_RESULT + sonar = $env:SONAR_RESULT + 'lint-workflows' = $env:LINT_RESULT + } + $rows = $results.GetEnumerator() | ForEach-Object { "| $($_.Key) | $($_.Value) |" } '| Job | Result |', '| --- | --- |', $rows | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - $failed = @($jobs.GetEnumerator() | Where-Object { $_.Value.result -ne 'success' } | ForEach-Object Key) + $failed = @($results.GetEnumerator() | Where-Object { + $_.Key -in 'discover', 'native', 'build', 'test', 'pack', 'aot' -and $_.Value -ne 'success' -or + $_.Key -eq 'sonar' -and $env:SONAR_REQUIRED -eq 'true' -and $_.Value -ne 'success' -or + $_.Key -eq 'lint-workflows' -and $_.Value -ne 'success' + } | ForEach-Object Key) if ($failed.Count -gt 0) { Write-Host "::error::Not successful: $($failed -join ', ')." exit 1 diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index f44b782e..08cc25a8 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -18,3 +18,7 @@ jobs: ci: name: CI uses: ./.github/workflows/ci.yml + with: + collect-coverage: ${{ vars.SONAR_CI_ENABLED == 'true' && github.ref == 'refs/heads/main' }} + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 11956087..0b1f45e0 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -16,32 +16,11 @@ jobs: name: CI if: github.event.pull_request.draft == false uses: ./.github/workflows/ci.yml - - # Lints every workflow and local action reference, since main-ci.yml and release.yml never run on a pull request. - lint-workflows: - name: Lint workflows - if: github.event.pull_request.draft == false - runs-on: windows-latest - timeout-minutes: 5 - env: - ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - sparse-checkout: .github - persist-credentials: false - - - name: Run actionlint - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $zip = Join-Path $env:RUNNER_TEMP 'actionlint.zip' - $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_${env:ACTIONLINT_VERSION}_windows_amd64.zip" - Invoke-WebRequest -Uri $url -OutFile $zip -MaximumRetryCount 3 -RetryIntervalSec 5 - $actual = (Get-FileHash -Path $zip -Algorithm SHA256).Hash - if ($actual -ne $env:ACTIONLINT_SHA256) { throw "actionlint $env:ACTIONLINT_VERSION has SHA-256 $actual, expected $env:ACTIONLINT_SHA256." } - $dir = Join-Path $env:RUNNER_TEMP 'actionlint' - Expand-Archive -Path $zip -DestinationPath $dir - & (Join-Path $dir 'actionlint.exe') -color + with: + collect-coverage: >- + ${{ vars.SONAR_CI_ENABLED == 'true' + && github.event.pull_request.draft == false + && github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.user.login != 'dependabot[bot]' }} + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 00000000..db215816 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,214 @@ +name: Sonar + +on: + workflow_call: + inputs: + project-key: + description: SonarQube Cloud project key. + type: string + default: CheatEngineNet_CheatEngine.SDK + organization: + description: SonarQube Cloud organization key. + type: string + default: cheatenginenet + wait-quality-gate: + description: Fail the job when the quality gate fails. + type: boolean + default: true + secrets: + SONAR_TOKEN: + description: SonarQube Cloud token. + required: true + +permissions: + contents: read + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + SONAR_SCANNER_VERSION: 11.3.0 + +defaults: + run: + shell: pwsh + +jobs: + analyze: + name: Analyze + runs-on: windows-latest + timeout-minutes: 30 + env: + SONAR_PROJECT_KEY: ${{ inputs.project-key }} + SONAR_ORGANIZATION: ${{ inputs.organization }} + SONAR_WAIT_QUALITY_GATE: ${{ inputs.wait-quality-gate }} + steps: + - name: Require token + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:SONAR_TOKEN)) { + throw 'SONAR_TOKEN is required when SONAR_CI_ENABLED is true.' + } + + # CI analysis and Automatic Analysis cannot run for the same SonarQube Cloud project. Keep this preflight before + # checkout so a PR cannot turn a configuration failure into arbitrary checkout code running in this tokened job. + - name: Require CI-based analysis + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + $uri = "https://sonarcloud.io/api/settings/values?component=$env:SONAR_PROJECT_KEY&keys=sonar.autoscan.enabled" + $response = Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $env:SONAR_TOKEN" } -TimeoutSec 30 -MaximumRetryCount 2 -RetryIntervalSec 3 + if (@($response.settings | Where-Object { $_.key -eq 'sonar.autoscan.enabled' -and $_.value -eq 'true' })) { + throw "Automatic Analysis is enabled on $env:SONAR_PROJECT_KEY. Disable it in SonarQube Cloud: Administration > Analysis Method before enabling SONAR_CI_ENABLED." + } + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # SonarScanner for .NET uses Java. Pin the same JDK 21 distribution as CheatEngine.Client so scanner behavior is + # reproducible across the SDK and its high-level consumer. + - name: Set up JDK 21 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: zulu + java-version: '21' + + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - name: Use CI-built native bridge + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: lua-protection-bridge + path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native + + - name: Download coverage + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-* + path: ${{ runner.temp }}/coverage + + # Do not use the checked-out nuget.config to resolve tooling. This job later uses SONAR_TOKEN, so the scanner + # package and every restored dependency must come only from nuget.org, not a source a pull request can redirect. + - name: Create NuGet.org-only configuration + id: nuget-config + run: | + $ErrorActionPreference = 'Stop' + $path = Join-Path $env:RUNNER_TEMP 'sonar-nuget.config' + @' + + + + + + + + '@ | Set-Content -LiteralPath $path -Encoding utf8NoBOM + "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # Restore before scanner begin: the subsequent analysis build is --no-restore, so no PR-supplied source can + # participate while scanner credentials are configured. + - name: Restore from NuGet.org + env: + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} + run: | + dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG + if ($LASTEXITCODE -ne 0) { + throw "Sonar restore failed with exit code $LASTEXITCODE." + } + + - name: Install scanner + env: + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} + run: | + dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" --version $env:SONAR_SCANNER_VERSION --configfile $env:NUGET_CONFIG + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner installation failed with exit code $LASTEXITCODE." + } + + # The begin step ignores the SONAR_TOKEN variable, so the token travels in SONARQUBE_SCANNER_PARAMS, which both scanner steps read. It stays off the command line. + - name: Begin analysis + env: + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: | + $coverage = "$env:RUNNER_TEMP/coverage" + $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) + if ($reports.Count -eq 0) { + Write-Host '::error::No coverage report was downloaded. Call ci.yml with collect-coverage set to true.' + exit 1 + } + Write-Host "Coverage reports: $($reports.Count)" + # These findings conflict with deliberate repository contracts. Keep them in the scanner configuration so + # production and test sources do not need Sonar-only attributes or code changes. + $ignoredIssues = @( + @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } + @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } + @{ Key = 'nullableFlow'; Rule = 'csharpsquid:S8970'; Resource = '**/*.cs' } + @{ Key = 'luaExportNames'; Rule = 'csharpsquid:S3218'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Api/**' } + @{ Key = 'luaTypeNames'; Rule = 'csharpsquid:S101'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Types/**' } + @{ Key = 'interopShape'; Rule = 'csharpsquid:S107'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs' } + @{ Key = 'callbackDispose'; Rule = 'csharpsquid:S3881'; Resource = 'libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs' } + @{ Key = 'memoryProtectionName'; Rule = 'csharpsquid:S2342'; Resource = 'libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs' } + @{ Key = 'descriptorDispatch'; Rule = 'csharpsquid:S1694'; Resource = 'libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs' } + @{ Key = 'apiOverloadLayout'; Rule = 'csharpsquid:S4136'; Resource = 'libs/**' } + @{ Key = 'roslynInstances'; Rule = 'csharpsquid:S2325'; Resource = 'analyzers/**' } + @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } + @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs' } + @{ Key = 'analyzerComments'; Rule = 'csharpsquid:S125'; Resource = 'analyzers/**' } + @{ Key = 'interopComments'; Rule = 'csharpsquid:S125'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Api/**' } + @{ Key = 'testReflection'; Rule = 'csharpsquid:S3011'; Resource = 'tests/**' } + @{ Key = 'testLiterals'; Rule = 'csharpsquid:S1192'; Resource = 'tests/**' } + @{ Key = 'testGc'; Rule = 'csharpsquid:S1215'; Resource = 'tests/**' } + @{ Key = 'testStaticHooks'; Rule = 'csharpsquid:S2696'; Resource = 'tests/**' } + @{ Key = 'testDoubleDispose'; Rule = 'csharpsquid:S3966'; Resource = 'tests/**' } + @{ Key = 'testBooleanTables'; Rule = 'csharpsquid:S1125'; Resource = 'tests/**' } + @{ Key = 'testComplexity'; Rule = 'csharpsquid:S3776'; Resource = 'tests/**' } + @{ Key = 'testParameters'; Rule = 'csharpsquid:S107'; Resource = 'tests/**' } + @{ Key = 'testMarkerClasses'; Rule = 'csharpsquid:S1118'; Resource = 'tests/**' } + @{ Key = 'testDuplicateScenarios'; Rule = 'csharpsquid:S4144'; Resource = 'tests/**' } + @{ Key = 'testFixedDoubles'; Rule = 'csharpsquid:S3400'; Resource = 'tests/**' } + @{ Key = 'testComments'; Rule = 'csharpsquid:S125'; Resource = 'tests/**' } + ) + $arguments = @( + "/k:$env:SONAR_PROJECT_KEY" + "/o:$env:SONAR_ORGANIZATION" + '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**' + # CI publishes managed coverage only. Keep build-time tooling and test-only sources out of the product + # coverage metric instead of presenting an incomplete report as if it covered those paths. + '/d:sonar.coverage.exclusions=tests/**,eng/**' + "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" + "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" + "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" + '/d:sonar.qualitygate.timeout=300' + ) + foreach ($issue in $ignoredIssues) { + $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).ruleKey=$($issue.Rule)" + $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).resourceKey=$($issue.Resource)" + } + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner begin failed with exit code $LASTEXITCODE." + } + + - name: Build + run: | + dotnet build CheatEngine.SDK.slnx -c Debug --no-restore --no-incremental --disable-build-servers + if ($LASTEXITCODE -ne 0) { + throw "Sonar analysis build failed with exit code $LASTEXITCODE." + } + + - name: End analysis + env: + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: | + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner end failed with exit code $LASTEXITCODE." + } diff --git a/AGENTS.md b/AGENTS.md index aab93af1..b283127b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,22 +25,35 @@ dotnet test --solution CheatEngine.SDK.slnx -c Release dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget ``` -These restore dependencies, compile the solution, validate both configurations, and produce the package. For host testing, follow `tests/CheatEngine.SDK.LivePlugin/README.md`; keep the complete plugin output together, including `cheatengine-sdk-lua-bridge.dll`. Configure Cheat Engine to use .NET 10 explicitly. +These restore dependencies, compile the solution, validate both configurations, and produce the package. For host +testing, follow `tests/CheatEngine.SDK.LivePlugin/README.md`; keep the complete plugin output together, including +`cheatengine-sdk-lua-bridge.dll`. Configure Cheat Engine to use .NET 10 explicitly. ## Coding Style & Naming Conventions -Follow `.editorconfig`: UTF-8; C# uses tabs for logical nesting and spaces only for continuation alignment; configuration/project files use two spaces. `.gitattributes` owns line-ending normalization for the working tree. Use file-scoped namespaces, explicit accessibility, PascalCase public members, and existing local naming patterns. Preserve native Lua identifiers and ABI layouts. +Follow `.editorconfig`: UTF-8; C# uses tabs for logical nesting and spaces only for continuation alignment; +configuration/project files use two spaces. `.gitattributes` owns line-ending normalization for the working tree. Use +file-scoped namespaces, explicit accessibility, PascalCase public members, and existing local naming patterns. Preserve +native Lua identifiers and ABI layouts. -Builds enforce compiler and analyzer diagnostics as errors, with configured exceptions. Document public APIs and provide a README beside every project. +Builds enforce compiler and analyzer diagnostics as errors, with configured exceptions. Document public APIs and provide +a README beside every project. -**LINQ is forbidden**, including query expressions and `System.Linq` operators. Use explicit loops and collection APIs to control allocations and iteration costs. Avoid unrelated refactors. +**LINQ is forbidden**, including query expressions and `System.Linq` operators. Use explicit loops and collection APIs +to control allocations and iteration costs. Avoid unrelated refactors. ## Testing Guidelines -Tests use xUnit v3 with Microsoft.Testing.Platform. Name tests `Subject_condition_expected`; add focused regressions for behavioral fixes. Debug validation rejects skips; Release permits the existing Debug-only guard skip. Coverage can be collected with `--coverage --coverage-output-format xml`. +Tests use xUnit v3 with Microsoft.Testing.Platform. Name tests `Subject_condition_expected`; add focused regressions for +behavioral fixes. Debug validation rejects skips; Release permits the existing Debug-only guard skip. Coverage can be +collected with `--coverage --coverage-output-format xml`. -Native tests use the bundled Lua DLL. Preserve stack balance, callback lifetimes, ownership, and native error boundaries. Distinguish fixture results from live Cheat Engine verification. +Native tests use the bundled Lua DLL. Preserve stack balance, callback lifetimes, ownership, and native error +boundaries. Distinguish fixture results from live Cheat Engine verification. ## Commit & Pull Request Guidelines -History uses imperative subjects such as `Fix CI validation findings`; no conventional-commit prefix is required. Do not add `Co-authored-by` trailers to commits. Keep commits focused. PR descriptions should explain the problem, resulting behavior, relevant issues, validation commands/results, and remaining live-host limitations. Update affected documentation and report build, test, and package results before requesting review. +History uses imperative subjects such as `Fix CI validation findings`; no conventional-commit prefix is required. Do not +add `Co-authored-by` trailers to commits. Keep commits focused. PR descriptions should explain the problem, resulting +behavior, relevant issues, validation commands/results, and remaining live-host limitations. Update affected +documentation and report build, test, and package results before requesting review. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e266bb2..34cf9e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ under [1.0.0](#100---2026-09-20) before upgrading. ## [1.0.0] - 2026-09-20 > [!WARNING] -> **Major breaking change: 1.0.0 is a new SDK, not an update of `CESDK` 0.2.1.** The new architecture replaces the target +> **Major breaking change: 1.0.0 is a new SDK, not an update of `CESDK` 0.2.1.** The new architecture replaces the +target > framework, the plugin model and the whole public API, so nothing written against 0.1.0 to 0.2.1 works the same way. > There is no compatibility layer and no deprecation period. A plugin has to be rewritten against the new API, > starting from the [quick start](README.md#quick-start). A plugin that stays on `CESDK` 0.2.1 keeps building as before. @@ -50,6 +51,7 @@ under [1.0.0](#100---2026-09-20) before upgrading. return `bool` or `LuaStatus`, and `LuaException` reports a failed Lua call. - Work that must run on the main thread goes through `MainThread` in `CheatEngine.SDK.Hosting.Threading`, and logging goes through the host. + ### Removed - The earlier static facades, among them `Process`, `MemoryAccess`, `AobScanner`, `MemScan`, `Debugger`, `Dbvm`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d1249f2..311c0495 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing to CheatEngine.SDK -Thanks for contributing. CheatEngine.SDK is a Windows x64, .NET 10 SDK for Cheat Engine 7.7 plugins. Keep changes focused, preserve existing patterns, and update documentation when behavior or public APIs change. +Thanks for contributing. CheatEngine.SDK is a Windows x64, .NET 10 SDK for Cheat Engine 7.7 plugins. Keep changes +focused, preserve existing patterns, and update documentation when behavior or public APIs change. ## Prerequisites @@ -8,7 +9,9 @@ Thanks for contributing. CheatEngine.SDK is a Windows x64, .NET 10 SDK for Cheat - .NET SDK 10.0.401, as pinned in [`global.json`](global.json) - Git -Ordinary managed work uses the checked-in Lua bridge and does not need a C toolchain. If you change [`native/cheatengine-sdk-lua-bridge`](native/cheatengine-sdk-lua-bridge/README.md), also install xmake and a Windows x64 C toolchain. +Ordinary managed work uses the checked-in Lua bridge and does not need a C toolchain. If you change [ +`native/cheatengine-sdk-lua-bridge`](native/cheatengine-sdk-lua-bridge/README.md), also install xmake and a Windows x64 +C toolchain. ## Build and test @@ -27,17 +30,24 @@ To create the package locally: dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget ``` -The CI workflow builds Debug and Release, and tests each `tests/**/*.Tests.csproj` project. Debug tests treat skipped tests as failures; Release permits the repository's existing Debug-only guard skip. For manual host validation, use the [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md). +The CI workflow builds Debug and Release, and tests each `tests/**/*.Tests.csproj` project. Debug tests treat skipped +tests as failures; Release permits the repository's existing Debug-only guard skip. For manual host validation, use +the [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md). ## Style and analyzers -- Follow [`.editorconfig`](.editorconfig): UTF-8, tab-based C# indentation (spaces only for continuation alignment), and two-space configuration/project-file indentation. [`.gitattributes`](.gitattributes) owns working-tree line-ending normalization. -- Use file-scoped namespaces, explicit accessibility, PascalCase public members, and the local naming patterns already present. -- Public APIs require XML documentation. Builds treat compiler and analyzer diagnostics as errors, except for configured exceptions. +- Follow [`.editorconfig`](.editorconfig): UTF-8, tab-based C# indentation (spaces only for continuation alignment), and + two-space configuration/project-file indentation. [`.gitattributes`](.gitattributes) owns working-tree line-ending + normalization. +- Use file-scoped namespaces, explicit accessibility, PascalCase public members, and the local naming patterns already + present. +- Public APIs require XML documentation. Builds treat compiler and analyzer diagnostics as errors, except for configured + exceptions. - Do not use LINQ, including query expressions or `System.Linq` operators. Prefer explicit loops and collection APIs. - Preserve native Lua identifiers and ABI layouts. Avoid unrelated refactors. -The package includes analyzers and code fixes. See the [diagnostic reference](analyzers/docs/README.md) for the `CESDK` rules and their fixes. +The package includes analyzers and code fixes. See the [diagnostic reference](analyzers/docs/README.md) for the `CESDK` +rules and their fixes. ## Branches and pull requests @@ -46,11 +56,13 @@ The package includes analyzers and code fixes. See the [diagnostic reference](an 3. Run the relevant build, test, and package commands. 4. Open a pull request against `main`; do not push directly to the protected branch. -PR descriptions should state the problem, resulting behavior, related issues, validation commands and results, and any remaining live-host limitations. Include documentation changes that the work requires. +PR descriptions should state the problem, resulting behavior, related issues, validation commands and results, and any +remaining live-host limitations. Include documentation changes that the work requires. ## Commits -Use focused commits with short, imperative subjects, such as `Fix CI validation findings`. Conventional Commit prefixes are not required. Do not add `Co-authored-by` trailers. +Use focused commits with short, imperative subjects, such as `Fix CI validation findings`. Conventional Commit prefixes +are not required. Do not add `Co-authored-by` trailers. ## Releases diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index a72fd88b..82b0bfe7 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -91,10 +91,12 @@ - + - + diff --git a/Directory.Build.props b/Directory.Build.props index 05eb72e9..cb319278 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,8 +19,8 @@ true all - - $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1903;NU1904;NU1905 + + $(WarningsNotAsErrors);NU1900;NU1905 diff --git a/Directory.Packages.props b/Directory.Packages.props index b44c731b..21135554 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + @@ -28,7 +29,7 @@ - + diff --git a/README.md b/README.md index 269a8119..85bd110b 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,22 @@ -CheatEngine.SDK lets you write Cheat Engine 7.7 plugins in C#. It packages the plugin-facing libraries, source generators, and analyzers needed to generate the entry point Cheat Engine loads and to expose C# methods to Lua. It is an independent project and is not affiliated with Cheat Engine. +CheatEngine.SDK lets you write Cheat Engine 7.7 plugins in C#. It packages the plugin-facing libraries, source +generators, and analyzers needed to generate the entry point Cheat Engine loads and to expose C# methods to Lua. It is +an independent project and is not affiliated with Cheat Engine. ## Requirements -| Requirement | Supported version | -| --- | --- | -| .NET SDK | 10.0.401 (or a later SDK selected through `latestFeature`) | +| Requirement | Supported version | +|---------------|-------------------------------------------------------------------------------------------------| +| .NET SDK | 10.0.401 (or a later SDK selected through `latestFeature`) | | .NET runtimes | .NET 10 `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App`, and `Microsoft.AspNetCore.App` | -| Cheat Engine | 7.7 | -| Platform | Windows x64 | +| Cheat Engine | 7.7 | +| Platform | Windows x64 | -Cheat Engine must be configured to run on .NET 10. The [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine) explains the required `ce.runtimeconfig.json` changes. +Cheat Engine must be configured to run on .NET 10. +The [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine) explains the required +`ce.runtimeconfig.json` changes. ## Install @@ -81,13 +85,15 @@ internal static partial class Commands } ``` -Build the project, then keep the complete output directory together when loading `MyPlugin.dll` from Cheat Engine's plugin settings: +Build the project, then keep the complete output directory together when loading `MyPlugin.dll` from Cheat Engine's +plugin settings: ```powershell dotnet build -c Release ``` -After enabling the plugin, run `print(greet("world"))` in Cheat Engine's Lua Engine window. For a fuller walkthrough, start with [Example 01](exemples/01-first-plugin/README.md). +After enabling the plugin, run `print(greet("world"))` in Cheat Engine's Lua Engine window. For a fuller walkthrough, +start with [Example 01](exemples/01-first-plugin/README.md). ## Build from source @@ -101,20 +107,22 @@ dotnet test --solution CheatEngine.SDK.slnx -c Release dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget ``` -Ordinary managed builds use the checked-in Windows x64 Lua bridge, so they do not require a C toolchain. Contributors changing [`native/cheatengine-sdk-lua-bridge`](native/cheatengine-sdk-lua-bridge/README.md) need xmake and a Windows x64 C toolchain to rebuild it. +Ordinary managed builds use the checked-in Windows x64 Lua bridge, so they do not require a C toolchain. Contributors +changing [`native/cheatengine-sdk-lua-bridge`](native/cheatengine-sdk-lua-bridge/README.md) need xmake and a Windows x64 +C toolchain to rebuild it. ## Project layout -| Path | Purpose | -| --- | --- | -| [`libs/`](libs/) | Layered annotations, ABI, Lua, engine, and hosting libraries. | -| [`src/CheatEngine.SDK/`](src/CheatEngine.SDK/) | The `CheatEngine.SDK` NuGet package and consumer build properties. | -| [`source-generators/`](source-generators/) | Generated plugin entry-point and Lua-binding components. | -| [`analyzers/`](analyzers/) | Diagnostics, code fixes, and their documentation. | -| [`native/`](native/) | The Lua test fixture and bundled Windows x64 Lua protection bridge. | -| [`tests/`](tests/) | Unit tests, benchmarks, shared fixtures, and the live-plugin sample. | -| [`exemples/`](exemples/) | Guides, recipes, and API documentation. The directory name is intentional. | -| [`eng/`](eng/) | Shared build configuration. | +| Path | Purpose | +|------------------------------------------------|----------------------------------------------------------------------------| +| [`libs/`](libs/) | Layered annotations, ABI, Lua, engine, and hosting libraries. | +| [`src/CheatEngine.SDK/`](src/CheatEngine.SDK/) | The `CheatEngine.SDK` NuGet package and consumer build properties. | +| [`source-generators/`](source-generators/) | Generated plugin entry-point and Lua-binding components. | +| [`analyzers/`](analyzers/) | Diagnostics, code fixes, and their documentation. | +| [`native/`](native/) | The Lua test fixture and bundled Windows x64 Lua protection bridge. | +| [`tests/`](tests/) | Unit tests, benchmarks, shared fixtures, and the live-plugin sample. | +| [`exemples/`](exemples/) | Guides, recipes, and API documentation. The directory name is intentional. | +| [`eng/`](eng/) | Shared build configuration. | ## Documentation @@ -126,4 +134,6 @@ Ordinary managed builds use the checked-in Windows x64 Lua bridge, so they do no ## License -[MIT](LICENSE). Cheat Engine is licensed separately. The Lua DLL retained under [`native/cheat-engine`](native/cheat-engine/README.md) is a test fixture under Cheat Engine's terms and is not included in the NuGet package. +[MIT](LICENSE). Cheat Engine is licensed separately. The Lua DLL retained under [ +`native/cheat-engine`](native/cheat-engine/README.md) is a test fixture under Cheat Engine's terms and is not included +in the NuGet package. diff --git a/RELEASING.md b/RELEASING.md index 8e5b68dc..b189e699 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,15 +9,15 @@ package manually: nuget.org versions are immutable, and the workflow artifact is Create an organization-owned trusted publishing policy under the `CheatEngine` organization at with these exact values: -| Field | Value | -|---|---| -| Policy owner | `CheatEngine` (organization) | -| Repository owner | `CheatEngineNet` | -| Repository | `CheatEngine.SDK` | -| Workflow file | `release.yml` | -| Environment | `nuget` | -| Scope | Push new packages and package versions | -| Package glob | `CheatEngine.SDK` | +| Field | Value | +|------------------|----------------------------------------| +| Policy owner | `CheatEngine` (organization) | +| Repository owner | `CheatEngineNet` | +| Repository | `CheatEngine.SDK` | +| Workflow file | `release.yml` | +| Environment | `nuget` | +| Scope | Push new packages and package versions | +| Package glob | `CheatEngine.SDK` | The GitHub `nuget` environment must contain an environment secret named `NUGET_USER`. Its value must be the exact nuget.org username of the administrator who created the policy, currently `AriusII`, not the organization name and not diff --git a/ROADMAP.md b/ROADMAP.md index 615f7f24..ec8e5e50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,99 +1,114 @@ # CheatEngine.SDK Roadmap -**Planning baseline: September 21, 2026.** This is an outcome-based plan, not a delivery-date commitment. The initial preparation session was denied GitHub writes; this branch is the later reviewable import. The roadmap describes planned outcomes, never implementation, package, fixture, or live-host completion. Read the live issue and Project state separately from this versioned plan. +**Planning baseline: September 21, 2026.** This is an outcome-based plan, not a delivery-date commitment. The initial +preparation session was denied GitHub writes; this branch is the later reviewable import. The roadmap describes planned +outcomes, never implementation, package, fixture, or live-host completion. Read the live issue and Project state +separately from this versioned plan. ## Operating boundary -SDK owns CE integration; Client owns developer-facing workflows. Independent reliability fixes need not wait for all SDK research. Source merged, package shipped, fixture passed and host qualified are separate gates. +SDK owns CE integration; Client owns developer-facing workflows. Independent reliability fixes need not wait for all SDK +research. Source merged, package shipped, fixture passed and host qualified are separate gates. ## Milestones -| Phase | Outcome | Exit evidence | -|---|---|---| -| SDK-M0 | Evidence and governance baseline | Archive-continuity gaps recorded; reviewed governance; source/artifact/support claims distinguished. | -| SDK-M1 | ABI and host safety qualification | Layout/signature and lifecycle evidence attached; native AOT loading remains a separate decision. | +| Phase | Outcome | Exit evidence | +|--------|-------------------------------------|--------------------------------------------------------------------------------------------------------------| +| SDK-M0 | Evidence and governance baseline | Archive-continuity gaps recorded; reviewed governance; source/artifact/support claims distinguished. | +| SDK-M1 | ABI and host safety qualification | Layout/signature and lifecycle evidence attached; native AOT loading remains a separate decision. | | SDK-M2 | Authoritative integration contracts | Factual outcomes, target identity and ownership contracts have deterministic tests and no Client dependency. | -| SDK-M3 | Memory, scan, and minimum artifacts | Per-primitive gates recorded and minimum containing SDK artifact identified for Client consumption. | -| SDK-M4 | Assembly and debugger contracts | Instruction, patch and continuation responsibilities qualified or explicitly deferred. | -| SDK-M5 | Advanced capability boundaries | Each optional family has independent prerequisites; no generic enable-all capability. | -| SDK-M6 | Release and ecosystem conformance | Declared release scope has package hashes, tested profiles and reproducible evidence. | +| SDK-M3 | Memory, scan, and minimum artifacts | Per-primitive gates recorded and minimum containing SDK artifact identified for Client consumption. | +| SDK-M4 | Assembly and debugger contracts | Instruction, patch and continuation responsibilities qualified or explicitly deferred. | +| SDK-M5 | Advanced capability boundaries | Each optional family has independent prerequisites; no generic enable-all capability. | +| SDK-M6 | Release and ecosystem conformance | Declared release scope has package hashes, tested profiles and reproducible evidence. | ## Epics and implementable work ### SDK-E01 — Evidence, governance, and capability provenance + Maintain the source-indexed execution baseline without inventing audit coverage. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-001](documentations/engineering/work-items/SDK-001.md) — Establish source, artifact, and capability provenance | P1 | Refinement and evidence; no declared issue blocker | -| [SDK-002](documentations/engineering/work-items/SDK-002.md) — Inventory the public CE extension surface and host profiles | P1 | SDK-001 | -| [SDK-003](documentations/engineering/work-items/SDK-003.md) — Adopt engineering governance and validate the bootstrap graph | P1 | Refinement and evidence; no declared issue blocker | +| Work item | Priority | Prerequisites | +|-----------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------------| +| [SDK-001](documentations/engineering/work-items/SDK-001.md) — Establish source, artifact, and capability provenance | P1 | Refinement and evidence; no declared issue blocker | +| [SDK-002](documentations/engineering/work-items/SDK-002.md) — Inventory the public CE extension surface and host profiles | P1 | SDK-001 | +| [SDK-003](documentations/engineering/work-items/SDK-003.md) — Adopt engineering governance and validate the bootstrap graph | P1 | Refinement and evidence; no declared issue blocker | ### SDK-E02 — ABI, hosting, and deployment qualification + Qualify exact host contracts and preserve the supported managed deployment. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-004](documentations/engineering/work-items/SDK-004.md) — Qualify classic ABI layouts and conflicting signatures | P1 | SDK-002 | -| [SDK-005](documentations/engineering/work-items/SDK-005.md) — Qualify activation admission, shutdown, and plugin coexistence | P1 | SDK-002 | -| [SDK-006](documentations/engineering/work-items/SDK-006.md) — Decide the NativeAOT plugin loader profile without weakening managed support | P2 | SDK-005 | +| Work item | Priority | Prerequisites | +|--------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| [SDK-004](documentations/engineering/work-items/SDK-004.md) — Qualify classic ABI layouts and conflicting signatures | P1 | SDK-002 | +| [SDK-005](documentations/engineering/work-items/SDK-005.md) — Qualify activation admission, shutdown, and plugin coexistence | P1 | SDK-002 | +| [SDK-006](documentations/engineering/work-items/SDK-006.md) — Decide the NativeAOT plugin loader profile without weakening managed support | P2 | SDK-005 | ### SDK-E03 — Authoritative semantic outcomes and Lua registration + Expose reusable CE semantics without depending on Client policy. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-007](documentations/engineering/work-items/SDK-007.md) — Preserve structured outcomes across Lua and Engine primitives | P1 | SDK-001 | -| [SDK-008](documentations/engineering/work-items/SDK-008.md) — Own built-in runtime, process, symbol, and table Lua contracts | P1 | SDK-007, SDK-002 | -| [SDK-009](documentations/engineering/work-items/SDK-009.md) — Return ownership-aware Lua registration leases | P1 | SDK-007, SDK-012 | +| Work item | Priority | Prerequisites | +|------------------------------------------------------------------------------------------------------------------------------|----------|------------------| +| [SDK-007](documentations/engineering/work-items/SDK-007.md) — Preserve structured outcomes across Lua and Engine primitives | P1 | SDK-001 | +| [SDK-008](documentations/engineering/work-items/SDK-008.md) — Own built-in runtime, process, symbol, and table Lua contracts | P1 | SDK-007, SDK-002 | +| [SDK-009](documentations/engineering/work-items/SDK-009.md) — Return ownership-aware Lua registration leases | P1 | SDK-007, SDK-012 | ### SDK-E04 — Runtime identity and resource ownership + Make runtime/target authority and exception-safe cleanup reusable for every SDK consumer. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-010](documentations/engineering/work-items/SDK-010.md) — Establish authoritative target identity for effectful operations | P1 | SDK-007, SDK-002 | -| [SDK-011](documentations/engineering/work-items/SDK-011.md) — Make resource ownership handoff and cleanup exception-safe | P1 | SDK-010, SDK-007 | -| [SDK-012](documentations/engineering/work-items/SDK-012.md) — Qualify shared Lua state, reset, and protected operation boundaries | P1 | SDK-002, SDK-005 | +| Work item | Priority | Prerequisites | +|-----------------------------------------------------------------------------------------------------------------------------------|----------|------------------| +| [SDK-010](documentations/engineering/work-items/SDK-010.md) — Establish authoritative target identity for effectful operations | P1 | SDK-007, SDK-002 | +| [SDK-011](documentations/engineering/work-items/SDK-011.md) — Make resource ownership handoff and cleanup exception-safe | P1 | SDK-010, SDK-007 | +| [SDK-012](documentations/engineering/work-items/SDK-012.md) — Qualify shared Lua state, reset, and protected operation boundaries | P1 | SDK-002, SDK-005 | ### SDK-E05 — Memory and scanning primitives + Provide qualified target reads, AOB outcomes, and scan-session ownership. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-013](documentations/engineering/work-items/SDK-013.md) — Qualify target memory, pointer width, and bounded buffer contracts | P1 | SDK-007, SDK-010 | -| [SDK-014](documentations/engineering/work-items/SDK-014.md) — Separate AOB absence, errors, and execution bounds | P1 | SDK-007, SDK-013 | -| [SDK-015](documentations/engineering/work-items/SDK-015.md) — Qualify the existing value-scan session factory | P2 | SDK-011, SDK-013 | +| Work item | Priority | Prerequisites | +|----------------------------------------------------------------------------------------------------------------------------------|----------|------------------| +| [SDK-013](documentations/engineering/work-items/SDK-013.md) — Qualify target memory, pointer width, and bounded buffer contracts | P1 | SDK-007, SDK-010 | +| [SDK-014](documentations/engineering/work-items/SDK-014.md) — Separate AOB absence, errors, and execution bounds | P1 | SDK-007, SDK-013 | +| [SDK-015](documentations/engineering/work-items/SDK-015.md) — Qualify the existing value-scan session factory | P2 | SDK-011, SDK-013 | ### SDK-E06 — Instruction, patch, and debugger contracts + Separate instruction processing, target mutation and immediate callback decisions. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-016](documentations/engineering/work-items/SDK-016.md) — Qualify assembly and disassembly contracts by instruction profile | P2 | SDK-004, SDK-013 | -| [SDK-017](documentations/engineering/work-items/SDK-017.md) — Qualify Auto Assembler patch application and disable ownership | P1 | SDK-011, SDK-012 | -| [SDK-018](documentations/engineering/work-items/SDK-018.md) — Define synchronous debugger callback and continuation ownership | P2 | SDK-004, SDK-005, SDK-010 | +| Work item | Priority | Prerequisites | +|---------------------------------------------------------------------------------------------------------------------------------|----------|---------------------------| +| [SDK-016](documentations/engineering/work-items/SDK-016.md) — Qualify assembly and disassembly contracts by instruction profile | P2 | SDK-004, SDK-013 | +| [SDK-017](documentations/engineering/work-items/SDK-017.md) — Qualify Auto Assembler patch application and disable ownership | P1 | SDK-011, SDK-012 | +| [SDK-018](documentations/engineering/work-items/SDK-018.md) — Define synchronous debugger callback and continuation ownership | P2 | SDK-004, SDK-005, SDK-010 | ### SDK-E07 — Record commands and optional capability families + Deliver exact record semantics early and keep unrelated advanced families independently gated. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-019](documentations/engineering/work-items/SDK-019.md) — Qualify timer and hotkey subscription ownership | P2 | SDK-005, SDK-012 | -| [SDK-020](documentations/engineering/work-items/SDK-020.md) — Partition advanced capability research into independently gated families | P3 | SDK-002, SDK-010 | -| [SDK-021](documentations/engineering/work-items/SDK-021.md) — Expose typed record and symbol mutation ownership | P1 | SDK-007, SDK-012 | +| Work item | Priority | Prerequisites | +|----------------------------------------------------------------------------------------------------------------------------------------|----------|------------------| +| [SDK-019](documentations/engineering/work-items/SDK-019.md) — Qualify timer and hotkey subscription ownership | P2 | SDK-005, SDK-012 | +| [SDK-020](documentations/engineering/work-items/SDK-020.md) — Partition advanced capability research into independently gated families | P3 | SDK-002, SDK-010 | +| [SDK-021](documentations/engineering/work-items/SDK-021.md) — Expose typed record and symbol mutation ownership | P1 | SDK-007, SDK-012 | ### SDK-E08 — Generation, artifacts, and ecosystem conformance + Make published artifacts and generated consumers match the qualified source contracts. -| Work item | Priority | Prerequisites | -|---|---|---| -| [SDK-022](documentations/engineering/work-items/SDK-022.md) — Validate generated bindings and marshalling in packed consumers | P1 | SDK-007, SDK-009 | -| [SDK-023](documentations/engineering/work-items/SDK-023.md) — Publish a traceable minimum contract artifact for Client adoption | P1 | SDK-008, SDK-009, SDK-010, SDK-011, SDK-021, SDK-022 | -| [SDK-024](documentations/engineering/work-items/SDK-024.md) — Establish release qualification and performance evidence | P2 | SDK-023, SDK-005 | +| Work item | Priority | Prerequisites | +|---------------------------------------------------------------------------------------------------------------------------------|----------|------------------------------------------------------| +| [SDK-022](documentations/engineering/work-items/SDK-022.md) — Validate generated bindings and marshalling in packed consumers | P1 | SDK-007, SDK-009 | +| [SDK-023](documentations/engineering/work-items/SDK-023.md) — Publish a traceable minimum contract artifact for Client adoption | P1 | SDK-008, SDK-009, SDK-010, SDK-011, SDK-021, SDK-022 | +| [SDK-024](documentations/engineering/work-items/SDK-024.md) — Establish release qualification and performance evidence | P2 | SDK-023, SDK-005 | ## Execution notes -A phase is an outcome grouping, not a global lock. A research task can proceed while an unrelated reliability fix ships. The graph specifies technical prerequisites; it does not estimate capacity. Before Client adoption, identify a package containing every required SDK primitive even when a prior SDK minimum-contract release is already complete. +A phase is an outcome grouping, not a global lock. A research task can proceed while an unrelated reliability fix ships. +The graph specifies technical prerequisites; it does not estimate capacity. Before Client adoption, identify a package +containing every required SDK primitive even when a prior SDK minimum-contract release is already complete. -Create a branch only when a leaf is ready. Keep SDK and Client PRs separate; publish the containing SDK artifact before declaring dependent Client behavior supported. Parent issue closure is never inferred from a single child PR. +Create a branch only when a leaf is ready. Keep SDK and Client PRs separate; publish the containing SDK artifact before +declaring dependent Client behavior supported. Parent issue closure is never inferred from a single child PR. diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs index 0f35105b..20d04ff8 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs @@ -8,103 +8,115 @@ namespace CheatEngine.SDK.Analyzers.CodeFixes.Plugin; /// The syntax edits behind the CESDK0001 fixes. Pure syntax in, syntax out. internal static class PluginClassRewriter { - /// The same token position and trivia, with the keyword . - public static SyntaxToken ToSealed(SyntaxToken modifier) - { - return SyntaxFactory.Token(modifier.LeadingTrivia, SyntaxKind.SealedKeyword, modifier.TrailingTrivia); - } + /// The same token position and trivia, with the keyword . + public static SyntaxToken ToSealed(SyntaxToken modifier) + { + return SyntaxFactory.Token(modifier.LeadingTrivia, SyntaxKind.SealedKeyword, modifier.TrailingTrivia); + } - /// - /// Adds public Name() { }: in front of the first declared constructor, else after the last field, else - /// as the first member. - /// - public static TypeDeclarationSyntax AddParameterlessConstructor(TypeDeclarationSyntax declaration) - { - var constructor = SyntaxFactory.ConstructorDeclaration(declaration.Identifier.WithoutTrivia()) - .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) - .WithBody(SyntaxFactory.Block()) - .WithAdditionalAnnotations(Formatter.Annotation); + /// + /// Adds public Name() { }: in front of the first declared constructor, else after the last field, else + /// as the first member. + /// + public static TypeDeclarationSyntax AddParameterlessConstructor(TypeDeclarationSyntax declaration) + { + ConstructorDeclarationSyntax constructor = SyntaxFactory + .ConstructorDeclaration(declaration.Identifier.WithoutTrivia()) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithBody(SyntaxFactory.Block()) + .WithAdditionalAnnotations(Formatter.Annotation); - return declaration.WithMembers(declaration.Members.Insert(FindConstructorIndex(declaration.Members), - constructor)); - } + return declaration.WithMembers(declaration.Members.Insert(FindConstructorIndex(declaration.Members), + constructor)); + } - /// - /// Replaces whatever accessibility the constructor declares (none means ) with - /// , keeping the trivia of the declaration. - /// - public static ConstructorDeclarationSyntax MakePublic(ConstructorDeclarationSyntax constructor) - { - var modifiers = constructor.Modifiers; - var first = IndexOfAccessibility(modifiers, 0); - if (first < 0) - { - // No accessibility keyword: 'public' becomes the first token after the attributes and takes over the - // leading trivia (indentation, comments) of the token that was there. - var anchor = modifiers.Count > 0 ? modifiers[0] : constructor.Identifier; - var publicKeyword = SyntaxFactory.Token(anchor.LeadingTrivia, SyntaxKind.PublicKeyword, - SyntaxFactory.TriviaList(SyntaxFactory.Space)); - var stripped = constructor.ReplaceToken(anchor, anchor.WithLeadingTrivia(SyntaxFactory.TriviaList())); - return stripped.WithModifiers(stripped.Modifiers.Insert(0, publicKeyword)); - } + /// + /// Replaces whatever accessibility the constructor declares (none means ) with + /// , keeping the trivia of the declaration. + /// + public static ConstructorDeclarationSyntax MakePublic(ConstructorDeclarationSyntax constructor) + { + SyntaxTokenList modifiers = constructor.Modifiers; + int first = IndexOfAccessibility(modifiers, 0); + if (first < 0) + { + // No accessibility keyword: 'public' becomes the first token after the attributes and takes over the + // leading trivia (indentation, comments) of the token that was there. + SyntaxToken anchor = modifiers.Count > 0 ? modifiers[0] : constructor.Identifier; + SyntaxToken publicKeyword = SyntaxFactory.Token(anchor.LeadingTrivia, SyntaxKind.PublicKeyword, + SyntaxFactory.TriviaList(SyntaxFactory.Space)); + ConstructorDeclarationSyntax stripped = + constructor.ReplaceToken(anchor, anchor.WithLeadingTrivia(SyntaxFactory.TriviaList())); + return stripped.WithModifiers(stripped.Modifiers.Insert(0, publicKeyword)); + } - var original = modifiers[first]; - modifiers = modifiers.Replace(original, - SyntaxFactory.Token(original.LeadingTrivia, SyntaxKind.PublicKeyword, original.TrailingTrivia)); + SyntaxToken original = modifiers[first]; + modifiers = modifiers.Replace(original, + SyntaxFactory.Token(original.LeadingTrivia, SyntaxKind.PublicKeyword, original.TrailingTrivia)); - // 'private protected' and 'protected internal' are two keywords: drop the second one, but not what the - // author wrote around it. Its comments move behind the modifier in front of it, which always exists. - for (var next = IndexOfAccessibility(modifiers, first + 1); - next >= 0; - next = IndexOfAccessibility(modifiers, next)) - { - var dropped = modifiers[next]; - var previous = modifiers[next - 1]; - var carried = FromFirstComment(dropped.LeadingTrivia.AddRange(dropped.TrailingTrivia)); - modifiers = modifiers - .Replace(previous, previous.WithTrailingTrivia(previous.TrailingTrivia.AddRange(carried))) - .RemoveAt(next); - } + // 'private protected' and 'protected internal' are two keywords: drop the second one, but not what the + // author wrote around it. Its comments move behind the modifier in front of it, which always exists. + for (int next = IndexOfAccessibility(modifiers, first + 1); + next >= 0; + next = IndexOfAccessibility(modifiers, next)) + { + SyntaxToken dropped = modifiers[next]; + SyntaxToken previous = modifiers[next - 1]; + SyntaxTriviaList carried = FromFirstComment(dropped.LeadingTrivia.AddRange(dropped.TrailingTrivia)); + modifiers = modifiers + .Replace(previous, previous.WithTrailingTrivia(previous.TrailingTrivia.AddRange(carried))) + .RemoveAt(next); + } - return constructor.WithModifiers(modifiers); - } + return constructor.WithModifiers(modifiers); + } - // The trivia from the first one that is not white space to the end: empty for a keyword that carries no comment, - // so that an ordinary 'private protected' loses the keyword and its spacing together. - private static SyntaxTriviaList FromFirstComment(SyntaxTriviaList trivia) - { - var kept = SyntaxFactory.TriviaList(); - foreach (var item in trivia) - if (kept.Count > 0 || - !(item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia))) - kept = kept.Add(item); + // The trivia from the first one that is not white space to the end: empty for a keyword that carries no comment, + // so that an ordinary 'private protected' loses the keyword and its spacing together. + private static SyntaxTriviaList FromFirstComment(SyntaxTriviaList trivia) + { + SyntaxTriviaList kept = SyntaxFactory.TriviaList(); + foreach (SyntaxTrivia item in trivia) + { + if (kept.Count > 0 || + !(item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia))) + { + kept = kept.Add(item); + } + } - return kept; - } + return kept; + } - private static int FindConstructorIndex(SyntaxList members) - { - var afterLastField = 0; - for (var index = 0; index < members.Count; index++) - switch (members[index]) - { - case ConstructorDeclarationSyntax: - return index; - case FieldDeclarationSyntax: - afterLastField = index + 1; - break; - } + private static int FindConstructorIndex(SyntaxList members) + { + int afterLastField = 0; + for (int index = 0; index < members.Count; index++) + { + switch (members[index]) + { + case ConstructorDeclarationSyntax: + return index; + case FieldDeclarationSyntax: + afterLastField = index + 1; + break; + } + } - return afterLastField; - } + return afterLastField; + } - private static int IndexOfAccessibility(SyntaxTokenList modifiers, int start) - { - for (var index = start; index < modifiers.Count; index++) - if (modifiers[index].Kind() is SyntaxKind.PublicKeyword or SyntaxKind.InternalKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.PrivateKeyword) - return index; + private static int IndexOfAccessibility(SyntaxTokenList modifiers, int start) + { + for (int index = start; index < modifiers.Count; index++) + { + if (modifiers[index].Kind() is SyntaxKind.PublicKeyword or SyntaxKind.InternalKeyword + or SyntaxKind.ProtectedKeyword or SyntaxKind.PrivateKeyword) + { + return index; + } + } - return -1; - } + return -1; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs index 050e9dae..4926f9a2 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs @@ -4,8 +4,10 @@ using System.Composition; using System.Threading; using System.Threading.Tasks; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; @@ -43,181 +45,226 @@ namespace CheatEngine.SDK.Analyzers.CodeFixes.Plugin; [Shared] public sealed class PluginClassShapeCodeFixProvider : CodeFixProvider { - internal const string MakeSealedEquivalenceKey = DiagnosticIds.InvalidPluginClass + ".MakeSealed"; - - internal const string AddConstructorEquivalenceKey = - DiagnosticIds.InvalidPluginClass + ".AddParameterlessConstructor"; - - internal const string MakeConstructorPublicEquivalenceKey = - DiagnosticIds.InvalidPluginClass + ".MakeConstructorPublic"; - - /// - public override ImmutableArray FixableDiagnosticIds { get; } = [DiagnosticIds.InvalidPluginClass]; - - /// - public override FixAllProvider GetFixAllProvider() - { - return WellKnownFixAllProviders.BatchFixer; - } - - /// - public override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - var cancellationToken = context.CancellationToken; - var root = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - var semanticModel = await context.Document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (root is null || semanticModel is null) return; - - foreach (var diagnostic in context.Diagnostics) - { - if (!diagnostic.Properties.TryGetValue(DiagnosticProperties.PluginClassProblem, out var problemName) - || !Enum.TryParse(problemName, out PluginShapeIssues problem) - || root.FindToken(diagnostic.Location.SourceSpan.Start).Parent - ?.FirstAncestorOrSelf() is not { } declaration - || semanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { } type) - continue; - - var solution = context.Document.Project.Solution; - var action = problem switch - { - PluginShapeIssues.Abstract => CreateMakeSealed(solution, type, SyntaxKind.AbstractKeyword, "abstract", - cancellationToken), - PluginShapeIssues.Static => CreateMakeSealed(solution, type, SyntaxKind.StaticKeyword, "static", - cancellationToken), - PluginShapeIssues.MissingParameterlessConstructor => CreateAddConstructor(context.Document, root, - declaration, type, semanticModel.Compilation, cancellationToken), - PluginShapeIssues.InaccessibleParameterlessConstructor => CreateMakeConstructorPublic(solution, type, - cancellationToken), - _ => null - }; - - if (action is not null) context.RegisterCodeFix(action, diagnostic); - } - } - - private static CodeAction? CreateMakeSealed(Solution solution, INamedTypeSymbol type, SyntaxKind modifierKind, - string modifierText, CancellationToken cancellationToken) - { - // Every part of a partial class may repeat the modifier; parts in generated code have no document. - Dictionary> modifiersByDocument = []; - foreach (var reference in type.DeclaringSyntaxReferences) - { - if (reference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax part - || solution.GetDocument(reference.SyntaxTree) is not { } document) - continue; - - foreach (var modifier in part.Modifiers) - { - if (!modifier.IsKind(modifierKind)) continue; - - if (!modifiersByDocument.TryGetValue(document.Id, out var modifiers)) - { - modifiers = []; - modifiersByDocument.Add(document.Id, modifiers); - } - - modifiers.Add(modifier); - } - } - - if (modifiersByDocument.Count == 0) return null; - - return CodeAction.Create( - $"Replace '{modifierText}' with 'sealed'", - async actionCancellationToken => - { - var changed = solution; - foreach (var entry in modifiersByDocument) - { - var document = changed.GetDocument(entry.Key); - var root = document is null - ? null - : await document.GetSyntaxRootAsync(actionCancellationToken).ConfigureAwait(false); - if (root is not null) - changed = changed.WithDocumentSyntaxRoot(entry.Key, - root.ReplaceTokens(entry.Value, - static (original, _) => PluginClassRewriter.ToSealed(original))); - } - - return changed; - }, - MakeSealedEquivalenceKey); - } - - private static CodeAction? CreateAddConstructor( - Document document, - SyntaxNode root, - TypeDeclarationSyntax declaration, - INamedTypeSymbol type, - Compilation compilation, - CancellationToken cancellationToken) - { - // 'class Plugin;' has no member list to add to. - if (declaration.OpenBraceToken.IsKind(SyntaxKind.None) || declaration.OpenBraceToken.IsMissing) return null; - - // A primary constructor forces every other constructor to chain to it: not mechanical. - foreach (var reference in type.DeclaringSyntaxReferences) - if (reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax { ParameterList: not null }) - return null; - - // 'public Name() { }' chains to 'base()' implicitly: same reason when the base class has nothing to bind it to. - if (!HasImplicitlyCallableBaseConstructor(type, compilation)) return null; - - return CodeAction.Create( - "Add public parameterless constructor", - _ => Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(declaration, - PluginClassRewriter.AddParameterlessConstructor(declaration)))), - AddConstructorEquivalenceKey); - } - - // Overload resolution of the implicit 'base()': a constructor whose parameters can all be omitted (none, - // optional, params), accessible from the derived class. An unresolved base class has no constructors: no fix. - private static bool HasImplicitlyCallableBaseConstructor(INamedTypeSymbol type, Compilation compilation) - { - if (type.BaseType is not { } baseType) return false; - - foreach (var constructor in baseType.InstanceConstructors) - if (CanOmitEveryArgument(constructor) && compilation.IsSymbolAccessibleWithin(constructor, type)) - return true; - - return false; - } - - private static bool CanOmitEveryArgument(IMethodSymbol constructor) - { - foreach (var parameter in constructor.Parameters) - if (!parameter.IsOptional && !parameter.IsParams) - return false; - - return true; - } - - private static CodeAction? CreateMakeConstructorPublic(Solution solution, INamedTypeSymbol type, - CancellationToken cancellationToken) - { - foreach (var constructor in type.InstanceConstructors) - { - if (!constructor.Parameters.IsEmpty - || constructor.IsImplicitlyDeclared - || constructor.DeclaringSyntaxReferences.IsEmpty - || constructor.DeclaringSyntaxReferences[0] is not { } reference - || reference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax syntax - || solution.GetDocument(reference.SyntaxTree) is not { } document) - continue; - - return CodeAction.Create( - "Make parameterless constructor public", - async actionCancellationToken => - { - var root = await document.GetSyntaxRootAsync(actionCancellationToken).ConfigureAwait(false); - return root is null - ? solution - : solution.WithDocumentSyntaxRoot(document.Id, - root.ReplaceNode(syntax, PluginClassRewriter.MakePublic(syntax))); - }, - MakeConstructorPublicEquivalenceKey); - } - - return null; - } + internal const string MakeSealedEquivalenceKey = DiagnosticIds.InvalidPluginClass + ".MakeSealed"; + + internal const string AddConstructorEquivalenceKey = + DiagnosticIds.InvalidPluginClass + ".AddParameterlessConstructor"; + + internal const string MakeConstructorPublicEquivalenceKey = + DiagnosticIds.InvalidPluginClass + ".MakeConstructorPublic"; + + /// + public override ImmutableArray FixableDiagnosticIds + { + get; + } = [DiagnosticIds.InvalidPluginClass]; + + /// + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + CancellationToken cancellationToken = context.CancellationToken; + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + SemanticModel? semanticModel = + await context.Document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || semanticModel is null) + { + return; + } + + foreach (Diagnostic diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue(DiagnosticProperties.PluginClassProblem, out string? problemName) + || !Enum.TryParse(problemName, out PluginShapeIssues problem) + || root.FindToken(diagnostic.Location.SourceSpan.Start).Parent + ?.FirstAncestorOrSelf() is not { } declaration + || semanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { } type) + { + continue; + } + + Solution solution = context.Document.Project.Solution; + CodeAction? action = problem switch + { + PluginShapeIssues.Abstract => CreateMakeSealed(solution, type, SyntaxKind.AbstractKeyword, "abstract", + cancellationToken), + PluginShapeIssues.Static => CreateMakeSealed(solution, type, SyntaxKind.StaticKeyword, "static", + cancellationToken), + PluginShapeIssues.MissingParameterlessConstructor => CreateAddConstructor(context.Document, root, + declaration, type, semanticModel.Compilation, cancellationToken), + PluginShapeIssues.InaccessibleParameterlessConstructor => CreateMakeConstructorPublic(solution, type, + cancellationToken), + _ => null + }; + + if (action is not null) + { + context.RegisterCodeFix(action, diagnostic); + } + } + } + + private static CodeAction? CreateMakeSealed(Solution solution, INamedTypeSymbol type, SyntaxKind modifierKind, + string modifierText, CancellationToken cancellationToken) + { + // Every part of a partial class may repeat the modifier; parts in generated code have no document. + Dictionary> modifiersByDocument = []; + foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) + { + if (reference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax part + || solution.GetDocument(reference.SyntaxTree) is not { } document) + { + continue; + } + + foreach (SyntaxToken modifier in part.Modifiers) + { + if (!modifier.IsKind(modifierKind)) + { + continue; + } + + if (!modifiersByDocument.TryGetValue(document.Id, out List? modifiers)) + { + modifiers = []; + modifiersByDocument.Add(document.Id, modifiers); + } + + modifiers.Add(modifier); + } + } + + if (modifiersByDocument.Count == 0) + { + return null; + } + + return CodeAction.Create( + $"Replace '{modifierText}' with 'sealed'", + async actionCancellationToken => + { + Solution changed = solution; + foreach (KeyValuePair> entry in modifiersByDocument) + { + Document? document = changed.GetDocument(entry.Key); + SyntaxNode? root = document is null + ? null + : await document.GetSyntaxRootAsync(actionCancellationToken).ConfigureAwait(false); + if (root is not null) + { + changed = changed.WithDocumentSyntaxRoot(entry.Key, + root.ReplaceTokens(entry.Value, + static (original, _) => PluginClassRewriter.ToSealed(original))); + } + } + + return changed; + }, + MakeSealedEquivalenceKey); + } + + private static CodeAction? CreateAddConstructor( + Document document, + SyntaxNode root, + TypeDeclarationSyntax declaration, + INamedTypeSymbol type, + Compilation compilation, + CancellationToken cancellationToken) + { + // 'class Plugin;' has no member list to add to. + if (declaration.OpenBraceToken.IsKind(SyntaxKind.None) || declaration.OpenBraceToken.IsMissing) + { + return null; + } + + // A primary constructor forces every other constructor to chain to it: not mechanical. + foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) + { + if (reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax { ParameterList: not null }) + { + return null; + } + } + + // 'public Name() { }' chains to 'base()' implicitly: same reason when the base class has nothing to bind it to. + if (!HasImplicitlyCallableBaseConstructor(type, compilation)) + { + return null; + } + + return CodeAction.Create( + "Add public parameterless constructor", + _ => Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(declaration, + PluginClassRewriter.AddParameterlessConstructor(declaration)))), + AddConstructorEquivalenceKey); + } + + // Overload resolution of the implicit 'base()': a constructor whose parameters can all be omitted (none, + // optional, params), accessible from the derived class. An unresolved base class has no constructors: no fix. + private static bool HasImplicitlyCallableBaseConstructor(INamedTypeSymbol type, Compilation compilation) + { + if (type.BaseType is not { } baseType) + { + return false; + } + + foreach (IMethodSymbol constructor in baseType.InstanceConstructors) + { + if (CanOmitEveryArgument(constructor) && compilation.IsSymbolAccessibleWithin(constructor, type)) + { + return true; + } + } + + return false; + } + + private static bool CanOmitEveryArgument(IMethodSymbol constructor) + { + foreach (IParameterSymbol parameter in constructor.Parameters) + { + if (!parameter.IsOptional && !parameter.IsParams) + { + return false; + } + } + + return true; + } + + private static CodeAction? CreateMakeConstructorPublic(Solution solution, INamedTypeSymbol type, + CancellationToken cancellationToken) + { + foreach (IMethodSymbol constructor in type.InstanceConstructors) + { + if (!constructor.Parameters.IsEmpty + || constructor.IsImplicitlyDeclared + || constructor.DeclaringSyntaxReferences.IsEmpty + || constructor.DeclaringSyntaxReferences[0] is not { } reference + || reference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax syntax + || solution.GetDocument(reference.SyntaxTree) is not { } document) + { + continue; + } + + return CodeAction.Create( + "Make parameterless constructor public", + async actionCancellationToken => + { + SyntaxNode? root = await document.GetSyntaxRootAsync(actionCancellationToken).ConfigureAwait(false); + return root is null + ? solution + : solution.WithDocumentSyntaxRoot(document.Id, + root.ReplaceNode(syntax, PluginClassRewriter.MakePublic(syntax))); + }, + MakeConstructorPublicEquivalenceKey); + } + + return null; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs index edabd55f..345a5f11 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs @@ -1,4 +1,5 @@ using System; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -19,146 +20,161 @@ namespace CheatEngine.SDK.Analyzers.CodeFixes.Usage; /// internal static class ExceptionGuardRewriter { - /// Wraps the statements of a block body. - public static BlockSyntax Guard(BlockSyntax body, ITypeSymbol returnType) - { - // Comments in front of the closing brace belong to the old body: they move into the try block with it. - var tryBlock = SyntaxFactory.Block(body.Statements) - .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken) - .WithLeadingTrivia(body.CloseBraceToken.LeadingTrivia)); - - return body - .WithStatements(SyntaxFactory.SingletonList(CreateTry(tryBlock, returnType))) - .WithCloseBraceToken(body.CloseBraceToken.WithLeadingTrivia(SyntaxFactory.ElasticMarker)) - .WithAdditionalAnnotations(Formatter.Annotation); - } - - /// Turns an expression body into a guarded block body. - /// The arrow clause; must not contain preprocessor directives. - /// The semicolon that ends the declaration; it becomes the end of the statement. - /// Decides between a return and an expression statement, and the failure value. - /// The line break of the document, see . - /// - /// Nothing the author wrote is dropped. Comments between the signature and the expression (=> // why, or - /// on lines of their own) move in front of the statement, one per line; the semicolon keeps its trivia up to the - /// last comment (; // why stays on the statement line) and whatever follows that comment, normally the - /// line break, ends the new block. A throw expression is only legal in an expression body: it becomes a throw - /// statement (return throw ...; is CS8115). - /// - public static BlockSyntax Guard(ArrowExpressionClauseSyntax expressionBody, SyntaxToken semicolon, - ITypeSymbol returnType, SyntaxTrivia endOfLine) - { - var comments = CommentLines(expressionBody.ArrowToken.LeadingTrivia, endOfLine) - .AddRange(CommentLines(expressionBody.ArrowToken.TrailingTrivia, endOfLine)) - .AddRange(CommentLines(expressionBody.Expression.GetLeadingTrivia(), endOfLine)); - - var afterSemicolon = semicolon.TrailingTrivia; - var statementPart = IndexAfterLastComment(afterSemicolon); - var withStatement = Slice(afterSemicolon, 0, statementPart); - if (statementPart > 0 && afterSemicolon[statementPart - 1].IsKind(SyntaxKind.SingleLineCommentTrivia)) - // The closing brace of the try block follows: it must not end up inside the comment. - withStatement = withStatement.Add(endOfLine); - - var statementSemicolon = semicolon.WithTrailingTrivia(withStatement.Add(SyntaxFactory.ElasticMarker)); - var expression = expressionBody.Expression.WithoutLeadingTrivia(); - StatementSyntax statement = expression switch - { - ThrowExpressionSyntax throwExpression => SyntaxFactory.ThrowStatement(throwExpression.ThrowKeyword, - throwExpression.Expression, statementSemicolon), - _ when returnType.SpecialType == SpecialType.System_Void => SyntaxFactory.ExpressionStatement(expression, - statementSemicolon), - _ => SyntaxFactory.ReturnStatement(expression).WithSemicolonToken(statementSemicolon) - }; - - var tryBlock = SyntaxFactory.Block(statement.WithLeadingTrivia(comments)); - return SyntaxFactory.Block(CreateTry(tryBlock, returnType)) - .WithTrailingTrivia(Slice(afterSemicolon, statementPart, afterSemicolon.Count)) - .WithAdditionalAnnotations(Formatter.Annotation); - } - - /// - /// Whether the expression body can be converted: not when a preprocessor directive sits between the signature - /// and the semicolon. #if branches there are halves of an expression body; as statements of a block the - /// inactive half would no longer compile, and the matching #endif lies outside the declaration. - /// - public static bool CanGuard(ArrowExpressionClauseSyntax expressionBody, SyntaxToken semicolon) - { - return !expressionBody.ContainsDirectives && !semicolon.ContainsDirectives; - } - - /// - /// The line break the document already uses, so that the inserted comment line does not introduce a second - /// convention; an elastic CR LF (replaced by the formatter's own choice) when the document has no line break. - /// - public static SyntaxTrivia DetectEndOfLine(SourceText text) - { - foreach (var line in text.Lines) - { - var length = line.EndIncludingLineBreak - line.End; - if (length > 0) - return SyntaxFactory.ElasticEndOfLine( - text.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))); - } - - return SyntaxFactory.ElasticCarriageReturnLineFeed; - } - - // Every comment of the list, each followed by a line break; white space and line breaks are dropped. - private static SyntaxTriviaList CommentLines(SyntaxTriviaList trivia, SyntaxTrivia endOfLine) - { - var comments = SyntaxFactory.TriviaList(); - foreach (var item in trivia) - if (IsComment(item)) - comments = comments.Add(item).Add(endOfLine); - - return comments; - } - - private static int IndexAfterLastComment(SyntaxTriviaList trivia) - { - for (var index = trivia.Count - 1; index >= 0; index--) - if (IsComment(trivia[index])) - return index + 1; - - return 0; - } - - private static SyntaxTriviaList Slice(SyntaxTriviaList trivia, int start, int end) - { - var slice = SyntaxFactory.TriviaList(); - for (var index = start; index < end; index++) slice = slice.Add(trivia[index]); - - return slice; - } - - private static bool IsComment(SyntaxTrivia trivia) - { - return trivia.Kind() is SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia - or SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia; - } - - private static TryStatementSyntax CreateTry(BlockSyntax tryBlock, ITypeSymbol returnType) - { - var exceptionType = SyntaxFactory.ParseTypeName("global::System.Exception") - .WithAdditionalAnnotations(Simplifier.Annotation); - var catchClause = SyntaxFactory.CatchClause() - .WithDeclaration(SyntaxFactory.CatchDeclaration(exceptionType)) - .WithBlock(CreateCatchBlock(returnType)); - - return SyntaxFactory.TryStatement(tryBlock, SyntaxFactory.SingletonList(catchClause), null); - } - - private static BlockSyntax CreateCatchBlock(ITypeSymbol returnType) - { - return SyntaxFactory.Block(SyntaxFactory.ReturnStatement(CreateFailureValue(returnType))); - } - - // The provider verifies the exact CE bootstrap signature before calling this rewriter. Keep that precondition - // here too, so a future caller cannot silently turn an unknown callback into a generic "return 0" fix. - private static LiteralExpressionSyntax CreateFailureValue(ITypeSymbol returnType) - { - return returnType.SpecialType != SpecialType.System_Int32 - ? throw new ArgumentException("The CE bootstrap failure convention returns Int32.", nameof(returnType)) - : SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)); - } + /// Wraps the statements of a block body. + public static BlockSyntax Guard(BlockSyntax body, ITypeSymbol returnType) + { + // Comments in front of the closing brace belong to the old body: they move into the try block with it. + BlockSyntax tryBlock = SyntaxFactory.Block(body.Statements) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken) + .WithLeadingTrivia(body.CloseBraceToken.LeadingTrivia)); + + return body + .WithStatements(SyntaxFactory.SingletonList(CreateTry(tryBlock, returnType))) + .WithCloseBraceToken(body.CloseBraceToken.WithLeadingTrivia(SyntaxFactory.ElasticMarker)) + .WithAdditionalAnnotations(Formatter.Annotation); + } + + /// Turns an expression body into a guarded block body. + /// The arrow clause; must not contain preprocessor directives. + /// The semicolon that ends the declaration; it becomes the end of the statement. + /// Decides between a return and an expression statement, and the failure value. + /// The line break of the document, see . + /// + /// Nothing the author wrote is dropped. Comments between the signature and the expression (=> // why, or + /// on lines of their own) move in front of the statement, one per line; the semicolon keeps its trivia up to the + /// last comment (; // why stays on the statement line) and whatever follows that comment, normally the + /// line break, ends the new block. A throw expression is only legal in an expression body: it becomes a throw + /// statement (return throw ...; is CS8115). + /// + public static BlockSyntax Guard(ArrowExpressionClauseSyntax expressionBody, SyntaxToken semicolon, + ITypeSymbol returnType, SyntaxTrivia endOfLine) + { + SyntaxTriviaList comments = CommentLines(expressionBody.ArrowToken.LeadingTrivia, endOfLine) + .AddRange(CommentLines(expressionBody.ArrowToken.TrailingTrivia, endOfLine)) + .AddRange(CommentLines(expressionBody.Expression.GetLeadingTrivia(), endOfLine)); + + SyntaxTriviaList afterSemicolon = semicolon.TrailingTrivia; + int statementPart = IndexAfterLastComment(afterSemicolon); + SyntaxTriviaList withStatement = Slice(afterSemicolon, 0, statementPart); + if (statementPart > 0 && afterSemicolon[statementPart - 1].IsKind(SyntaxKind.SingleLineCommentTrivia)) + // The closing brace of the try block follows: it must not end up inside the comment. + { + withStatement = withStatement.Add(endOfLine); + } + + SyntaxToken statementSemicolon = semicolon.WithTrailingTrivia(withStatement.Add(SyntaxFactory.ElasticMarker)); + ExpressionSyntax expression = expressionBody.Expression.WithoutLeadingTrivia(); + StatementSyntax statement = expression switch + { + ThrowExpressionSyntax throwExpression => SyntaxFactory.ThrowStatement(throwExpression.ThrowKeyword, + throwExpression.Expression, statementSemicolon), + _ when returnType.SpecialType == SpecialType.System_Void => SyntaxFactory.ExpressionStatement(expression, + statementSemicolon), + _ => SyntaxFactory.ReturnStatement(expression).WithSemicolonToken(statementSemicolon) + }; + + BlockSyntax tryBlock = SyntaxFactory.Block(statement.WithLeadingTrivia(comments)); + return SyntaxFactory.Block(CreateTry(tryBlock, returnType)) + .WithTrailingTrivia(Slice(afterSemicolon, statementPart, afterSemicolon.Count)) + .WithAdditionalAnnotations(Formatter.Annotation); + } + + /// + /// Whether the expression body can be converted: not when a preprocessor directive sits between the signature + /// and the semicolon. #if branches there are halves of an expression body; as statements of a block the + /// inactive half would no longer compile, and the matching #endif lies outside the declaration. + /// + public static bool CanGuard(ArrowExpressionClauseSyntax expressionBody, SyntaxToken semicolon) + { + return !expressionBody.ContainsDirectives && !semicolon.ContainsDirectives; + } + + /// + /// The line break the document already uses, so that the inserted comment line does not introduce a second + /// convention; an elastic CR LF (replaced by the formatter's own choice) when the document has no line break. + /// + public static SyntaxTrivia DetectEndOfLine(SourceText text) + { + foreach (TextLine line in text.Lines) + { + int length = line.EndIncludingLineBreak - line.End; + if (length > 0) + { + return SyntaxFactory.ElasticEndOfLine( + text.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))); + } + } + + return SyntaxFactory.ElasticCarriageReturnLineFeed; + } + + // Every comment of the list, each followed by a line break; white space and line breaks are dropped. + private static SyntaxTriviaList CommentLines(SyntaxTriviaList trivia, SyntaxTrivia endOfLine) + { + SyntaxTriviaList comments = SyntaxFactory.TriviaList(); + foreach (SyntaxTrivia item in trivia) + { + if (IsComment(item)) + { + comments = comments.Add(item).Add(endOfLine); + } + } + + return comments; + } + + private static int IndexAfterLastComment(SyntaxTriviaList trivia) + { + for (int index = trivia.Count - 1; index >= 0; index--) + { + if (IsComment(trivia[index])) + { + return index + 1; + } + } + + return 0; + } + + private static SyntaxTriviaList Slice(SyntaxTriviaList trivia, int start, int end) + { + SyntaxTriviaList slice = SyntaxFactory.TriviaList(); + for (int index = start; index < end; index++) + { + slice = slice.Add(trivia[index]); + } + + return slice; + } + + private static bool IsComment(SyntaxTrivia trivia) + { + return trivia.Kind() is SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia + or SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia; + } + + private static TryStatementSyntax CreateTry(BlockSyntax tryBlock, ITypeSymbol returnType) + { + TypeSyntax exceptionType = SyntaxFactory.ParseTypeName("global::System.Exception") + .WithAdditionalAnnotations(Simplifier.Annotation); + CatchClauseSyntax catchClause = SyntaxFactory.CatchClause() + .WithDeclaration(SyntaxFactory.CatchDeclaration(exceptionType)) + .WithBlock(CreateCatchBlock(returnType)); + + return SyntaxFactory.TryStatement(tryBlock, SyntaxFactory.SingletonList(catchClause), null); + } + + private static BlockSyntax CreateCatchBlock(ITypeSymbol returnType) + { + return SyntaxFactory.Block(SyntaxFactory.ReturnStatement(CreateFailureValue(returnType))); + } + + // The provider verifies the exact CE bootstrap signature before calling this rewriter. Keep that precondition + // here too, so a future caller cannot silently turn an unknown callback into a generic "return 0" fix. + private static LiteralExpressionSyntax CreateFailureValue(ITypeSymbol returnType) + { + return returnType.SpecialType != SpecialType.System_Int32 + ? throw new ArgumentException("The CE bootstrap failure convention returns Int32.", nameof(returnType)) + : SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)); + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs index 750eba2d..b95c46bb 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs @@ -3,7 +3,9 @@ using System.Composition; using System.Threading; using System.Threading.Tasks; + using CheatEngine.SDK.Analyzers.Diagnostics; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; @@ -30,115 +32,129 @@ namespace CheatEngine.SDK.Analyzers.CodeFixes.Usage; [Shared] public sealed class UnmanagedCallersOnlyGuardCodeFixProvider : CodeFixProvider { - internal const string WrapEquivalenceKey = DiagnosticIds.UnguardedUnmanagedCallersOnly + ".WrapInTryCatch"; - - private const string WrapTitle = "Wrap body in try/catch returning a failure value"; - - /// - public override ImmutableArray FixableDiagnosticIds { get; } = - [DiagnosticIds.UnguardedUnmanagedCallersOnly]; - - /// - public override FixAllProvider GetFixAllProvider() - { - return WellKnownFixAllProviders.BatchFixer; - } - - /// - public override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - var semanticModel = - await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); - if (root is null || semanticModel is null) return; - - foreach (var diagnostic in context.Diagnostics) - { - var declaration = FindDeclaration(root, diagnostic.Location.SourceSpan); - if (declaration is null - || semanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not IMethodSymbol method - || !HasKnownFailureConvention(method)) - continue; - - context.RegisterCodeFix( - CodeAction.Create( - WrapTitle, - cancellationToken => WrapAsync(context.Document, declaration, cancellationToken), - WrapEquivalenceKey), - diagnostic); - } - } - - // The known CE bootstrap convention is a type member, so a local function never has a safe failure convention to fix. - private static MethodDeclarationSyntax? FindDeclaration(SyntaxNode root, TextSpan span) - { - for (var node = root.FindToken(span.Start).Parent; node is not null; node = node.Parent) - switch (node) - { - case MethodDeclarationSyntax { Body: not null } method: - return method; - case MethodDeclarationSyntax { ExpressionBody: { } expressionBody } method: - return ExceptionGuardRewriter.CanGuard(expressionBody, method.SemicolonToken) ? method : null; - case LocalFunctionStatementSyntax: - case AnonymousFunctionExpressionSyntax: - return null; - case MemberDeclarationSyntax: - return null; - } - - return null; - } - - private static async Task WrapAsync(Document document, MethodDeclarationSyntax declaration, - CancellationToken cancellationToken) - { - var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (root is null || - semanticModel?.GetDeclaredSymbol(declaration, cancellationToken) is not IMethodSymbol method - || !HasKnownFailureConvention(method)) - return document; - - var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); - var endOfLine = ExceptionGuardRewriter.DetectEndOfLine(text); - - var guarded = declaration switch - { - { Body: { } body } syntax => syntax.WithBody( - ExceptionGuardRewriter.Guard(body, method.ReturnType)), - { ExpressionBody: { } expressionBody } syntax => syntax - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithBody(ExceptionGuardRewriter.Guard(expressionBody, syntax.SemicolonToken, method.ReturnType, - endOfLine)), - _ => declaration - }; - - return document.WithSyntaxRoot(root.ReplaceNode(declaration, guarded)); - } - - // CE's managed bootstrap signature is the one callback failure convention owned by this SDK today. A bare - // [UnmanagedCallersOnly] method has no such contract: returning zero may mean success, a count, or an address. - private static bool HasKnownFailureConvention(IMethodSymbol method) - { - if (!method.IsStatic - || method.IsGenericMethod - || method.DeclaredAccessibility != Accessibility.Public - || !string.Equals(method.Name, "CEPluginInitialize", StringComparison.Ordinal) - || method.ReturnsByRef - || method.ReturnsByRefReadonly - || method.ReturnType.SpecialType != SpecialType.System_Int32 - || method.Parameters.Length != 2 - || method.Parameters[0].RefKind != RefKind.None - || method.Parameters[0].Type.SpecialType != SpecialType.System_IntPtr - || method.Parameters[1].RefKind != RefKind.None - || method.Parameters[1].Type.SpecialType != SpecialType.System_Int32) - return false; - - var containingType = method.ContainingType; - return string.Equals(containingType.Name, "CESDK", StringComparison.Ordinal) - && containingType.ContainingType is null - && string.Equals(containingType.ContainingNamespace.ToDisplayString(), "CESDK", - StringComparison.Ordinal); - } + internal const string WrapEquivalenceKey = DiagnosticIds.UnguardedUnmanagedCallersOnly + ".WrapInTryCatch"; + + private const string WrapTitle = "Wrap body in try/catch returning a failure value"; + + /// + public override ImmutableArray FixableDiagnosticIds + { + get; + } = + [DiagnosticIds.UnguardedUnmanagedCallersOnly]; + + /// + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + SemanticModel? semanticModel = + await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null || semanticModel is null) + { + return; + } + + foreach (Diagnostic diagnostic in context.Diagnostics) + { + MethodDeclarationSyntax? declaration = FindDeclaration(root, diagnostic.Location.SourceSpan); + if (declaration is null + || semanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not IMethodSymbol method + || !HasKnownFailureConvention(method)) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + WrapTitle, + cancellationToken => WrapAsync(context.Document, declaration, cancellationToken), + WrapEquivalenceKey), + diagnostic); + } + } + + // The known CE bootstrap convention is a type member, so a local function never has a safe failure convention to fix. + private static MethodDeclarationSyntax? FindDeclaration(SyntaxNode root, TextSpan span) + { + for (SyntaxNode? node = root.FindToken(span.Start).Parent; node is not null; node = node.Parent) + { + switch (node) + { + case MethodDeclarationSyntax { Body: not null } method: + return method; + case MethodDeclarationSyntax { ExpressionBody: { } expressionBody } method: + return ExceptionGuardRewriter.CanGuard(expressionBody, method.SemicolonToken) ? method : null; + case LocalFunctionStatementSyntax: + case AnonymousFunctionExpressionSyntax: + return null; + case MemberDeclarationSyntax: + return null; + } + } + + return null; + } + + private static async Task WrapAsync(Document document, MethodDeclarationSyntax declaration, + CancellationToken cancellationToken) + { + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || + semanticModel?.GetDeclaredSymbol(declaration, cancellationToken) is not IMethodSymbol method + || !HasKnownFailureConvention(method)) + { + return document; + } + + SourceText text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + SyntaxTrivia endOfLine = ExceptionGuardRewriter.DetectEndOfLine(text); + + MethodDeclarationSyntax guarded = declaration switch + { + { Body: { } body } syntax => syntax.WithBody( + ExceptionGuardRewriter.Guard(body, method.ReturnType)), + { ExpressionBody: { } expressionBody } syntax => syntax + .WithExpressionBody(null) + .WithSemicolonToken(default) + .WithBody(ExceptionGuardRewriter.Guard(expressionBody, syntax.SemicolonToken, method.ReturnType, + endOfLine)), + _ => declaration + }; + + return document.WithSyntaxRoot(root.ReplaceNode(declaration, guarded)); + } + + // CE's managed bootstrap signature is the one callback failure convention owned by this SDK today. A bare + // [UnmanagedCallersOnly] method has no such contract: returning zero may mean success, a count, or an address. + private static bool HasKnownFailureConvention(IMethodSymbol method) + { + if (!method.IsStatic + || method.IsGenericMethod + || method.DeclaredAccessibility != Accessibility.Public + || !string.Equals(method.Name, "CEPluginInitialize", StringComparison.Ordinal) + || method.ReturnsByRef + || method.ReturnsByRefReadonly + || method.ReturnType.SpecialType != SpecialType.System_Int32 + || method.Parameters.Length != 2 + || method.Parameters[0].RefKind != RefKind.None + || method.Parameters[0].Type.SpecialType != SpecialType.System_IntPtr + || method.Parameters[1].RefKind != RefKind.None + || method.Parameters[1].Type.SpecialType != SpecialType.System_Int32) + { + return false; + } + + INamedTypeSymbol containingType = method.ContainingType; + return string.Equals(containingType.Name, "CESDK", StringComparison.Ordinal) + && containingType.ContainingType is null + && string.Equals(containingType.ContainingNamespace.ToDisplayString(), "CESDK", + StringComparison.Ordinal); + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticCategories.cs b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticCategories.cs index 573bb9a9..b6661438 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticCategories.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticCategories.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Analyzers.Diagnostics; /// The category names of the CheatEngine.SDK diagnostics. internal static class DiagnosticCategories { - /// Plugin shape and bootstrap rules (CESDK0xxx). - public const string Plugin = "CheatEngine.SDK.Plugin"; + /// Plugin shape and bootstrap rules (CESDK0xxx). + public const string Plugin = "CheatEngine.SDK.Plugin"; - /// Runtime-safety usage rules (CESDK1xxx). - public const string Usage = "CheatEngine.SDK.Usage"; + /// Runtime-safety usage rules (CESDK1xxx). + public const string Usage = "CheatEngine.SDK.Usage"; - /// Generator-input rules (CESDK2xxx): shapes the LuaBindings generator silently skips. - public const string Generation = "CheatEngine.SDK.Generation"; + /// Generator-input rules (CESDK2xxx): shapes the LuaBindings generator silently skips. + public const string Generation = "CheatEngine.SDK.Generation"; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs index 9f2c83c5..d9f73ff3 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs @@ -11,54 +11,54 @@ namespace CheatEngine.SDK.Analyzers.Diagnostics; /// public static class DiagnosticIds { - /// A class marked [CheatEnginePlugin] cannot be constructed by the generated entry point. - public const string InvalidPluginClass = "CESDK0001"; + /// A class marked [CheatEnginePlugin] cannot be constructed by the generated entry point. + public const string InvalidPluginClass = "CESDK0001"; - /// More than one class in the compilation is marked [CheatEnginePlugin]. - public const string MultiplePluginClasses = "CESDK0002"; + /// More than one class in the compilation is marked [CheatEnginePlugin]. + public const string MultiplePluginClasses = "CESDK0002"; - /// Generation is disabled but the manual host bootstrap is absent or has the wrong shape. - public const string InvalidManualBootstrap = "CESDK0003"; + /// Generation is disabled but the manual host bootstrap is absent or has the wrong shape. + public const string InvalidManualBootstrap = "CESDK0003"; - /// - /// A plugin assembly declares a namespace that is CESDK or nested under it: the namespace of the - /// CESDK.CESDK type that Cheat Engine requires in every plugin assembly. - /// - public const string ReservedNamespace = "CESDK0004"; + /// + /// A plugin assembly declares a namespace that is CESDK or nested under it: the namespace of the + /// CESDK.CESDK type that Cheat Engine requires in every plugin assembly. + /// + public const string ReservedNamespace = "CESDK0004"; - /// A source declaration collides with the CESDK.CESDK type generated for an enabled plugin. - public const string GeneratedEntryPointCollision = "CESDK0005"; + /// A source declaration collides with the CESDK.CESDK type generated for an enabled plugin. + public const string GeneratedEntryPointCollision = "CESDK0005"; - /// A plugin constructor or initializer directly calls an API which requires an enabled plugin. - public const string RequiresPluginEnabledTooEarly = "CESDK1001"; + /// A plugin constructor or initializer directly calls an API which requires an enabled plugin. + public const string RequiresPluginEnabledTooEarly = "CESDK1001"; - /// A directly borrowed Cheat Engine value is disposed or asynchronously disposed. - public const string DisposeBorrowedValue = "CESDK1003"; + /// A directly borrowed Cheat Engine value is disposed or asynchronously disposed. + public const string DisposeBorrowedValue = "CESDK1003"; - /// An exception can escape a method marked [UnmanagedCallersOnly]. - public const string UnguardedUnmanagedCallersOnly = "CESDK1004"; + /// An exception can escape a method marked [UnmanagedCallersOnly]. + public const string UnguardedUnmanagedCallersOnly = "CESDK1004"; - /// An OnEnable or OnDisable implementation is async void. - public const string AsyncPluginLifecycle = "CESDK1005"; + /// An OnEnable or OnDisable implementation is async void. + public const string AsyncPluginLifecycle = "CESDK1005"; - /// A [LuaFunction] or [LuaGlobal] binding exists but the compilation does not allow unsafe code. - public const string UnsafeBlocksRequired = "CESDK2001"; + /// A [LuaFunction] or [LuaGlobal] binding exists but the compilation does not allow unsafe code. + public const string UnsafeBlocksRequired = "CESDK2001"; - /// The type that declares a [LuaFunction] or [LuaGlobal] member cannot receive a generated part. - public const string InvalidLuaBindingContainingType = "CESDK2002"; + /// The type that declares a [LuaFunction] or [LuaGlobal] member cannot receive a generated part. + public const string InvalidLuaBindingContainingType = "CESDK2002"; - /// A [LuaFunction] method cannot be exported by a generated thunk. - public const string InvalidLuaFunction = "CESDK2003"; + /// A [LuaFunction] method cannot be exported by a generated thunk. + public const string InvalidLuaFunction = "CESDK2003"; - /// A [LuaGlobal] method cannot receive a generated body. - public const string InvalidLuaGlobal = "CESDK2004"; + /// A [LuaGlobal] method cannot receive a generated body. + public const string InvalidLuaGlobal = "CESDK2004"; - /// Two otherwise valid Lua function exports of one type have the same Lua name. - public const string DuplicateLuaName = "CESDK2005"; + /// Two otherwise valid Lua function exports of one type have the same Lua name. + public const string DuplicateLuaName = "CESDK2005"; - /// A Lua annotation is placed on a declaration the active generator cannot implement. - public const string InvalidLuaAnnotationTarget = "CESDK2006"; + /// A Lua annotation is placed on a declaration the active generator cannot implement. + public const string InvalidLuaAnnotationTarget = "CESDK2006"; - /// A user declaration collides with a member that a Lua binding generator must emit. - public const string GeneratedLuaIdentityCollision = "CESDK2007"; + /// A user declaration collides with a member that a Lua binding generator must emit. + public const string GeneratedLuaIdentityCollision = "CESDK2007"; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticProperties.cs b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticProperties.cs index fdc43cc3..5dca13bc 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticProperties.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticProperties.cs @@ -8,9 +8,9 @@ namespace CheatEngine.SDK.Analyzers.Diagnostics; /// internal static class DiagnosticProperties { - /// - /// On CESDK0001: the name of the single flag the - /// diagnostic is about. - /// - public const string PluginClassProblem = "CheatEngine.SDK.PluginClassProblem"; + /// + /// On CESDK0001: the name of the single flag the + /// diagnostic is about. + /// + public const string PluginClassProblem = "CheatEngine.SDK.PluginClassProblem"; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/ContainingTypeProblemText.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/ContainingTypeProblemText.cs index b9b00ba1..d60798b5 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/ContainingTypeProblemText.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/ContainingTypeProblemText.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; namespace CheatEngine.SDK.Analyzers.Generation; @@ -10,29 +11,29 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// internal static class ContainingTypeProblemText { - /// The flags in the order they are reported for one member. - public static readonly ImmutableArray ReportOrder = - [ - ContainingTypeIssues.NotClassOrStruct, - ContainingTypeIssues.Generic, - ContainingTypeIssues.FileLocal, - ContainingTypeIssues.NotPartial - ]; + /// The flags in the order they are reported for one member. + public static readonly ImmutableArray ReportOrder = + [ + ContainingTypeIssues.NotClassOrStruct, + ContainingTypeIssues.Generic, + ContainingTypeIssues.FileLocal, + ContainingTypeIssues.NotPartial + ]; - /// Returns the message fragment of a single flag. - public static string Describe(ContainingTypeIssues problem) - { - return problem switch - { - ContainingTypeIssues.NotClassOrStruct => - "must be a class or a struct: interfaces, enums and delegates take no generated members", - ContainingTypeIssues.Generic => - "must not be generic, and must not be nested in a generic type: a generated part cannot be named without type arguments", - ContainingTypeIssues.FileLocal => - "must not be a file-local type, and must not be nested in one: a generated part in another file cannot reach it", - ContainingTypeIssues.NotPartial => - "must be declared partial, like every type it is nested in: a generated part needs a second declaration to add itself to", - _ => "cannot receive a generated Lua binding part" - }; - } + /// Returns the message fragment of a single flag. + public static string Describe(ContainingTypeIssues problem) + { + return problem switch + { + ContainingTypeIssues.NotClassOrStruct => + "must be a class or a struct: interfaces, enums and delegates take no generated members", + ContainingTypeIssues.Generic => + "must not be generic, and must not be nested in a generic type: a generated part cannot be named without type arguments", + ContainingTypeIssues.FileLocal => + "must not be a file-local type, and must not be nested in one: a generated part in another file cannot reach it", + ContainingTypeIssues.NotPartial => + "must be declared partial, like every type it is nested in: a generated part needs a second declaration to add itself to", + _ => "cannot receive a generated Lua binding part" + }; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs index 1de2f380..c8cdf8af 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs @@ -1,9 +1,11 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.WellKnown; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -41,152 +43,193 @@ namespace CheatEngine.SDK.Analyzers.Generation; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class LuaBindingAnalyzer : DiagnosticAnalyzer { - /// - public override ImmutableArray SupportedDiagnostics { get; } = - [ - DiagnosticDescriptors.UnsafeBlocksRequired, - DiagnosticDescriptors.InvalidLuaBindingContainingType, - DiagnosticDescriptors.InvalidLuaFunction, - DiagnosticDescriptors.InvalidLuaGlobal, - DiagnosticDescriptors.DuplicateLuaName, - ]; - - /// - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(OnCompilationStart); - } - - private static void OnCompilationStart(CompilationStartAnalysisContext context) - { - var luaFunctionAttribute = - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaFunctionAttribute); - var luaGlobalAttribute = - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaGlobalAttribute); - if (luaFunctionAttribute is null && luaGlobalAttribute is null) return; - - LuaBindingContractSymbols symbols = new( - luaFunctionAttribute, - luaGlobalAttribute, - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMarshallerAttribute), - SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.ILuaMarshaller), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaClassAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMethodAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaPropertyAttribute), - SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState)); - - // Mirrors CheatEngine.SDK.SourceGenerators.LuaBindings.Model.CompilationFacts.From: a non-C# compilation (never seen - // here, the analyzer is C#-only) would read as "unsafe not allowed" too. - var allowsUnsafe = context.Compilation.Options is CSharpCompilationOptions { AllowUnsafe: true }; - - LuaFunctionDuplicateState duplicateNames = new(); - context.RegisterSymbolAction( - symbolContext => AnalyzeMethod(symbolContext, symbols, allowsUnsafe, duplicateNames), SymbolKind.Method); - context.RegisterCompilationEndAction(duplicateNames.Report); - } - - private static void AnalyzeMethod(SymbolAnalysisContext context, LuaBindingContractSymbols symbols, - bool allowsUnsafe, LuaFunctionDuplicateState duplicateNames) - { - var method = (IMethodSymbol)context.Symbol; - var luaFunction = symbols.LuaFunctionAttribute is null - ? null - : FindAttribute(method, symbols.LuaFunctionAttribute); - var luaGlobal = symbols.LuaGlobalAttribute is null ? null : FindAttribute(method, symbols.LuaGlobalAttribute); - if (luaFunction is null && luaGlobal is null) return; - - var name = method.Name; - var location = FirstLocation(method); - - if (!allowsUnsafe && luaFunction is not null) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsafeBlocksRequired, location, name)); - - var typeIssues = ContainingTypeShape.Inspect(method.ContainingType, context.CancellationToken); - foreach (var problem in ContainingTypeProblemText.ReportOrder) - { - if ((typeIssues & problem) == ContainingTypeIssues.None) continue; - - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidLuaBindingContainingType, location, name, - ContainingTypeProblemText.Describe(problem))); - } - - if (luaFunction is not null) - AnalyzeLuaFunction(context, method, luaFunction, location, typeIssues, duplicateNames, symbols); - - if (luaGlobal is not null) AnalyzeLuaGlobal(context, method, luaGlobal, location, symbols); - } - - private static void AnalyzeLuaFunction( - SymbolAnalysisContext context, - IMethodSymbol method, - AttributeData attribute, - Location location, - ContainingTypeIssues typeIssues, - LuaFunctionDuplicateState duplicateNames, - LuaBindingContractSymbols symbols) - { - var name = ReadName(attribute); - var issues = LuaFunctionShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); - if (!LuaNames.IsValidName(name)) issues |= LuaFunctionShapeIssues.InvalidName; - - foreach (var problem in LuaFunctionProblemText.ReportOrder) - { - if ((issues & problem) == LuaFunctionShapeIssues.None) continue; - - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidLuaFunction, location, method.Name, - LuaFunctionProblemText.Describe(problem))); - } - - // A duplicate-name verdict needs every sibling member of the containing type, not just this one method: - // only a method with no other problem is a candidate, exactly the generator's own grouping input - // (LuaFunctionModel.IsValid). The compilation-end action (LuaFunctionDuplicateState.Report) reports it. - if (issues == LuaFunctionShapeIssues.None && typeIssues == ContainingTypeIssues.None) - duplicateNames.AddCandidate(method.ContainingType, name!, method.Name, location); - } - - private static void AnalyzeLuaGlobal(SymbolAnalysisContext context, IMethodSymbol method, AttributeData attribute, - Location location, LuaBindingContractSymbols symbols) - { - var issues = LuaGlobalShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); - if (!LuaNames.IsValidName(ReadName(attribute))) issues |= LuaGlobalShapeIssues.InvalidName; - - foreach (var problem in LuaGlobalProblemText.ReportOrder) - { - if ((issues & problem) == LuaGlobalShapeIssues.None) continue; - - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidLuaGlobal, location, method.Name, LuaGlobalProblemText.Describe(problem))); - } - } - - // The name argument of [LuaFunction(name)]/[LuaGlobal(name)]; null while the author is typing (missing, not a - // string, or explicitly null), which LuaNames.IsValidName also rejects. The attribute constructor's own - // ArgumentException never runs at compile time, so an empty string reaches here too. - private static string? ReadName(AttributeData attribute) - { - var arguments = attribute.ConstructorArguments; - return arguments is [{ Kind: TypedConstantKind.Primitive, Value: string name }] - ? name - : null; - } - - private static AttributeData? FindAttribute(IMethodSymbol method, INamedTypeSymbol attributeClass) - { - foreach (var attribute in method.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) - return attribute; - - return null; - } - - private static Location FirstLocation(IMethodSymbol method) - { - return method.Locations.IsEmpty ? Location.None : method.Locations[0]; - } + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = + [ + DiagnosticDescriptors.UnsafeBlocksRequired, + DiagnosticDescriptors.InvalidLuaBindingContainingType, + DiagnosticDescriptors.InvalidLuaFunction, + DiagnosticDescriptors.InvalidLuaGlobal, + DiagnosticDescriptors.DuplicateLuaName + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + INamedTypeSymbol? luaFunctionAttribute = + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaFunctionAttribute); + INamedTypeSymbol? luaGlobalAttribute = + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaGlobalAttribute); + if (luaFunctionAttribute is null && luaGlobalAttribute is null) + { + return; + } + + LuaBindingContractSymbols symbols = new( + luaFunctionAttribute, + luaGlobalAttribute, + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMarshallerAttribute), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.ILuaMarshaller), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaClassAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMethodAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaPropertyAttribute), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState)); + + // Mirrors CheatEngine.SDK.SourceGenerators.LuaBindings.Model.CompilationFacts.From: a non-C# compilation (never seen + // here, the analyzer is C#-only) would read as "unsafe not allowed" too. + bool allowsUnsafe = context.Compilation.Options is CSharpCompilationOptions { AllowUnsafe: true }; + + LuaFunctionDuplicateState duplicateNames = new(); + context.RegisterSymbolAction( + symbolContext => AnalyzeMethod(symbolContext, symbols, allowsUnsafe, duplicateNames), SymbolKind.Method); + context.RegisterCompilationEndAction(duplicateNames.Report); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context, LuaBindingContractSymbols symbols, + bool allowsUnsafe, LuaFunctionDuplicateState duplicateNames) + { + IMethodSymbol method = (IMethodSymbol) context.Symbol; + AttributeData? luaFunction = symbols.LuaFunctionAttribute is null + ? null + : FindAttribute(method, symbols.LuaFunctionAttribute); + AttributeData? luaGlobal = symbols.LuaGlobalAttribute is null + ? null + : FindAttribute(method, symbols.LuaGlobalAttribute); + if (luaFunction is null && luaGlobal is null) + { + return; + } + + string name = method.Name; + Location location = FirstLocation(method); + + if (!allowsUnsafe && luaFunction is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsafeBlocksRequired, location, name)); + } + + ContainingTypeIssues typeIssues = ContainingTypeShape.Inspect(method.ContainingType, context.CancellationToken); + foreach (ContainingTypeIssues problem in ContainingTypeProblemText.ReportOrder) + { + if ((typeIssues & problem) == ContainingTypeIssues.None) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidLuaBindingContainingType, location, name, + ContainingTypeProblemText.Describe(problem))); + } + + if (luaFunction is not null) + { + AnalyzeLuaFunction(context, method, luaFunction, location, typeIssues, duplicateNames, symbols); + } + + if (luaGlobal is not null) + { + AnalyzeLuaGlobal(context, method, luaGlobal, location, symbols); + } + } + + private static void AnalyzeLuaFunction( + SymbolAnalysisContext context, + IMethodSymbol method, + AttributeData attribute, + Location location, + ContainingTypeIssues typeIssues, + LuaFunctionDuplicateState duplicateNames, + LuaBindingContractSymbols symbols) + { + string? name = ReadName(attribute); + LuaFunctionShapeIssues issues = LuaFunctionShape.Inspect(context.Compilation, method, symbols.LuaState, + symbols.LuaMarshallerAttribute, + symbols.LuaMarshallerContract, out _); + if (!LuaNames.IsValidName(name)) + { + issues |= LuaFunctionShapeIssues.InvalidName; + } + + foreach (LuaFunctionShapeIssues problem in LuaFunctionProblemText.ReportOrder) + { + if ((issues & problem) == LuaFunctionShapeIssues.None) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidLuaFunction, location, method.Name, + LuaFunctionProblemText.Describe(problem))); + } + + // A duplicate-name verdict needs every sibling member of the containing type, not just this one method: + // only a method with no other problem is a candidate, exactly the generator's own grouping input + // (LuaFunctionModel.IsValid). The compilation-end action (LuaFunctionDuplicateState.Report) reports it. + if (issues == LuaFunctionShapeIssues.None && typeIssues == ContainingTypeIssues.None) + { + duplicateNames.AddCandidate(method.ContainingType, name!, method.Name, location); + } + } + + private static void AnalyzeLuaGlobal(SymbolAnalysisContext context, IMethodSymbol method, AttributeData attribute, + Location location, LuaBindingContractSymbols symbols) + { + LuaGlobalShapeIssues issues = LuaGlobalShape.Inspect(context.Compilation, method, symbols.LuaState, + symbols.LuaMarshallerAttribute, + symbols.LuaMarshallerContract, out _); + if (!LuaNames.IsValidName(ReadName(attribute))) + { + issues |= LuaGlobalShapeIssues.InvalidName; + } + + foreach (LuaGlobalShapeIssues problem in LuaGlobalProblemText.ReportOrder) + { + if ((issues & problem) == LuaGlobalShapeIssues.None) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidLuaGlobal, location, method.Name, LuaGlobalProblemText.Describe(problem))); + } + } + + // The name argument of [LuaFunction(name)]/[LuaGlobal(name)]; null while the author is typing (missing, not a + // string, or explicitly null), which LuaNames.IsValidName also rejects. The attribute constructor's own + // ArgumentException never runs at compile time, so an empty string reaches here too. + private static string? ReadName(AttributeData attribute) + { + ImmutableArray arguments = attribute.ConstructorArguments; + return arguments is [{ Kind: TypedConstantKind.Primitive, Value: string name }] + ? name + : null; + } + + private static AttributeData? FindAttribute(IMethodSymbol method, INamedTypeSymbol attributeClass) + { + foreach (AttributeData attribute in method.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) + { + return attribute; + } + } + + return null; + } + + private static Location FirstLocation(IMethodSymbol method) + { + return method.Locations.IsEmpty ? Location.None : method.Locations[0]; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs index 5589f361..0ec8b34b 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs @@ -24,36 +24,60 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// The resolved LuaProperty marker, or . /// The resolved SDK LuaState symbol, or . internal sealed class LuaBindingContractSymbols( - INamedTypeSymbol? luaFunctionAttribute, - INamedTypeSymbol? luaGlobalAttribute, - INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, - INamedTypeSymbol? luaClassAttribute, - INamedTypeSymbol? luaMethodAttribute, - INamedTypeSymbol? luaPropertyAttribute, - INamedTypeSymbol? luaState) + INamedTypeSymbol? luaFunctionAttribute, + INamedTypeSymbol? luaGlobalAttribute, + INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, + INamedTypeSymbol? luaClassAttribute, + INamedTypeSymbol? luaMethodAttribute, + INamedTypeSymbol? luaPropertyAttribute, + INamedTypeSymbol? luaState) { - /// The marker attribute of an exported Lua function. - public INamedTypeSymbol? LuaFunctionAttribute { get; } = luaFunctionAttribute; + /// The marker attribute of an exported Lua function. + public INamedTypeSymbol? LuaFunctionAttribute + { + get; + } = luaFunctionAttribute; - /// The marker attribute of a bound Lua global. - public INamedTypeSymbol? LuaGlobalAttribute { get; } = luaGlobalAttribute; + /// The marker attribute of a bound Lua global. + public INamedTypeSymbol? LuaGlobalAttribute + { + get; + } = luaGlobalAttribute; - /// The marker attribute that names a concrete static marshaller. - public INamedTypeSymbol? LuaMarshallerAttribute { get; } = luaMarshallerAttribute; + /// The marker attribute that names a concrete static marshaller. + public INamedTypeSymbol? LuaMarshallerAttribute + { + get; + } = luaMarshallerAttribute; - /// The static-abstract marshaller contract. - public INamedTypeSymbol? LuaMarshallerContract { get; } = luaMarshallerContract; + /// The static-abstract marshaller contract. + public INamedTypeSymbol? LuaMarshallerContract + { + get; + } = luaMarshallerContract; - /// The marker attribute of a generated borrowed Lua object-handle struct. - public INamedTypeSymbol? LuaClassAttribute { get; } = luaClassAttribute; + /// The marker attribute of a generated borrowed Lua object-handle struct. + public INamedTypeSymbol? LuaClassAttribute + { + get; + } = luaClassAttribute; - /// The marker attribute of a generated Lua object method body. - public INamedTypeSymbol? LuaMethodAttribute { get; } = luaMethodAttribute; + /// The marker attribute of a generated Lua object method body. + public INamedTypeSymbol? LuaMethodAttribute + { + get; + } = luaMethodAttribute; - /// The marker attribute of generated Lua object property accessors. - public INamedTypeSymbol? LuaPropertyAttribute { get; } = luaPropertyAttribute; + /// The marker attribute of generated Lua object property accessors. + public INamedTypeSymbol? LuaPropertyAttribute + { + get; + } = luaPropertyAttribute; - /// The real SDK LuaState symbol, prohibited as a LuaMethod parameter. - public INamedTypeSymbol? LuaState { get; } = luaState; + /// The real SDK LuaState symbol, prohibited as a LuaMethod parameter. + public INamedTypeSymbol? LuaState + { + get; + } = luaState; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs index 2d5c1216..5b73fe87 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; +using System.Collections.Generic; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -23,43 +26,53 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// internal sealed class LuaFunctionDuplicateState { - private readonly - ConcurrentDictionary<(string ContainingType, string LuaName), - ConcurrentQueue<(string MethodName, Location Location)>> _candidates = new(); + private readonly + ConcurrentDictionary<(string ContainingType, string LuaName), + ConcurrentQueue<(string MethodName, Location Location)>> _candidates = new(); - /// Records a method that would be exported if no sibling of its containing type registered the same Lua name. - public void AddCandidate(INamedTypeSymbol containingType, string luaName, string methodName, Location location) - { - (string, string) key = (containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), luaName); - ConcurrentQueue<(string, Location)> members = - _candidates.GetOrAdd(key, static _ => new ConcurrentQueue<(string, Location)>()); - members.Enqueue((methodName, location)); - } + /// Records a method that would be exported if no sibling of its containing type registered the same Lua name. + public void AddCandidate(INamedTypeSymbol containingType, string luaName, string methodName, Location location) + { + (string, string) key = (containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), luaName); + ConcurrentQueue<(string, Location)> members = + _candidates.GetOrAdd(key, static _ => new ConcurrentQueue<(string, Location)>()); + members.Enqueue((methodName, location)); + } - /// - /// The compilation-end action: reports CESDK2005 for every member of a group of two or more. - /// - public void Report(CompilationAnalysisContext context) - { - foreach (var members in _candidates.Values) - { - if (members.Count < 2) continue; + /// + /// The compilation-end action: reports CESDK2005 for every member of a group of two or more. + /// + public void Report(CompilationAnalysisContext context) + { + foreach (ConcurrentQueue<(string MethodName, Location Location)> members in _candidates.Values) + { + if (members.Count < 2) + { + continue; + } - foreach (var (methodName, location) in members) - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.DuplicateLuaName, - location, - methodName, - LuaNameFor(members))); - } - } + foreach ((string methodName, Location location) in members) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DuplicateLuaName, + location, + methodName, + LuaNameFor(members))); + } + } + } - private string LuaNameFor(ConcurrentQueue<(string MethodName, Location Location)> members) - { - foreach (var pair in _candidates) - if (ReferenceEquals(pair.Value, members)) - return pair.Key.LuaName; + private string LuaNameFor(ConcurrentQueue<(string MethodName, Location Location)> members) + { + foreach (KeyValuePair<(string ContainingType, string LuaName), + ConcurrentQueue<(string MethodName, Location Location)>> pair in _candidates) + { + if (ReferenceEquals(pair.Value, members)) + { + return pair.Key.LuaName; + } + } - return string.Empty; - } + return string.Empty; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs index 5a1dbee9..4fb100b5 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; namespace CheatEngine.SDK.Analyzers.Generation; @@ -14,51 +15,51 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// internal static class LuaFunctionProblemText { - /// The flags in the order they are reported for one method. - public static readonly ImmutableArray ReportOrder = - [ - LuaFunctionShapeIssues.NotOrdinaryMethod, - LuaFunctionShapeIssues.NotStatic, - LuaFunctionShapeIssues.Generic, - LuaFunctionShapeIssues.Async, - LuaFunctionShapeIssues.InvalidName, - LuaFunctionShapeIssues.ByRefParameter, - LuaFunctionShapeIssues.ParamsParameter, - LuaFunctionShapeIssues.OptionalParameter, - LuaFunctionShapeIssues.StateParameterNotFirst, - LuaFunctionShapeIssues.UnsupportedParameterType, - LuaFunctionShapeIssues.UnsupportedReturnType, - LuaFunctionShapeIssues.DuplicateName - ]; + /// The flags in the order they are reported for one method. + public static readonly ImmutableArray ReportOrder = + [ + LuaFunctionShapeIssues.NotOrdinaryMethod, + LuaFunctionShapeIssues.NotStatic, + LuaFunctionShapeIssues.Generic, + LuaFunctionShapeIssues.Async, + LuaFunctionShapeIssues.InvalidName, + LuaFunctionShapeIssues.ByRefParameter, + LuaFunctionShapeIssues.ParamsParameter, + LuaFunctionShapeIssues.OptionalParameter, + LuaFunctionShapeIssues.StateParameterNotFirst, + LuaFunctionShapeIssues.UnsupportedParameterType, + LuaFunctionShapeIssues.UnsupportedReturnType, + LuaFunctionShapeIssues.DuplicateName + ]; - /// Returns the message fragment of a single flag. - public static string Describe(LuaFunctionShapeIssues problem) - { - return problem switch - { - LuaFunctionShapeIssues.NotOrdinaryMethod => - "must be an ordinary method: not an accessor, operator, conversion, local function or explicit interface implementation", - LuaFunctionShapeIssues.NotStatic => "must be static: the generated thunk has no receiver to call it on", - LuaFunctionShapeIssues.Generic => "must not be generic", - LuaFunctionShapeIssues.Async => - "must not be async: the thunk calls it synchronously and could not catch what its continuation throws", - LuaFunctionShapeIssues.InvalidName => - "must be given a Lua identifier in [LuaFunction] that is not a reserved word (Lua 5.3 manual, section 3.1)", - LuaFunctionShapeIssues.ByRefParameter => - "must take its parameters by value: 'ref', 'in', 'out' and 'ref readonly' are not supported, a Lua argument is a value", - LuaFunctionShapeIssues.ParamsParameter => - "must not have a 'params' parameter: variadic exports are not supported", - LuaFunctionShapeIssues.OptionalParameter => - "must not have a parameter with a default value: the thunk checks the exact argument count", - LuaFunctionShapeIssues.StateParameterNotFirst => - "must take its 'CheatEngine.SDK.Lua.State.LuaState' parameter, if any, first: the state is passed first or not at all", - LuaFunctionShapeIssues.UnsupportedParameterType => - "must use only parameter types a marshaller reads: int, long, float, double, bool, nuint, ReadOnlySpan or string", - LuaFunctionShapeIssues.UnsupportedReturnType => - "must return void or a type a marshaller pushes: int, long, float, double, bool, nuint, ReadOnlySpan or string", - LuaFunctionShapeIssues.DuplicateName => - "must not share its Lua name with another [LuaFunction] of the same containing type: one registration table cannot bind a name twice", - _ => "cannot be exported by a generated thunk" - }; - } + /// Returns the message fragment of a single flag. + public static string Describe(LuaFunctionShapeIssues problem) + { + return problem switch + { + LuaFunctionShapeIssues.NotOrdinaryMethod => + "must be an ordinary method: not an accessor, operator, conversion, local function or explicit interface implementation", + LuaFunctionShapeIssues.NotStatic => "must be static: the generated thunk has no receiver to call it on", + LuaFunctionShapeIssues.Generic => "must not be generic", + LuaFunctionShapeIssues.Async => + "must not be async: the thunk calls it synchronously and could not catch what its continuation throws", + LuaFunctionShapeIssues.InvalidName => + "must be given a Lua identifier in [LuaFunction] that is not a reserved word (Lua 5.3 manual, section 3.1)", + LuaFunctionShapeIssues.ByRefParameter => + "must take its parameters by value: 'ref', 'in', 'out' and 'ref readonly' are not supported, a Lua argument is a value", + LuaFunctionShapeIssues.ParamsParameter => + "must not have a 'params' parameter: variadic exports are not supported", + LuaFunctionShapeIssues.OptionalParameter => + "must not have a parameter with a default value: the thunk checks the exact argument count", + LuaFunctionShapeIssues.StateParameterNotFirst => + "must take its 'CheatEngine.SDK.Lua.State.LuaState' parameter, if any, first: the state is passed first or not at all", + LuaFunctionShapeIssues.UnsupportedParameterType => + "must use only parameter types a marshaller reads: int, long, float, double, bool, nuint, ReadOnlySpan or string", + LuaFunctionShapeIssues.UnsupportedReturnType => + "must return void or a type a marshaller pushes: int, long, float, double, bool, nuint, ReadOnlySpan or string", + LuaFunctionShapeIssues.DuplicateName => + "must not share its Lua name with another [LuaFunction] of the same containing type: one registration table cannot bind a name twice", + _ => "cannot be exported by a generated thunk" + }; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs index 74926949..f4ca4cef 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; namespace CheatEngine.SDK.Analyzers.Generation; @@ -9,63 +10,63 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// internal static class LuaGlobalProblemText { - /// The flags in the order they are reported for one method. - public static readonly ImmutableArray ReportOrder = - [ - LuaGlobalShapeIssues.NotOrdinaryMethod, - LuaGlobalShapeIssues.NotStatic, - LuaGlobalShapeIssues.NotPartialDefinition, - LuaGlobalShapeIssues.AlreadyImplemented, - LuaGlobalShapeIssues.Generic, - LuaGlobalShapeIssues.Async, - LuaGlobalShapeIssues.InvalidName, - LuaGlobalShapeIssues.ByRefParameter, - LuaGlobalShapeIssues.ParamsParameter, - LuaGlobalShapeIssues.OptionalParameter, - LuaGlobalShapeIssues.StateParameterNotFirst, - LuaGlobalShapeIssues.UnsupportedParameterType, - LuaGlobalShapeIssues.ResultBeforeArgument, - LuaGlobalShapeIssues.UnsupportedResultType, - LuaGlobalShapeIssues.SpanResult, - LuaGlobalShapeIssues.UnsupportedReturnType, - LuaGlobalShapeIssues.TryFormReturnNotBool - ]; + /// The flags in the order they are reported for one method. + public static readonly ImmutableArray ReportOrder = + [ + LuaGlobalShapeIssues.NotOrdinaryMethod, + LuaGlobalShapeIssues.NotStatic, + LuaGlobalShapeIssues.NotPartialDefinition, + LuaGlobalShapeIssues.AlreadyImplemented, + LuaGlobalShapeIssues.Generic, + LuaGlobalShapeIssues.Async, + LuaGlobalShapeIssues.InvalidName, + LuaGlobalShapeIssues.ByRefParameter, + LuaGlobalShapeIssues.ParamsParameter, + LuaGlobalShapeIssues.OptionalParameter, + LuaGlobalShapeIssues.StateParameterNotFirst, + LuaGlobalShapeIssues.UnsupportedParameterType, + LuaGlobalShapeIssues.ResultBeforeArgument, + LuaGlobalShapeIssues.UnsupportedResultType, + LuaGlobalShapeIssues.SpanResult, + LuaGlobalShapeIssues.UnsupportedReturnType, + LuaGlobalShapeIssues.TryFormReturnNotBool + ]; - /// Returns the message fragment of a single flag. - public static string Describe(LuaGlobalShapeIssues problem) - { - return problem switch - { - LuaGlobalShapeIssues.NotOrdinaryMethod => - "must be an ordinary method: not an accessor, operator, local function or explicit interface implementation", - LuaGlobalShapeIssues.NotStatic => "must be static: this pass binds static members only", - LuaGlobalShapeIssues.NotPartialDefinition => - "must be the defining declaration of a partial method: the generator adds the implementing part", - LuaGlobalShapeIssues.AlreadyImplemented => - "must not already have an implementing declaration: a generated one would be a second body", - LuaGlobalShapeIssues.Generic => "must not be generic", - LuaGlobalShapeIssues.Async => "must not be async", - LuaGlobalShapeIssues.InvalidName => - "must be given a Lua identifier in [LuaGlobal] that is not a reserved word (Lua 5.3 manual, section 3.1)", - LuaGlobalShapeIssues.ByRefParameter => - "must take its arguments by value and its results as 'out' parameters: 'ref', 'in' and 'ref readonly' are not supported", - LuaGlobalShapeIssues.ParamsParameter => "must not have a 'params' parameter", - LuaGlobalShapeIssues.OptionalParameter => - "must not have a parameter with a default value: the body pushes every argument", - LuaGlobalShapeIssues.StateParameterNotFirst => - "must take its 'CheatEngine.SDK.Lua.State.LuaState' parameter, if any, first", - LuaGlobalShapeIssues.UnsupportedParameterType => - "must use only argument types a marshaller pushes: int, long, float, double, bool, nuint, ReadOnlySpan or string", - LuaGlobalShapeIssues.ResultBeforeArgument => - "must declare its results ('out' parameters and copy-out pairs) after every argument", - LuaGlobalShapeIssues.UnsupportedResultType => - "must use only result types a marshaller reads (int, long, float, double, bool, nuint, string), or a 'Span destination, out int written' copy-out pair", - LuaGlobalShapeIssues.SpanResult => - "must not return a ReadOnlySpan result: it would point into a Lua string popped before the wrapper returns; use 'Span destination, out int written' or 'string' instead", - LuaGlobalShapeIssues.UnsupportedReturnType => "must return void, bool or a type a marshaller reads", - LuaGlobalShapeIssues.TryFormReturnNotBool => - "must return bool or LuaOperationStatus when it declares 'out' results: the throwing form has no 'out' parameter", - _ => "cannot receive a generated body" - }; - } + /// Returns the message fragment of a single flag. + public static string Describe(LuaGlobalShapeIssues problem) + { + return problem switch + { + LuaGlobalShapeIssues.NotOrdinaryMethod => + "must be an ordinary method: not an accessor, operator, local function or explicit interface implementation", + LuaGlobalShapeIssues.NotStatic => "must be static: this pass binds static members only", + LuaGlobalShapeIssues.NotPartialDefinition => + "must be the defining declaration of a partial method: the generator adds the implementing part", + LuaGlobalShapeIssues.AlreadyImplemented => + "must not already have an implementing declaration: a generated one would be a second body", + LuaGlobalShapeIssues.Generic => "must not be generic", + LuaGlobalShapeIssues.Async => "must not be async", + LuaGlobalShapeIssues.InvalidName => + "must be given a Lua identifier in [LuaGlobal] that is not a reserved word (Lua 5.3 manual, section 3.1)", + LuaGlobalShapeIssues.ByRefParameter => + "must take its arguments by value and its results as 'out' parameters: 'ref', 'in' and 'ref readonly' are not supported", + LuaGlobalShapeIssues.ParamsParameter => "must not have a 'params' parameter", + LuaGlobalShapeIssues.OptionalParameter => + "must not have a parameter with a default value: the body pushes every argument", + LuaGlobalShapeIssues.StateParameterNotFirst => + "must take its 'CheatEngine.SDK.Lua.State.LuaState' parameter, if any, first", + LuaGlobalShapeIssues.UnsupportedParameterType => + "must use only argument types a marshaller pushes: int, long, float, double, bool, nuint, ReadOnlySpan or string", + LuaGlobalShapeIssues.ResultBeforeArgument => + "must declare its results ('out' parameters and copy-out pairs) after every argument", + LuaGlobalShapeIssues.UnsupportedResultType => + "must use only result types a marshaller reads (int, long, float, double, bool, nuint, string), or a 'Span destination, out int written' copy-out pair", + LuaGlobalShapeIssues.SpanResult => + "must not return a ReadOnlySpan result: it would point into a Lua string popped before the wrapper returns; use 'Span destination, out int written' or 'string' instead", + LuaGlobalShapeIssues.UnsupportedReturnType => "must return void, bool or a type a marshaller reads", + LuaGlobalShapeIssues.TryFormReturnNotBool => + "must return bool or LuaOperationStatus when it declares 'out' results: the throwing form has no 'out' parameter", + _ => "cannot receive a generated body" + }; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs index d6dfcfd9..7afb0b68 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Immutable; using System.Threading; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.WellKnown; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -26,457 +28,726 @@ namespace CheatEngine.SDK.Analyzers.Generation; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class LuaObjectBindingAnalyzer : DiagnosticAnalyzer { - private const string PartialKeyword = "partial"; - - /// - public override ImmutableArray SupportedDiagnostics { get; } = - [ - DiagnosticDescriptors.InvalidLuaAnnotationTarget, - DiagnosticDescriptors.GeneratedLuaIdentityCollision, - ]; - - /// - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(OnCompilationStart); - } - - private static void OnCompilationStart(CompilationStartAnalysisContext context) - { - LuaObjectContractSymbols symbols = new( - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaClassAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMethodAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaPropertyAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaFunctionAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaGlobalAttribute), - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMarshallerAttribute), - SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.ILuaMarshaller), - SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState), - context.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"), - context.Compilation.GetTypeByMetadataName("CheatEngine.SDK.Engine.Objects.CEObject")); - if (!symbols.HasAnyLuaObjectAnnotation) return; - - context.RegisterSymbolAction(symbolContext => AnalyzeType(symbolContext, symbols), SymbolKind.NamedType); - context.RegisterSymbolAction(symbolContext => AnalyzeMethod(symbolContext, symbols), SymbolKind.Method); - context.RegisterSymbolAction(symbolContext => AnalyzeProperty(symbolContext, symbols), SymbolKind.Property); - } - - private static void AnalyzeType(SymbolAnalysisContext context, LuaObjectContractSymbols symbols) - { - var type = (INamedTypeSymbol)context.Symbol; - if (symbols.LuaClassAttribute is not null - && FindAttribute(type, symbols.LuaClassAttribute) is { } luaClassAttribute) - { - var problem = LuaClassProblem(type, luaClassAttribute, context.CancellationToken); - if (problem.Length > 0) - ReportInvalid(context, type, problem); - else - ReportLuaClassIdentityCollisions(context, type, symbols.CEObject); - } - - // LuaFunction and LuaGlobal may live on an ordinary partial type. Their generated thunks, registration methods - // and cache fields still have to remain distinct from source members even when the type is not a LuaClass. - ReportLuaFunctionIdentityCollisions(context, type, symbols); - ReportLuaGlobalIdentityCollisions(context, type, symbols); - } - - private static void AnalyzeMethod(SymbolAnalysisContext context, LuaObjectContractSymbols symbols) - { - var method = (IMethodSymbol)context.Symbol; - if (symbols.LuaMethodAttribute is not null - && FindAttribute(method, symbols.LuaMethodAttribute) is { } luaMethodAttribute) - { - var problem = LuaMethodProblem(method, luaMethodAttribute, symbols, context.CancellationToken); - if (problem.Length > 0) ReportInvalid(context, method, problem); - } - } - - private static void AnalyzeProperty(SymbolAnalysisContext context, LuaObjectContractSymbols symbols) - { - var property = (IPropertySymbol)context.Symbol; - if (symbols.LuaPropertyAttribute is null - || FindAttribute(property, symbols.LuaPropertyAttribute) is not { } luaPropertyAttribute) - return; - - var problem = LuaPropertyProblem(property, luaPropertyAttribute, symbols, context.CancellationToken); - if (problem.Length > 0) ReportInvalid(context, property, problem); - } - - // CESDK2007 needs all members of the type but no other compilation-wide information, so it remains an IDE-live rule. - private static void ReportLuaClassIdentityCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, - INamedTypeSymbol? ceObject) - { - if (LuaClassGeneratedNames.IsGeneratedType(type.Name)) ReportCollision(context, type, type.Name); - - foreach (var member in type.GetMembers()) - { - if (LuaClassGeneratedNames.IsGeneratedMember(member.Name)) - ReportCollision(context, member, member.Name); - else if (LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) - ReportCollision(context, member, "Handle"); - } - - foreach (var member in type.GetMembers()) - if (member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator } method - && method.Name is "op_Equality" or "op_Inequality") - ReportCollision(context, member, "operator " + - (string.Equals(method.Name, "op_Equality", StringComparison.Ordinal) - ? "==" - : "!=")); - - if (ceObject is null) return; - - foreach (var constructor in type.InstanceConstructors) - if (constructor.Parameters.Length == 1 - && constructor.Parameters[0].RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, ceObject)) - ReportCollision(context, constructor, type.Name + "(CEObject)"); - } - - private static void ReportNamedMemberCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, - string sourceName, string generatedIdentity) - { - foreach (var member in type.GetMembers(sourceName)) ReportCollision(context, member, generatedIdentity); - } - - private static void ReportCollision(SymbolAnalysisContext context, ISymbol member, string generatedIdentity) - { - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.GeneratedLuaIdentityCollision, - FirstLocation(member), member.Name, generatedIdentity)); - } - - private static void ReportLuaFunctionIdentityCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, - LuaObjectContractSymbols symbols) - { - if (symbols.LuaFunctionAttribute is null) return; - - var containsValidFunction = false; - var containingIssues = ContainingTypeShape.Inspect(type, context.CancellationToken); - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method - || FindAttribute(method, symbols.LuaFunctionAttribute) is not { } attribute) - continue; - - var name = ReadName(attribute); - var issues = LuaFunctionShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); - if (!LuaNames.IsValidName(name)) issues |= LuaFunctionShapeIssues.InvalidName; - if (issues != LuaFunctionShapeIssues.None || containingIssues != ContainingTypeIssues.None) continue; - - containsValidFunction = true; - ReportNamedMemberCollisions(context, type, LuaThunkModel.ThunkNameFor(name!), - LuaThunkModel.ThunkNameFor(name!)); - } - - if (!containsValidFunction) return; - - ReportNamedMemberCollisions(context, type, LuaRegistrationEmitter.RegisterLeaseMethodName, - LuaRegistrationEmitter.RegisterLeaseMethodName); - ReportNamedMemberCollisions(context, type, LuaRegistrationEmitter.RegisterMethodName, - LuaRegistrationEmitter.RegisterMethodName); - ReportNamedMemberCollisions(context, type, LuaRegistrationEmitter.UnregisterMethodName, - LuaRegistrationEmitter.UnregisterMethodName); - } - - private static void ReportLuaGlobalIdentityCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, - LuaObjectContractSymbols symbols) - { - if (symbols.LuaGlobalAttribute is null) return; - - var containingIssues = ContainingTypeShape.Inspect(type, context.CancellationToken); - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method - || FindAttribute(method, symbols.LuaGlobalAttribute) is not { } attribute) - continue; - - var name = ReadName(attribute); - var issues = LuaGlobalShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); - if (!LuaNames.IsValidName(name)) issues |= LuaGlobalShapeIssues.InvalidName; - if (issues != LuaGlobalShapeIssues.None || containingIssues != ContainingTypeIssues.None) continue; - - ReportLuaGlobalLocalCollisions(context, method); - var generatedName = LuaGlobalCallModel.CacheFieldFor(name!); - ReportNamedMemberCollisions(context, type, generatedName, generatedName); - } - } - - private static void ReportLuaGlobalLocalCollisions(SymbolAnalysisContext context, IMethodSymbol method) - { - foreach (var parameter in method.Parameters) - if (IsLuaGlobalGeneratedLocalName(parameter.Name)) - ReportCollision(context, parameter, "generated local " + parameter.Name); - } - - private static string LuaClassProblem(INamedTypeSymbol type, AttributeData attribute, - CancellationToken cancellationToken) - { - if (!LuaNames.IsValidName(ReadName(attribute))) return "the Lua class name must be a Lua identifier"; - if (type.TypeKind != TypeKind.Struct) return "[LuaClass] is supported only on a struct"; - if (type.IsRecord) return "the borrowed handle struct must not be a record struct"; - if (type.IsRefLikeType) return "the borrowed handle struct must not be ref-like"; - if (!type.IsReadOnly) return "the borrowed handle struct must be readonly"; - if (type.Arity != 0) return "the borrowed handle struct must not be generic"; - if (type.IsFileLocal) return "the borrowed handle struct must not be file-local"; - if (!IsPartial(type, cancellationToken)) return "the borrowed handle struct must be partial"; - - return ContainingLuaClassProblem(type.ContainingType, cancellationToken); - } - - private static string ContainingLuaClassProblem(INamedTypeSymbol? containing, CancellationToken cancellationToken) - { - for (; containing is not null; containing = containing.ContainingType) - { - if (containing.IsGenericType) return "no containing type of the borrowed handle struct may be generic"; - if (containing.IsFileLocal) return "no containing type of the borrowed handle struct may be file-local"; - if (!IsPartial(containing, cancellationToken)) - return "every containing type of the borrowed handle struct must be partial"; - } - - return string.Empty; - } - - private static string LuaMethodProblem(IMethodSymbol method, AttributeData attribute, - LuaObjectContractSymbols symbols, - CancellationToken cancellationToken) - { - var classProblem = LuaClassProblemForMember(method.ContainingType, symbols, cancellationToken); - if (classProblem.Length > 0) return classProblem; - if (!LuaNames.IsValidName(ReadName(attribute))) return "the Lua method name must be a Lua identifier"; - if (method.MethodKind != MethodKind.Ordinary) return "the member must be an ordinary method"; - if (method.IsStatic) return "the generated object method needs an instance receiver"; - if (method.IsGenericMethod) return "the generated object method must not be generic"; - if (method.IsAsync) return "the generated object method must not be async"; - if (!method.IsPartialDefinition) return "the member must be the defining declaration of a partial method"; - if (method.PartialImplementationPart is not null) return "the partial method already has an implementation"; - if (method.ReturnsByRef || method.ReturnsByRefReadonly) - return "ref and ref readonly returns are not supported"; - - var hasOutResult = false; - foreach (var parameter in method.Parameters) - { - cancellationToken.ThrowIfCancellationRequested(); - if (IsGeneratedLocalName(parameter.Name)) return "the parameter name collides with a generated local"; - if (parameter.RefKind == RefKind.Out) - { - hasOutResult = true; - if (!IsScalar(parameter.Type, allowReadOnlySpan: false, symbols.ReadOnlySpan)) - return "out results must be supported scalar values"; - - continue; - } - - if (hasOutResult) return "arguments must precede every out result"; - if (parameter.RefKind != RefKind.None) return "ref, in and ref readonly parameters are not supported"; - if (parameter.IsParams || parameter.HasExplicitDefaultValue) - return "optional and params parameters are not supported"; - if (symbols.LuaState is not null && SymbolEqualityComparer.Default.Equals(parameter.Type, symbols.LuaState)) - return "LuaMethod does not take a LuaState parameter"; - if (!IsScalar(parameter.Type, allowReadOnlySpan: true, symbols.ReadOnlySpan)) - return "parameters must be supported scalar values"; - } - - return LuaMethodReturnProblem(method, hasOutResult, symbols.ReadOnlySpan); - } - - private static string LuaMethodReturnProblem(IMethodSymbol method, bool hasOutResult, - INamedTypeSymbol? readOnlySpan) - { - if (hasOutResult) - return method.ReturnType.SpecialType == SpecialType.System_Boolean - ? string.Empty - : "a method with out results must return bool"; - - return method.ReturnsVoid || IsScalar(method.ReturnType, allowReadOnlySpan: false, readOnlySpan) - ? string.Empty - : "the throwing form must return void or a supported scalar value"; - } - - private static string LuaPropertyProblem(IPropertySymbol property, AttributeData attribute, - LuaObjectContractSymbols symbols, - CancellationToken cancellationToken) - { - var classProblem = LuaClassProblemForMember(property.ContainingType, symbols, cancellationToken); - if (classProblem.Length > 0) return classProblem; - if (!LuaNames.IsValidName(ReadName(attribute))) return "the Lua property name must be a Lua identifier"; - if (property.IsStatic) return "the generated object property needs an instance receiver"; - if (property.RefKind != RefKind.None) - return "ref and ref readonly properties are not supported"; - if (!IsScalar(property.Type, allowReadOnlySpan: false, symbols.ReadOnlySpan)) - return "the property type must be a supported scalar value"; - if (!IsBodylessPartialProperty(property, cancellationToken)) - return "the member must be a partial property with bodyless get and/or set accessors"; - - return string.Empty; - } - - private static string LuaClassProblemForMember(INamedTypeSymbol containingType, LuaObjectContractSymbols symbols, - CancellationToken cancellationToken) - { - if (symbols.LuaClassAttribute is null - || FindAttribute(containingType, symbols.LuaClassAttribute) is not { } luaClassAttribute) - return "the containing type must carry [LuaClass]"; - - return LuaClassProblem(containingType, luaClassAttribute, cancellationToken); - } - - private static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellationToken) - { - var hasDeclaration = false; - foreach (var reference in type.DeclaringSyntaxReferences) - { - cancellationToken.ThrowIfCancellationRequested(); - if (reference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax declaration) return false; - - hasDeclaration = true; - var hasPartialModifier = false; - foreach (var modifier in declaration.Modifiers) - if (string.Equals(modifier.ValueText, PartialKeyword, StringComparison.Ordinal)) - { - hasPartialModifier = true; - break; - } - - if (!hasPartialModifier) return false; - } - - return hasDeclaration; - } - - private static bool IsBodylessPartialProperty(IPropertySymbol property, CancellationToken cancellationToken) - { - var hasDeclaration = false; - foreach (var reference in property.DeclaringSyntaxReferences) - { - cancellationToken.ThrowIfCancellationRequested(); - if (reference.GetSyntax(cancellationToken) is not PropertyDeclarationSyntax declaration) return false; - - hasDeclaration = true; - var hasPartialModifier = false; - foreach (var modifier in declaration.Modifiers) - if (string.Equals(modifier.ValueText, PartialKeyword, StringComparison.Ordinal)) - { - hasPartialModifier = true; - break; - } - - if (!hasPartialModifier || declaration.AccessorList is null || - declaration.AccessorList.Accessors.Count == 0) - return false; - - if (declaration.ExplicitInterfaceSpecifier is not null) return false; - - foreach (var accessor in declaration.AccessorList.Accessors) - if (accessor.Kind() is not SyntaxKind.GetAccessorDeclaration and not SyntaxKind.SetAccessorDeclaration - || accessor.Body is not null || accessor.ExpressionBody is not null - || !HasSupportedAccessorModifiers(accessor)) - return false; - } - - return hasDeclaration; - } - - private static bool HasSupportedAccessorModifiers(AccessorDeclarationSyntax accessor) - { - foreach (var modifier in accessor.Modifiers) - if (modifier.Kind() is not (SyntaxKind.PublicKeyword or SyntaxKind.PrivateKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword)) - return false; - - return true; - } - - private static bool IsScalar(ITypeSymbol type, bool allowReadOnlySpan, INamedTypeSymbol? readOnlySpan) - { - if (type.SpecialType is SpecialType.System_Int32 or SpecialType.System_Int64 or SpecialType.System_Single - or SpecialType.System_Double or SpecialType.System_Boolean or SpecialType.System_UIntPtr - or SpecialType.System_String) - return true; - - if (!allowReadOnlySpan || readOnlySpan is null || type is not INamedTypeSymbol { IsGenericType: true } named - || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) - || named.TypeArguments.Length != 1) - return false; - - return named.TypeArguments[0].SpecialType == SpecialType.System_Byte; - } - - private static bool IsGeneratedLocalName(string name) - { - return name is "__ceState" or "__ceOperation" or "__ceTop" or "__ceStatus" or "__ceResult"; - } - - private static bool IsLuaGlobalGeneratedLocalName(string name) - { - return name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result"; - } - - private static AttributeData? FindAttribute(ISymbol symbol, INamedTypeSymbol attributeClass) - { - foreach (var attribute in symbol.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) - return attribute; - - return null; - } - - private static string? ReadName(AttributeData attribute) - { - return attribute.ConstructorArguments is [{ Kind: TypedConstantKind.Primitive, Value: string name }] - ? name - : null; - } - - private static void ReportInvalid(SymbolAnalysisContext context, ISymbol symbol, string problem) - { - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidLuaAnnotationTarget, - FirstLocation(symbol), symbol.Name, problem)); - } - - private static Location FirstLocation(ISymbol symbol) - { - return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; - } - - private sealed class LuaObjectContractSymbols( - INamedTypeSymbol? luaClassAttribute, - INamedTypeSymbol? luaMethodAttribute, - INamedTypeSymbol? luaPropertyAttribute, - INamedTypeSymbol? luaFunctionAttribute, - INamedTypeSymbol? luaGlobalAttribute, - INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, - INamedTypeSymbol? luaState, - INamedTypeSymbol? readOnlySpan, - INamedTypeSymbol? ceObject) - { - public INamedTypeSymbol? LuaClassAttribute { get; } = luaClassAttribute; - - public INamedTypeSymbol? LuaMethodAttribute { get; } = luaMethodAttribute; - - public INamedTypeSymbol? LuaPropertyAttribute { get; } = luaPropertyAttribute; - - public INamedTypeSymbol? LuaFunctionAttribute { get; } = luaFunctionAttribute; - - public INamedTypeSymbol? LuaGlobalAttribute { get; } = luaGlobalAttribute; - - public INamedTypeSymbol? LuaMarshallerAttribute { get; } = luaMarshallerAttribute; - - public INamedTypeSymbol? LuaMarshallerContract { get; } = luaMarshallerContract; - - public INamedTypeSymbol? LuaState { get; } = luaState; - - public INamedTypeSymbol? ReadOnlySpan { get; } = readOnlySpan; - - public INamedTypeSymbol? CEObject { get; } = ceObject; - - public bool HasAnyLuaObjectAnnotation => LuaClassAttribute is not null || LuaMethodAttribute is not null - || LuaPropertyAttribute is not null || - LuaFunctionAttribute is not null - || LuaGlobalAttribute is not null; - } + private const string PartialKeyword = "partial"; + + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = + [ + DiagnosticDescriptors.InvalidLuaAnnotationTarget, + DiagnosticDescriptors.GeneratedLuaIdentityCollision + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + LuaObjectContractSymbols symbols = new( + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaClassAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMethodAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaPropertyAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaFunctionAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaGlobalAttribute), + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMarshallerAttribute), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.ILuaMarshaller), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState), + context.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"), + context.Compilation.GetTypeByMetadataName("CheatEngine.SDK.Engine.Objects.CEObject")); + if (!symbols.HasAnyLuaObjectAnnotation) + { + return; + } + + context.RegisterSymbolAction(symbolContext => AnalyzeType(symbolContext, symbols), SymbolKind.NamedType); + context.RegisterSymbolAction(symbolContext => AnalyzeMethod(symbolContext, symbols), SymbolKind.Method); + context.RegisterSymbolAction(symbolContext => AnalyzeProperty(symbolContext, symbols), SymbolKind.Property); + } + + private static void AnalyzeType(SymbolAnalysisContext context, LuaObjectContractSymbols symbols) + { + INamedTypeSymbol type = (INamedTypeSymbol) context.Symbol; + if (symbols.LuaClassAttribute is not null + && FindAttribute(type, symbols.LuaClassAttribute) is { } luaClassAttribute) + { + string problem = LuaClassProblem(type, luaClassAttribute, context.CancellationToken); + if (problem.Length > 0) + { + ReportInvalid(context, type, problem); + } + else + { + ReportLuaClassIdentityCollisions(context, type, symbols.CEObject); + } + } + + // LuaFunction and LuaGlobal may live on an ordinary partial type. Their generated thunks, registration methods + // and cache fields still have to remain distinct from source members even when the type is not a LuaClass. + ReportLuaFunctionIdentityCollisions(context, type, symbols); + ReportLuaGlobalIdentityCollisions(context, type, symbols); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context, LuaObjectContractSymbols symbols) + { + IMethodSymbol method = (IMethodSymbol) context.Symbol; + if (symbols.LuaMethodAttribute is not null + && FindAttribute(method, symbols.LuaMethodAttribute) is { } luaMethodAttribute) + { + string problem = LuaMethodProblem(method, luaMethodAttribute, symbols, context.CancellationToken); + if (problem.Length > 0) + { + ReportInvalid(context, method, problem); + } + } + } + + private static void AnalyzeProperty(SymbolAnalysisContext context, LuaObjectContractSymbols symbols) + { + IPropertySymbol property = (IPropertySymbol) context.Symbol; + if (symbols.LuaPropertyAttribute is null + || FindAttribute(property, symbols.LuaPropertyAttribute) is not { } luaPropertyAttribute) + { + return; + } + + string problem = LuaPropertyProblem(property, luaPropertyAttribute, symbols, context.CancellationToken); + if (problem.Length > 0) + { + ReportInvalid(context, property, problem); + } + } + + // CESDK2007 needs all members of the type but no other compilation-wide information, so it remains an IDE-live rule. + private static void ReportLuaClassIdentityCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, + INamedTypeSymbol? ceObject) + { + if (LuaClassGeneratedNames.IsGeneratedType(type.Name)) + { + ReportCollision(context, type, type.Name); + } + + foreach (ISymbol member in type.GetMembers()) + { + if (LuaClassGeneratedNames.IsGeneratedMember(member.Name)) + { + ReportCollision(context, member, member.Name); + } + else if (LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) + { + ReportCollision(context, member, "Handle"); + } + } + + foreach (ISymbol member in type.GetMembers()) + { + if (member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator } method + && method.Name is "op_Equality" or "op_Inequality") + { + ReportCollision(context, member, "operator " + + (string.Equals(method.Name, "op_Equality", StringComparison.Ordinal) + ? "==" + : "!=")); + } + } + + if (ceObject is null) + { + return; + } + + foreach (IMethodSymbol constructor in type.InstanceConstructors) + { + if (constructor.Parameters.Length == 1 + && constructor.Parameters[0].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, ceObject)) + { + ReportCollision(context, constructor, type.Name + "(CEObject)"); + } + } + } + + private static void ReportNamedMemberCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, + string sourceName, string generatedIdentity) + { + foreach (ISymbol member in type.GetMembers(sourceName)) + { + ReportCollision(context, member, generatedIdentity); + } + } + + private static void ReportCollision(SymbolAnalysisContext context, ISymbol member, string generatedIdentity) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.GeneratedLuaIdentityCollision, + FirstLocation(member), member.Name, generatedIdentity)); + } + + private static void ReportLuaFunctionIdentityCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, + LuaObjectContractSymbols symbols) + { + if (symbols.LuaFunctionAttribute is null) + { + return; + } + + bool containsValidFunction = false; + ContainingTypeIssues containingIssues = ContainingTypeShape.Inspect(type, context.CancellationToken); + foreach (ISymbol member in type.GetMembers()) + { + if (member is not IMethodSymbol method + || FindAttribute(method, symbols.LuaFunctionAttribute) is not { } attribute) + { + continue; + } + + string? name = ReadName(attribute); + LuaFunctionShapeIssues issues = LuaFunctionShape.Inspect(context.Compilation, method, symbols.LuaState, + symbols.LuaMarshallerAttribute, + symbols.LuaMarshallerContract, out _); + if (!LuaNames.IsValidName(name)) + { + issues |= LuaFunctionShapeIssues.InvalidName; + } + + if (issues != LuaFunctionShapeIssues.None || containingIssues != ContainingTypeIssues.None) + { + continue; + } + + containsValidFunction = true; + ReportNamedMemberCollisions(context, type, LuaThunkModel.ThunkNameFor(name!), + LuaThunkModel.ThunkNameFor(name!)); + } + + if (!containsValidFunction) + { + return; + } + + ReportNamedMemberCollisions(context, type, LuaRegistrationEmitter.RegisterLeaseMethodName, + LuaRegistrationEmitter.RegisterLeaseMethodName); + ReportNamedMemberCollisions(context, type, LuaRegistrationEmitter.RegisterMethodName, + LuaRegistrationEmitter.RegisterMethodName); + ReportNamedMemberCollisions(context, type, LuaRegistrationEmitter.UnregisterMethodName, + LuaRegistrationEmitter.UnregisterMethodName); + } + + private static void ReportLuaGlobalIdentityCollisions(SymbolAnalysisContext context, INamedTypeSymbol type, + LuaObjectContractSymbols symbols) + { + if (symbols.LuaGlobalAttribute is null) + { + return; + } + + ContainingTypeIssues containingIssues = ContainingTypeShape.Inspect(type, context.CancellationToken); + foreach (ISymbol member in type.GetMembers()) + { + if (member is not IMethodSymbol method + || FindAttribute(method, symbols.LuaGlobalAttribute) is not { } attribute) + { + continue; + } + + string? name = ReadName(attribute); + LuaGlobalShapeIssues issues = LuaGlobalShape.Inspect(context.Compilation, method, symbols.LuaState, + symbols.LuaMarshallerAttribute, + symbols.LuaMarshallerContract, out _); + if (!LuaNames.IsValidName(name)) + { + issues |= LuaGlobalShapeIssues.InvalidName; + } + + if (issues != LuaGlobalShapeIssues.None || containingIssues != ContainingTypeIssues.None) + { + continue; + } + + ReportLuaGlobalLocalCollisions(context, method); + string generatedName = LuaGlobalCallModel.CacheFieldFor(name!); + ReportNamedMemberCollisions(context, type, generatedName, generatedName); + } + } + + private static void ReportLuaGlobalLocalCollisions(SymbolAnalysisContext context, IMethodSymbol method) + { + foreach (IParameterSymbol parameter in method.Parameters) + { + if (IsLuaGlobalGeneratedLocalName(parameter.Name)) + { + ReportCollision(context, parameter, "generated local " + parameter.Name); + } + } + } + + private static string LuaClassProblem(INamedTypeSymbol type, AttributeData attribute, + CancellationToken cancellationToken) + { + if (!LuaNames.IsValidName(ReadName(attribute))) + { + return "the Lua class name must be a Lua identifier"; + } + + if (type.TypeKind != TypeKind.Struct) + { + return "[LuaClass] is supported only on a struct"; + } + + if (type.IsRecord) + { + return "the borrowed handle struct must not be a record struct"; + } + + if (type.IsRefLikeType) + { + return "the borrowed handle struct must not be ref-like"; + } + + if (!type.IsReadOnly) + { + return "the borrowed handle struct must be readonly"; + } + + if (type.Arity != 0) + { + return "the borrowed handle struct must not be generic"; + } + + if (type.IsFileLocal) + { + return "the borrowed handle struct must not be file-local"; + } + + if (!IsPartial(type, cancellationToken)) + { + return "the borrowed handle struct must be partial"; + } + + return ContainingLuaClassProblem(type.ContainingType, cancellationToken); + } + + private static string ContainingLuaClassProblem(INamedTypeSymbol? containing, CancellationToken cancellationToken) + { + for (; containing is not null; containing = containing.ContainingType) + { + if (containing.IsGenericType) + { + return "no containing type of the borrowed handle struct may be generic"; + } + + if (containing.IsFileLocal) + { + return "no containing type of the borrowed handle struct may be file-local"; + } + + if (!IsPartial(containing, cancellationToken)) + { + return "every containing type of the borrowed handle struct must be partial"; + } + } + + return string.Empty; + } + + private static string LuaMethodProblem(IMethodSymbol method, AttributeData attribute, + LuaObjectContractSymbols symbols, + CancellationToken cancellationToken) + { + string classProblem = LuaClassProblemForMember(method.ContainingType, symbols, cancellationToken); + if (classProblem.Length > 0) + { + return classProblem; + } + + if (!LuaNames.IsValidName(ReadName(attribute))) + { + return "the Lua method name must be a Lua identifier"; + } + + if (method.MethodKind != MethodKind.Ordinary) + { + return "the member must be an ordinary method"; + } + + if (method.IsStatic) + { + return "the generated object method needs an instance receiver"; + } + + if (method.IsGenericMethod) + { + return "the generated object method must not be generic"; + } + + if (method.IsAsync) + { + return "the generated object method must not be async"; + } + + if (!method.IsPartialDefinition) + { + return "the member must be the defining declaration of a partial method"; + } + + if (method.PartialImplementationPart is not null) + { + return "the partial method already has an implementation"; + } + + if (method.ReturnsByRef || method.ReturnsByRefReadonly) + { + return "ref and ref readonly returns are not supported"; + } + + string parameterProblem = LuaMethodParameterProblem(method, symbols, cancellationToken, out bool hasOutResult); + if (parameterProblem.Length > 0) + { + return parameterProblem; + } + + return LuaMethodReturnProblem(method, hasOutResult, symbols.ReadOnlySpan); + } + + private static string LuaMethodParameterProblem(IMethodSymbol method, LuaObjectContractSymbols symbols, + CancellationToken cancellationToken, out bool hasOutResult) + { + hasOutResult = false; + foreach (IParameterSymbol parameter in method.Parameters) + { + cancellationToken.ThrowIfCancellationRequested(); + if (IsGeneratedLocalName(parameter.Name)) + { + return "the parameter name collides with a generated local"; + } + + if (parameter.RefKind == RefKind.Out) + { + hasOutResult = true; + if (!IsScalar(parameter.Type, false, symbols.ReadOnlySpan)) + { + return "out results must be supported scalar values"; + } + + continue; + } + + if (hasOutResult) + { + return "arguments must precede every out result"; + } + + if (parameter.RefKind != RefKind.None) + { + return "ref, in and ref readonly parameters are not supported"; + } + + if (parameter.IsParams || parameter.HasExplicitDefaultValue) + { + return "optional and params parameters are not supported"; + } + + if (symbols.LuaState is not null && SymbolEqualityComparer.Default.Equals(parameter.Type, symbols.LuaState)) + { + return "LuaMethod does not take a LuaState parameter"; + } + + if (!IsScalar(parameter.Type, true, symbols.ReadOnlySpan)) + { + return "parameters must be supported scalar values"; + } + } + + return string.Empty; + } + + private static string LuaMethodReturnProblem(IMethodSymbol method, bool hasOutResult, + INamedTypeSymbol? readOnlySpan) + { + if (hasOutResult) + { + return method.ReturnType.SpecialType == SpecialType.System_Boolean + ? string.Empty + : "a method with out results must return bool"; + } + + return method.ReturnsVoid || IsScalar(method.ReturnType, false, readOnlySpan) + ? string.Empty + : "the throwing form must return void or a supported scalar value"; + } + + private static string LuaPropertyProblem(IPropertySymbol property, AttributeData attribute, + LuaObjectContractSymbols symbols, + CancellationToken cancellationToken) + { + string classProblem = LuaClassProblemForMember(property.ContainingType, symbols, cancellationToken); + if (classProblem.Length > 0) + { + return classProblem; + } + + if (!LuaNames.IsValidName(ReadName(attribute))) + { + return "the Lua property name must be a Lua identifier"; + } + + if (property.IsStatic) + { + return "the generated object property needs an instance receiver"; + } + + if (property.RefKind != RefKind.None) + { + return "ref and ref readonly properties are not supported"; + } + + if (!IsScalar(property.Type, false, symbols.ReadOnlySpan)) + { + return "the property type must be a supported scalar value"; + } + + if (!IsBodylessPartialProperty(property, cancellationToken)) + { + return "the member must be a partial property with bodyless get and/or set accessors"; + } + + return string.Empty; + } + + private static string LuaClassProblemForMember(INamedTypeSymbol containingType, LuaObjectContractSymbols symbols, + CancellationToken cancellationToken) + { + if (symbols.LuaClassAttribute is null + || FindAttribute(containingType, symbols.LuaClassAttribute) is not { } luaClassAttribute) + { + return "the containing type must carry [LuaClass]"; + } + + return LuaClassProblem(containingType, luaClassAttribute, cancellationToken); + } + + private static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellationToken) + { + bool hasDeclaration = false; + foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax declaration) + { + return false; + } + + hasDeclaration = true; + bool hasPartialModifier = false; + foreach (SyntaxToken modifier in declaration.Modifiers) + { + if (string.Equals(modifier.ValueText, PartialKeyword, StringComparison.Ordinal)) + { + hasPartialModifier = true; + break; + } + } + + if (!hasPartialModifier) + { + return false; + } + } + + return hasDeclaration; + } + + private static bool IsBodylessPartialProperty(IPropertySymbol property, CancellationToken cancellationToken) + { + bool hasDeclaration = false; + foreach (SyntaxReference reference in property.DeclaringSyntaxReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reference.GetSyntax(cancellationToken) is not PropertyDeclarationSyntax declaration) + { + return false; + } + + hasDeclaration = true; + bool hasPartialModifier = false; + foreach (SyntaxToken modifier in declaration.Modifiers) + { + if (string.Equals(modifier.ValueText, PartialKeyword, StringComparison.Ordinal)) + { + hasPartialModifier = true; + break; + } + } + + if (!hasPartialModifier || declaration.AccessorList is null || + declaration.AccessorList.Accessors.Count == 0) + { + return false; + } + + if (declaration.ExplicitInterfaceSpecifier is not null) + { + return false; + } + + foreach (AccessorDeclarationSyntax accessor in declaration.AccessorList.Accessors) + { + if (accessor.Kind() is not SyntaxKind.GetAccessorDeclaration and not SyntaxKind.SetAccessorDeclaration + || accessor.Body is not null || accessor.ExpressionBody is not null + || !HasSupportedAccessorModifiers(accessor)) + { + return false; + } + } + } + + return hasDeclaration; + } + + private static bool HasSupportedAccessorModifiers(AccessorDeclarationSyntax accessor) + { + foreach (SyntaxToken modifier in accessor.Modifiers) + { + if (modifier.Kind() is not (SyntaxKind.PublicKeyword or SyntaxKind.PrivateKeyword + or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword)) + { + return false; + } + } + + return true; + } + + private static bool IsScalar(ITypeSymbol type, bool allowReadOnlySpan, INamedTypeSymbol? readOnlySpan) + { + if (type.SpecialType is SpecialType.System_Int32 or SpecialType.System_Int64 or SpecialType.System_Single + or SpecialType.System_Double or SpecialType.System_Boolean or SpecialType.System_UIntPtr + or SpecialType.System_String) + { + return true; + } + + if (!allowReadOnlySpan || readOnlySpan is null || type is not INamedTypeSymbol { IsGenericType: true } named + || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) + || named.TypeArguments.Length != 1) + { + return false; + } + + return named.TypeArguments[0].SpecialType == SpecialType.System_Byte; + } + + private static bool IsGeneratedLocalName(string name) + { + return name is "__ceState" or "__ceOperation" or "__ceTop" or "__ceStatus" or "__ceResult"; + } + + private static bool IsLuaGlobalGeneratedLocalName(string name) + { + return name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result"; + } + + private static AttributeData? FindAttribute(ISymbol symbol, INamedTypeSymbol attributeClass) + { + foreach (AttributeData attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) + { + return attribute; + } + } + + return null; + } + + private static string? ReadName(AttributeData attribute) + { + return attribute.ConstructorArguments is [{ Kind: TypedConstantKind.Primitive, Value: string name }] + ? name + : null; + } + + private static void ReportInvalid(SymbolAnalysisContext context, ISymbol symbol, string problem) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidLuaAnnotationTarget, + FirstLocation(symbol), symbol.Name, problem)); + } + + private static Location FirstLocation(ISymbol symbol) + { + return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; + } + + private sealed class LuaObjectContractSymbols( + INamedTypeSymbol? luaClassAttribute, + INamedTypeSymbol? luaMethodAttribute, + INamedTypeSymbol? luaPropertyAttribute, + INamedTypeSymbol? luaFunctionAttribute, + INamedTypeSymbol? luaGlobalAttribute, + INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, + INamedTypeSymbol? luaState, + INamedTypeSymbol? readOnlySpan, + INamedTypeSymbol? ceObject) + { + public INamedTypeSymbol? LuaClassAttribute + { + get; + } = luaClassAttribute; + + public INamedTypeSymbol? LuaMethodAttribute + { + get; + } = luaMethodAttribute; + + public INamedTypeSymbol? LuaPropertyAttribute + { + get; + } = luaPropertyAttribute; + + public INamedTypeSymbol? LuaFunctionAttribute + { + get; + } = luaFunctionAttribute; + + public INamedTypeSymbol? LuaGlobalAttribute + { + get; + } = luaGlobalAttribute; + + public INamedTypeSymbol? LuaMarshallerAttribute + { + get; + } = luaMarshallerAttribute; + + public INamedTypeSymbol? LuaMarshallerContract + { + get; + } = luaMarshallerContract; + + public INamedTypeSymbol? LuaState + { + get; + } = luaState; + + public INamedTypeSymbol? ReadOnlySpan + { + get; + } = readOnlySpan; + + public INamedTypeSymbol? CEObject + { + get; + } = ceObject; + + public bool HasAnyLuaObjectAnnotation => LuaClassAttribute is not null || LuaMethodAttribute is not null + || LuaPropertyAttribute is not null || + LuaFunctionAttribute is not null + || LuaGlobalAttribute is not null; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs index b9a8b5c6..f7e998f0 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Immutable; using System.Threading; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.WellKnown; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -38,211 +40,250 @@ namespace CheatEngine.SDK.Analyzers.Plugin; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class CheatEnginePluginAnalyzer : DiagnosticAnalyzer { - // The namespace of the type CESDK.CESDK that Cheat Engine looks up in every plugin assembly. The SDK's own namespaces - // (CheatEngine.SDK.*) are not reserved: only a root segment of exactly this name is. - private const string ReservedRootNamespace = "CESDK"; - - // The MSBuild switch of the entry point generator, as the compiler sees it. - private const string GenerateEntryPointKey = "build_property.CheatEngineSdkGenerateEntryPoint"; - - /// - public override ImmutableArray SupportedDiagnostics { get; } = - [ - DiagnosticDescriptors.InvalidPluginClass, - DiagnosticDescriptors.MultiplePluginClasses, - DiagnosticDescriptors.InvalidManualBootstrap, - DiagnosticDescriptors.ReservedNamespace, - DiagnosticDescriptors.GeneratedEntryPointCollision, - ]; - - /// - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(OnCompilationStart); - } - - private static void OnCompilationStart(CompilationStartAnalysisContext context) - { - var pluginAttribute = - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.CheatEnginePluginAttribute); - var pluginBase = SdkSymbolResolver.Hosting(context.Compilation, WellKnownTypeNames.CheatEnginePluginBase); - if (pluginAttribute is null || pluginBase is null) return; - - // CESDK0001, CESDK0002, CESDK0004 and CESDK0005 describe what the GENERATED entry point needs. The direct - // package build asset makes the property compiler-visible and supplies true by default. Without that explicit - // contract (for example through an indirect package reference), this analyzer must stay out of the way rather - // than inventing either a generated or manual bootstrap obligation. An explicit false transfers ownership of - // CESDK.CESDK to the author, which CESDK0003 validates at compilation end. - bool? entryPointIsGenerated = null; - if (context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue(GenerateEntryPointKey, out var raw) - && bool.TryParse(raw, out var generate)) - entryPointIsGenerated = generate; - - // The last two are optional: without them the matching CESDK0001 checks are stricter or skipped, never wrong. - PluginContractSymbols symbols = new( - pluginAttribute, - pluginBase, - context.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SetsRequiredMembersAttribute), - context.Compilation.GetTypeByMetadataName(WellKnownTypeNames.ObsoleteAttribute)); - - PluginCompilationState state = new(entryPointIsGenerated); - context.RegisterSymbolAction( - symbolContext => AnalyzeNamedType(symbolContext, symbols, state, entryPointIsGenerated), - SymbolKind.NamedType); - context.RegisterSyntaxNodeAction( - nodeContext => AnalyzeNamespaceDeclaration(nodeContext, state), - SyntaxKind.NamespaceDeclaration, - SyntaxKind.FileScopedNamespaceDeclaration); - context.RegisterCompilationEndAction(state.Report); - } - - private static void AnalyzeNamedType( - SymbolAnalysisContext context, - PluginContractSymbols symbols, - PluginCompilationState state, - bool? entryPointIsGenerated) - { - var type = (INamedTypeSymbol)context.Symbol; - - if (IsEntryPointType(type)) - state.AddEntryPointType( - type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), - FirstLocation(type), - IsManualBootstrap(type)); - - // The attribute targets classes only: on anything else the compiler already reports CS0592. - if (type.TypeKind != TypeKind.Class || - FindAttribute(type, symbols.PluginAttribute) is not { } attribute) return; - - var name = type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); - var attributeSyntax = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken); - var classLocation = GetClassLocation(type, attributeSyntax); - state.AddPluginClass(name, classLocation); - if (entryPointIsGenerated is not true) return; - - var problems = PluginShape.Inspect( - type, - attribute, - symbols.PluginBase, - symbols.SetsRequiredMembersAttribute, - symbols.ObsoleteAttribute, - out _, - out _); - if (problems == PluginShapeIssues.None) return; - - foreach (var problem in PluginClassProblemText.ReportOrder) - { - if ((problems & problem) == PluginShapeIssues.None) continue; - - // The name is a property of the attribute application, everything else of the class declaration. - var location = problem == PluginShapeIssues.InvalidName && attributeSyntax is not null - ? attributeSyntax.GetLocation() - : classLocation; - - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidPluginClass, - location, - ImmutableDictionary.Empty.Add(DiagnosticProperties.PluginClassProblem, - problem.ToString()), - name, - PluginClassProblemText.Describe(problem))); - } - } - - private static AttributeData? FindAttribute(INamedTypeSymbol type, INamedTypeSymbol attributeClass) - { - foreach (var attribute in type.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) - return attribute; - - return null; - } - - private static bool IsEntryPointType(INamedTypeSymbol type) - { - return type is - { - Name: ReservedRootNamespace, - Arity: 0, - ContainingType: null, - ContainingNamespace: - { - Name: ReservedRootNamespace, - ContainingNamespace.IsGlobalNamespace: true, - }, - }; - } - - private static bool IsManualBootstrap(INamedTypeSymbol type) - { - if (!type.IsStatic) return false; - - foreach (var member in type.GetMembers("CEPluginInitialize")) - { - if (member is not IMethodSymbol - { - MethodKind: MethodKind.Ordinary, - IsStatic: true, - IsGenericMethod: false, - DeclaredAccessibility: Accessibility.Public, - ReturnsByRef: false, - ReturnsByRefReadonly: false, - ReturnType.SpecialType: SpecialType.System_Int32, - Parameters: - [ - { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_IntPtr }, - { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_Int32 } - ], - }) - continue; - - return true; - } - - return false; - } - - private static Location FirstLocation(ISymbol symbol) - { - return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; - } - - // A partial class has one location per part: the part that carries the attribute is the one the user thinks of - // as "the plugin class", and the only one that certainly is not generated code. - private static Location GetClassLocation(INamedTypeSymbol type, SyntaxNode? attributeSyntax) - { - for (var node = attributeSyntax; node is not null; node = node.Parent) - if (node is BaseTypeDeclarationSyntax declaration) - return declaration.Identifier.GetLocation(); - - return type.Locations.IsEmpty ? Location.None : type.Locations[0]; - } - - private static void AnalyzeNamespaceDeclaration(SyntaxNodeAnalysisContext context, PluginCompilationState state) - { - var declaration = (BaseNamespaceDeclarationSyntax)context.Node; - - // A nested declaration is under 'CESDK' exactly when its outermost declaration is: one report per outermost one. - if (declaration.Parent is not CompilationUnitSyntax - || context.SemanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not INamespaceSymbol - declared - || !IsUnderReservedRoot(declared, context.CancellationToken)) - return; - - state.AddReservedNamespace(declared.ToDisplayString(), declaration.Name.GetLocation()); - } - - private static bool IsUnderReservedRoot(INamespaceSymbol declared, CancellationToken cancellationToken) - { - var root = declared; - while (root.ContainingNamespace is { IsGlobalNamespace: false } parent) - { - cancellationToken.ThrowIfCancellationRequested(); - root = parent; - } - - return string.Equals(root.Name, ReservedRootNamespace, StringComparison.Ordinal); - } + // The namespace of the type CESDK.CESDK that Cheat Engine looks up in every plugin assembly. The SDK's own namespaces + // (CheatEngine.SDK.*) are not reserved: only a root segment of exactly this name is. + private const string ReservedRootNamespace = "CESDK"; + + // The MSBuild switch of the entry point generator, as the compiler sees it. + private const string GenerateEntryPointKey = "build_property.CheatEngineSdkGenerateEntryPoint"; + + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = + [ + DiagnosticDescriptors.InvalidPluginClass, + DiagnosticDescriptors.MultiplePluginClasses, + DiagnosticDescriptors.InvalidManualBootstrap, + DiagnosticDescriptors.ReservedNamespace, + DiagnosticDescriptors.GeneratedEntryPointCollision + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + INamedTypeSymbol? pluginAttribute = + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.CheatEnginePluginAttribute); + INamedTypeSymbol? pluginBase = + SdkSymbolResolver.Hosting(context.Compilation, WellKnownTypeNames.CheatEnginePluginBase); + if (pluginAttribute is null || pluginBase is null) + { + return; + } + + // CESDK0001, CESDK0002, CESDK0004 and CESDK0005 describe what the GENERATED entry point needs. The direct + // package build asset makes the property compiler-visible and supplies true by default. Without that explicit + // contract (for example through an indirect package reference), this analyzer must stay out of the way rather + // than inventing either a generated or manual bootstrap obligation. An explicit false transfers ownership of + // CESDK.CESDK to the author, which CESDK0003 validates at compilation end. + bool? entryPointIsGenerated = null; + if (context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue(GenerateEntryPointKey, + out string? raw) + && bool.TryParse(raw, out bool generate)) + { + entryPointIsGenerated = generate; + } + + // The last two are optional: without them the matching CESDK0001 checks are stricter or skipped, never wrong. + PluginContractSymbols symbols = new( + pluginAttribute, + pluginBase, + context.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SetsRequiredMembersAttribute), + context.Compilation.GetTypeByMetadataName(WellKnownTypeNames.ObsoleteAttribute)); + + PluginCompilationState state = new(entryPointIsGenerated); + context.RegisterSymbolAction( + symbolContext => AnalyzeNamedType(symbolContext, symbols, state, entryPointIsGenerated), + SymbolKind.NamedType); + context.RegisterSyntaxNodeAction( + nodeContext => AnalyzeNamespaceDeclaration(nodeContext, state), + SyntaxKind.NamespaceDeclaration, + SyntaxKind.FileScopedNamespaceDeclaration); + context.RegisterCompilationEndAction(state.Report); + } + + private static void AnalyzeNamedType( + SymbolAnalysisContext context, + PluginContractSymbols symbols, + PluginCompilationState state, + bool? entryPointIsGenerated) + { + INamedTypeSymbol type = (INamedTypeSymbol) context.Symbol; + + if (IsEntryPointType(type)) + { + state.AddEntryPointType( + type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), + FirstLocation(type), + IsManualBootstrap(type)); + } + + // The attribute targets classes only: on anything else the compiler already reports CS0592. + if (type.TypeKind != TypeKind.Class || + FindAttribute(type, symbols.PluginAttribute) is not { } attribute) + { + return; + } + + string name = type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); + SyntaxNode? attributeSyntax = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken); + Location classLocation = GetClassLocation(type, attributeSyntax); + state.AddPluginClass(name, classLocation); + if (entryPointIsGenerated is not true) + { + return; + } + + PluginShapeIssues problems = PluginShape.Inspect( + type, + attribute, + symbols.PluginBase, + symbols.SetsRequiredMembersAttribute, + symbols.ObsoleteAttribute, + out _, + out _); + if (problems == PluginShapeIssues.None) + { + return; + } + + foreach (PluginShapeIssues problem in PluginClassProblemText.ReportOrder) + { + if ((problems & problem) == PluginShapeIssues.None) + { + continue; + } + + // The name is a property of the attribute application, everything else of the class declaration. + Location location = problem == PluginShapeIssues.InvalidName && attributeSyntax is not null + ? attributeSyntax.GetLocation() + : classLocation; + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidPluginClass, + location, + ImmutableDictionary.Empty.Add(DiagnosticProperties.PluginClassProblem, + problem.ToString()), + name, + PluginClassProblemText.Describe(problem))); + } + } + + private static AttributeData? FindAttribute(INamedTypeSymbol type, INamedTypeSymbol attributeClass) + { + foreach (AttributeData attribute in type.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) + { + return attribute; + } + } + + return null; + } + + private static bool IsEntryPointType(INamedTypeSymbol type) + { + return type is + { + Name: ReservedRootNamespace, + Arity: 0, + ContainingType: null, + ContainingNamespace: + { + Name: ReservedRootNamespace, + ContainingNamespace.IsGlobalNamespace: true + } + }; + } + + private static bool IsManualBootstrap(INamedTypeSymbol type) + { + if (!type.IsStatic) + { + return false; + } + + foreach (ISymbol member in type.GetMembers("CEPluginInitialize")) + { + if (member is not IMethodSymbol + { + MethodKind: MethodKind.Ordinary, + IsStatic: true, + IsGenericMethod: false, + DeclaredAccessibility: Accessibility.Public, + ReturnsByRef: false, + ReturnsByRefReadonly: false, + ReturnType.SpecialType: SpecialType.System_Int32, + Parameters: + [ + { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_IntPtr }, + { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_Int32 } + ] + }) + { + continue; + } + + return true; + } + + return false; + } + + private static Location FirstLocation(ISymbol symbol) + { + return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; + } + + // A partial class has one location per part: the part that carries the attribute is the one the user thinks of + // as "the plugin class", and the only one that certainly is not generated code. + private static Location GetClassLocation(INamedTypeSymbol type, SyntaxNode? attributeSyntax) + { + for (SyntaxNode? node = attributeSyntax; node is not null; node = node.Parent) + { + if (node is BaseTypeDeclarationSyntax declaration) + { + return declaration.Identifier.GetLocation(); + } + } + + return type.Locations.IsEmpty ? Location.None : type.Locations[0]; + } + + private static void AnalyzeNamespaceDeclaration(SyntaxNodeAnalysisContext context, PluginCompilationState state) + { + BaseNamespaceDeclarationSyntax declaration = (BaseNamespaceDeclarationSyntax) context.Node; + + // A nested declaration is under 'CESDK' exactly when its outermost declaration is: one report per outermost one. + if (declaration.Parent is not CompilationUnitSyntax + || context.SemanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not INamespaceSymbol + declared + || !IsUnderReservedRoot(declared, context.CancellationToken)) + { + return; + } + + state.AddReservedNamespace(declared.ToDisplayString(), declaration.Name.GetLocation()); + } + + private static bool IsUnderReservedRoot(INamespaceSymbol declared, CancellationToken cancellationToken) + { + INamespaceSymbol root = declared; + while (root.ContainingNamespace is { IsGlobalNamespace: false } parent) + { + cancellationToken.ThrowIfCancellationRequested(); + root = parent; + } + + return string.Equals(root.Name, ReservedRootNamespace, StringComparison.Ordinal); + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginClassProblemText.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginClassProblemText.cs index 67c587a5..13291f82 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginClassProblemText.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginClassProblemText.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared.Shapes; namespace CheatEngine.SDK.Analyzers.Plugin; @@ -11,54 +12,54 @@ namespace CheatEngine.SDK.Analyzers.Plugin; /// internal static class PluginClassProblemText { - /// The flags in the order they are reported for one class. - public static readonly ImmutableArray ReportOrder = - [ - PluginShapeIssues.Static, - PluginShapeIssues.Abstract, - PluginShapeIssues.Generic, - PluginShapeIssues.NestedInGeneric, - PluginShapeIssues.NotDerivedFromPluginBase, - PluginShapeIssues.Inaccessible, - PluginShapeIssues.FileLocal, - PluginShapeIssues.ReservedEntryPointName, - PluginShapeIssues.MissingParameterlessConstructor, - PluginShapeIssues.InaccessibleParameterlessConstructor, - PluginShapeIssues.RequiredMembers, - PluginShapeIssues.ObsoleteError, - PluginShapeIssues.InvalidName - ]; + /// The flags in the order they are reported for one class. + public static readonly ImmutableArray ReportOrder = + [ + PluginShapeIssues.Static, + PluginShapeIssues.Abstract, + PluginShapeIssues.Generic, + PluginShapeIssues.NestedInGeneric, + PluginShapeIssues.NotDerivedFromPluginBase, + PluginShapeIssues.Inaccessible, + PluginShapeIssues.FileLocal, + PluginShapeIssues.ReservedEntryPointName, + PluginShapeIssues.MissingParameterlessConstructor, + PluginShapeIssues.InaccessibleParameterlessConstructor, + PluginShapeIssues.RequiredMembers, + PluginShapeIssues.ObsoleteError, + PluginShapeIssues.InvalidName + ]; - /// Returns the message fragment of a single flag. - public static string Describe(PluginShapeIssues problem) - { - return problem switch - { - PluginShapeIssues.Static => "must not be static: the generated entry point creates an instance of it", - PluginShapeIssues.Abstract => "must not be abstract: the generated entry point creates it with 'new'", - PluginShapeIssues.Generic => - "must not be generic: the generated entry point has no type arguments to give it", - PluginShapeIssues.NestedInGeneric => - "must not be nested in a generic type: the generated entry point has no type arguments to give it", - PluginShapeIssues.NotDerivedFromPluginBase => - "must derive from 'CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin'", - PluginShapeIssues.Inaccessible => - "must be reachable from generated code in the same assembly: it and every type it is nested in must be public or internal", - PluginShapeIssues.FileLocal => - "must not be a file-local type: the generated entry point lives in another file", - PluginShapeIssues.ReservedEntryPointName => - "must not be, or be nested in, a type named 'CESDK.CESDK': Cheat Engine dictates that name for the generated entry point type", - PluginShapeIssues.MissingParameterlessConstructor => - "must declare a public or internal constructor with no parameters: the generated entry point calls it as 'new T()'", - PluginShapeIssues.InaccessibleParameterlessConstructor => - "must make a parameterless constructor public or internal: the generated entry point calls it as 'new T()'", - PluginShapeIssues.RequiredMembers => - "must not have required members, unless its parameterless constructor is marked [SetsRequiredMembers]: the generated entry point calls 'new' without an object initializer", - PluginShapeIssues.ObsoleteError => - "must be usable without an [Obsolete] error: neither the class, a type it is nested in, nor its parameterless constructor may be marked [Obsolete] with 'error: true', because the generated entry point names them", - PluginShapeIssues.InvalidName => - "must be given a non-empty display name in [CheatEnginePlugin]: it is the name Cheat Engine shows in its plugin list", - _ => "cannot be constructed by the generated entry point" - }; - } + /// Returns the message fragment of a single flag. + public static string Describe(PluginShapeIssues problem) + { + return problem switch + { + PluginShapeIssues.Static => "must not be static: the generated entry point creates an instance of it", + PluginShapeIssues.Abstract => "must not be abstract: the generated entry point creates it with 'new'", + PluginShapeIssues.Generic => + "must not be generic: the generated entry point has no type arguments to give it", + PluginShapeIssues.NestedInGeneric => + "must not be nested in a generic type: the generated entry point has no type arguments to give it", + PluginShapeIssues.NotDerivedFromPluginBase => + "must derive from 'CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin'", + PluginShapeIssues.Inaccessible => + "must be reachable from generated code in the same assembly: it and every type it is nested in must be public or internal", + PluginShapeIssues.FileLocal => + "must not be a file-local type: the generated entry point lives in another file", + PluginShapeIssues.ReservedEntryPointName => + "must not be, or be nested in, a type named 'CESDK.CESDK': Cheat Engine dictates that name for the generated entry point type", + PluginShapeIssues.MissingParameterlessConstructor => + "must declare a public or internal constructor with no parameters: the generated entry point calls it as 'new T()'", + PluginShapeIssues.InaccessibleParameterlessConstructor => + "must make a parameterless constructor public or internal: the generated entry point calls it as 'new T()'", + PluginShapeIssues.RequiredMembers => + "must not have required members, unless its parameterless constructor is marked [SetsRequiredMembers]: the generated entry point calls 'new' without an object initializer", + PluginShapeIssues.ObsoleteError => + "must be usable without an [Obsolete] error: neither the class, a type it is nested in, nor its parameterless constructor may be marked [Obsolete] with 'error: true', because the generated entry point names them", + PluginShapeIssues.InvalidName => + "must be given a non-empty display name in [CheatEnginePlugin]: it is the name Cheat Engine shows in its plugin list", + _ => "cannot be constructed by the generated entry point" + }; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs index 77e27e46..146e92da 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; + using CheatEngine.SDK.Analyzers.Diagnostics; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -17,88 +19,106 @@ namespace CheatEngine.SDK.Analyzers.Plugin; /// internal sealed class PluginCompilationState { - private readonly bool? _entryPointIsGenerated; - private readonly ConcurrentQueue<(string Name, Location Location, bool IsManualBootstrap)> _entryPointTypes = new(); - private readonly ConcurrentQueue<(string Name, Location Location)> _pluginClasses = new(); - private readonly ConcurrentQueue<(string Name, Location Location)> _reservedNamespaces = new(); - - /// - /// when a direct package reference made generated bootstrap mode explicit; - /// for explicit manual-bootstrap mode; when no direct build - /// contract reached this compilation. - /// - public PluginCompilationState(bool? entryPointIsGenerated) - { - _entryPointIsGenerated = entryPointIsGenerated; - } - - /// Records a class that carries the plugin attribute, valid or not. - public void AddPluginClass(string name, Location location) - { - _pluginClasses.Enqueue((name, location)); - } - - /// Records a top-level namespace declaration that is CESDK or starts with CESDK.. - public void AddReservedNamespace(string name, Location location) - { - _reservedNamespaces.Enqueue((name, location)); - } - - /// Records source code that declares the exact type identity the host reserves for its managed bootstrap. - public void AddEntryPointType(string name, Location location, bool isManualBootstrap) - { - _entryPointTypes.Enqueue((name, location, isManualBootstrap)); - } - - /// The compilation-end action: reports CESDK0002 through CESDK0005. - public void Report(CompilationAnalysisContext context) - { - var pluginClassCount = _pluginClasses.Count; - if (pluginClassCount == 0) - // Not a plugin assembly (the SDK's own libraries, a helper library): both rules are about plugins. - return; - - if (pluginClassCount > 1 && _entryPointIsGenerated is true) - foreach (var (name, location) in _pluginClasses) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.MultiplePluginClasses, location, name, - pluginClassCount)); - - if (_entryPointIsGenerated is true) - { - foreach (var (name, location) in _reservedNamespaces) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.ReservedNamespace, location, name)); - - foreach (var (name, location, _) in _entryPointTypes) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.GeneratedEntryPointCollision, location, - name)); - - return; - } - - if (_entryPointIsGenerated is not false) return; - - var hasManualBootstrap = false; - foreach (var (_, _, isManualBootstrap) in _entryPointTypes) - if (isManualBootstrap) - { - hasManualBootstrap = true; - break; - } - - if (hasManualBootstrap) return; - - var locationForManualBootstrap = Location.None; - foreach (var (_, location, _) in _entryPointTypes) - { - locationForManualBootstrap = location; - break; - } - - var requirement = locationForManualBootstrap == Location.None - ? "the assembly declares no static CESDK.CESDK type with public static int CEPluginInitialize(System.IntPtr, int)" - : "CESDK.CESDK has no public static int CEPluginInitialize(System.IntPtr, int) method"; - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidManualBootstrap, - locationForManualBootstrap, - requirement)); - } + private readonly bool? _entryPointIsGenerated; + private readonly ConcurrentQueue<(string Name, Location Location, bool IsManualBootstrap)> _entryPointTypes = new(); + private readonly ConcurrentQueue<(string Name, Location Location)> _pluginClasses = new(); + private readonly ConcurrentQueue<(string Name, Location Location)> _reservedNamespaces = new(); + + /// + /// when a direct package reference made generated bootstrap mode explicit; + /// for explicit manual-bootstrap mode; when no direct build + /// contract reached this compilation. + /// + public PluginCompilationState(bool? entryPointIsGenerated) + { + _entryPointIsGenerated = entryPointIsGenerated; + } + + /// Records a class that carries the plugin attribute, valid or not. + public void AddPluginClass(string name, Location location) + { + _pluginClasses.Enqueue((name, location)); + } + + /// Records a top-level namespace declaration that is CESDK or starts with CESDK.. + public void AddReservedNamespace(string name, Location location) + { + _reservedNamespaces.Enqueue((name, location)); + } + + /// Records source code that declares the exact type identity the host reserves for its managed bootstrap. + public void AddEntryPointType(string name, Location location, bool isManualBootstrap) + { + _entryPointTypes.Enqueue((name, location, isManualBootstrap)); + } + + /// The compilation-end action: reports CESDK0002 through CESDK0005. + public void Report(CompilationAnalysisContext context) + { + int pluginClassCount = _pluginClasses.Count; + if (pluginClassCount == 0) + // Not a plugin assembly (the SDK's own libraries, a helper library): both rules are about plugins. + { + return; + } + + if (_entryPointIsGenerated is true) + { + ReportGeneratedEntryPointDiagnostics(context, pluginClassCount); + return; + } + + if (_entryPointIsGenerated is false) + { + ReportMissingManualBootstrap(context); + } + } + + private void ReportGeneratedEntryPointDiagnostics(CompilationAnalysisContext context, int pluginClassCount) + { + if (pluginClassCount > 1) + { + foreach ((string name, Location location) in _pluginClasses) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.MultiplePluginClasses, location, name, + pluginClassCount)); + } + } + + foreach ((string name, Location location) in _reservedNamespaces) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.ReservedNamespace, location, name)); + } + + foreach ((string name, Location location, bool _) in _entryPointTypes) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.GeneratedEntryPointCollision, location, + name)); + } + } + + private void ReportMissingManualBootstrap(CompilationAnalysisContext context) + { + foreach ((string _, Location _, bool isManualBootstrap) in _entryPointTypes) + { + if (isManualBootstrap) + { + return; + } + } + + Location locationForManualBootstrap = Location.None; + foreach ((string _, Location location, bool _) in _entryPointTypes) + { + locationForManualBootstrap = location; + break; + } + + string requirement = locationForManualBootstrap == Location.None + ? "the assembly declares no static CESDK.CESDK type with public static int CEPluginInitialize(System.IntPtr, int)" + : "CESDK.CESDK has no public static int CEPluginInitialize(System.IntPtr, int) method"; + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidManualBootstrap, + locationForManualBootstrap, + requirement)); + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginContractSymbols.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginContractSymbols.cs index 59cd2280..aec9a21d 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginContractSymbols.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginContractSymbols.cs @@ -21,20 +21,32 @@ namespace CheatEngine.SDK.Analyzers.Plugin; /// same namespace and name from another assembly does not satisfy the entry-point contract. /// internal sealed class PluginContractSymbols( - INamedTypeSymbol pluginAttribute, - INamedTypeSymbol pluginBase, - INamedTypeSymbol? setsRequiredMembersAttribute, - INamedTypeSymbol? obsoleteAttribute) + INamedTypeSymbol pluginAttribute, + INamedTypeSymbol pluginBase, + INamedTypeSymbol? setsRequiredMembersAttribute, + INamedTypeSymbol? obsoleteAttribute) { - /// The marker attribute of a plugin class. - public INamedTypeSymbol PluginAttribute { get; } = pluginAttribute; + /// The marker attribute of a plugin class. + public INamedTypeSymbol PluginAttribute + { + get; + } = pluginAttribute; - /// The actual SDK plugin base. Source lookalikes from another assembly never satisfy the entry-point contract. - public INamedTypeSymbol PluginBase { get; } = pluginBase; + /// The actual SDK plugin base. Source lookalikes from another assembly never satisfy the entry-point contract. + public INamedTypeSymbol PluginBase + { + get; + } = pluginBase; - /// On a constructor: new T() needs no object initializer although T has required members. - public INamedTypeSymbol? SetsRequiredMembersAttribute { get; } = setsRequiredMembersAttribute; + /// On a constructor: new T() needs no object initializer although T has required members. + public INamedTypeSymbol? SetsRequiredMembersAttribute + { + get; + } = setsRequiredMembersAttribute; - /// With error: true: naming the marked symbol is CS0619. - public INamedTypeSymbol? ObsoleteAttribute { get; } = obsoleteAttribute; + /// With error: true: naming the marked symbol is CS0619. + public INamedTypeSymbol? ObsoleteAttribute + { + get; + } = obsoleteAttribute; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs index cf23ca0a..4837f102 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs @@ -62,203 +62,235 @@ namespace CheatEngine.SDK.Analyzers.Usage; /// /// The resolved System.Environment, or . internal sealed class ExceptionGuard( - INamedTypeSymbol exceptionType, - INamedTypeSymbol? doesNotReturnAttribute, - INamedTypeSymbol? environmentType) + INamedTypeSymbol exceptionType, + INamedTypeSymbol? doesNotReturnAttribute, + INamedTypeSymbol? environmentType) { - /// Whether every statement of is safe in the sense of the type remarks. - /// A block body, or the implicit block of an expression body. - public bool IsGuarded(IBlockOperation body) - { - foreach (var statement in body.Operations) - if (!IsSafeStatement(statement)) - return false; + /// Whether every statement of is safe in the sense of the type remarks. + /// A block body, or the implicit block of an expression body. + public bool IsGuarded(IBlockOperation body) + { + foreach (IOperation statement in body.Operations) + { + if (!IsSafeStatement(statement)) + { + return false; + } + } - return true; - } + return true; + } - private bool IsSafeStatement(IOperation statement) - { - return statement switch - { - ITryOperation tryOperation => - IsGuardTry(tryOperation, exceptionType, doesNotReturnAttribute, environmentType), - IVariableDeclarationGroupOperation declarations => AreTrivialDeclarations(declarations), - IReturnOperation { Kind: OperationKind.Return } returnOperation => - returnOperation.ReturnedValue is null || IsTriviallyNonThrowing(returnOperation.ReturnedValue), - ILocalFunctionOperation => true, - IEmptyOperation => true, - IBlockOperation block => IsGuarded(block), - _ => false - }; - } + private bool IsSafeStatement(IOperation statement) + { + return statement switch + { + ITryOperation tryOperation => + IsGuardTry(tryOperation, exceptionType, doesNotReturnAttribute, environmentType), + IVariableDeclarationGroupOperation declarations => AreTrivialDeclarations(declarations), + IReturnOperation { Kind: OperationKind.Return } returnOperation => + returnOperation.ReturnedValue is null || IsTriviallyNonThrowing(returnOperation.ReturnedValue), + ILocalFunctionOperation => true, + IEmptyOperation => true, + IBlockOperation block => IsGuarded(block), + _ => false + }; + } - private static bool IsGuardTry(ITryOperation tryOperation, INamedTypeSymbol exceptionType, - INamedTypeSymbol? doesNotReturnAttribute, INamedTypeSymbol? environmentType) - { - var hasCatchAll = false; - foreach (var catchClause in tryOperation.Catches) - { - // A rethrow in ANY clause leaves the try statement: sibling clauses do not catch it. - if (ContainsThrow(catchClause.Handler, doesNotReturnAttribute, environmentType)) return false; + private static bool IsGuardTry(ITryOperation tryOperation, INamedTypeSymbol exceptionType, + INamedTypeSymbol? doesNotReturnAttribute, INamedTypeSymbol? environmentType) + { + bool hasCatchAll = false; + foreach (ICatchClauseOperation catchClause in tryOperation.Catches) + { + // A rethrow in ANY clause leaves the try statement: sibling clauses do not catch it. + if (ContainsThrow(catchClause.Handler, doesNotReturnAttribute, environmentType)) + { + return false; + } - hasCatchAll |= IsCatchAll(catchClause, exceptionType); - } + hasCatchAll |= IsCatchAll(catchClause, exceptionType); + } - return hasCatchAll - && (tryOperation.Finally is null - || !ContainsThrow(tryOperation.Finally, doesNotReturnAttribute, environmentType)); - } + return hasCatchAll + && (tryOperation.Finally is null + || !ContainsThrow(tryOperation.Finally, doesNotReturnAttribute, environmentType)); + } - // 'catch { }' has the exception type System.Object; 'catch (Exception)' names the root of the hierarchy. - private static bool IsCatchAll(ICatchClauseOperation catchClause, INamedTypeSymbol exceptionType) - { - return catchClause.Filter is null - && (catchClause.ExceptionType.SpecialType == SpecialType.System_Object - || SymbolEqualityComparer.Default.Equals(catchClause.ExceptionType, exceptionType)); - } + // 'catch { }' has the exception type System.Object; 'catch (Exception)' names the root of the hierarchy. + private static bool IsCatchAll(ICatchClauseOperation catchClause, INamedTypeSymbol exceptionType) + { + return catchClause.Filter is null + && (catchClause.ExceptionType.SpecialType == SpecialType.System_Object + || SymbolEqualityComparer.Default.Equals(catchClause.ExceptionType, exceptionType)); + } - private static bool ContainsThrow(IOperation block, INamedTypeSymbol? doesNotReturnAttribute, - INamedTypeSymbol? environmentType) - { - foreach (var descendant in block.Descendants()) - if (descendant.Kind == OperationKind.Throw - || (descendant is IInvocationOperation invocation - && NeverReturnsByThrowing(invocation.TargetMethod, doesNotReturnAttribute, environmentType))) - return true; + private static bool ContainsThrow(IOperation block, INamedTypeSymbol? doesNotReturnAttribute, + INamedTypeSymbol? environmentType) + { + foreach (IOperation descendant in block.Descendants()) + { + if (descendant.Kind == OperationKind.Throw + || (descendant is IInvocationOperation invocation + && NeverReturnsByThrowing(invocation.TargetMethod, doesNotReturnAttribute, environmentType))) + { + return true; + } + } - return false; - } + return false; + } - // [DoesNotReturn] is how a method says "I always throw": ExceptionDispatchInfo.Throw (the rethrow idiom that - // keeps the stack trace) and every throw helper carry it. Environment.FailFast and Environment.Exit carry it - // too, but they end the process in a controlled way; nothing unwinds into native code. - private static bool NeverReturnsByThrowing(IMethodSymbol method, INamedTypeSymbol? doesNotReturnAttribute, - INamedTypeSymbol? environmentType) - { - if (doesNotReturnAttribute is null - || SymbolEqualityComparer.Default.Equals(method.ContainingType, environmentType)) - return false; + // [DoesNotReturn] is how a method says "I always throw": ExceptionDispatchInfo.Throw (the rethrow idiom that + // keeps the stack trace) and every throw helper carry it. Environment.FailFast and Environment.Exit carry it + // too, but they end the process in a controlled way; nothing unwinds into native code. + private static bool NeverReturnsByThrowing(IMethodSymbol method, INamedTypeSymbol? doesNotReturnAttribute, + INamedTypeSymbol? environmentType) + { + if (doesNotReturnAttribute is null + || SymbolEqualityComparer.Default.Equals(method.ContainingType, environmentType)) + { + return false; + } - foreach (var attribute in method.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, doesNotReturnAttribute)) - return true; + foreach (AttributeData attribute in method.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, doesNotReturnAttribute)) + { + return true; + } + } - return false; - } + return false; + } - private static bool AreTrivialDeclarations(IVariableDeclarationGroupOperation group) - { - foreach (var declaration in group.Declarations) - foreach (var declarator in declaration.Declarators) - if (declarator.Initializer is { } initializer && !IsTriviallyNonThrowing(initializer.Value)) - return false; + private static bool AreTrivialDeclarations(IVariableDeclarationGroupOperation group) + { + foreach (IVariableDeclarationOperation declaration in group.Declarations) + foreach (IVariableDeclaratorOperation declarator in declaration.Declarators) + { + if (declarator.Initializer is { } initializer && !IsTriviallyNonThrowing(initializer.Value)) + { + return false; + } + } - return true; - } + return true; + } - private static bool IsTriviallyNonThrowing(IOperation value) - { - if (value.ConstantValue.HasValue) return true; + private static bool IsTriviallyNonThrowing(IOperation value) + { + if (value.ConstantValue.HasValue) + { + return true; + } - return value switch - { - IDefaultValueOperation or ILocalReferenceOperation or IParameterReferenceOperation => true, + return value switch + { + IDefaultValueOperation or ILocalReferenceOperation or IParameterReferenceOperation => true, - // Reading a static field can run a type initializer, which can throw. The core-library primitives are - // the exception: 'IntPtr.Zero', 'UIntPtr.Zero', 'string.Empty'. - IFieldReferenceOperation - { - Field: { IsStatic: true, ContainingType.SpecialType: not SpecialType.None } - } => true, + // Reading a static field can run a type initializer, which can throw. The core-library primitives are + // the exception: 'IntPtr.Zero', 'UIntPtr.Zero', 'string.Empty'. + IFieldReferenceOperation + { + Field: { IsStatic: true, ContainingType.SpecialType: not SpecialType.None } + } => true, - IConversionOperation conversion => IsNonThrowingConversion(conversion) && - IsTriviallyNonThrowing(conversion.Operand), + IConversionOperation conversion => IsNonThrowingConversion(conversion) && + IsTriviallyNonThrowing(conversion.Operand), - // '-x', '+x', '~x', '!x' outside a checked context. 'dynamic' and 'decimal' operands run code, and so does - // the fifth built-in unary operator: '^x' constructs a System.Index, which rejects negative values. - IUnaryOperation - { - OperatorKind: UnaryOperatorKind.Minus or UnaryOperatorKind.Plus or UnaryOperatorKind.BitwiseNegation - or UnaryOperatorKind.Not, - OperatorMethod: null, - IsChecked: false - } unary => IsPrimitiveOrEnum(unary.Operand.Type) && IsTriviallyNonThrowing(unary.Operand), + // '-x', '+x', '~x', '!x' outside a checked context. 'dynamic' and 'decimal' operands run code, and so does + // the fifth built-in unary operator: '^x' constructs a System.Index, which rejects negative values. + IUnaryOperation + { + OperatorKind: UnaryOperatorKind.Minus or UnaryOperatorKind.Plus or UnaryOperatorKind.BitwiseNegation + or UnaryOperatorKind.Not, + OperatorMethod: null, + IsChecked: false + } unary => IsPrimitiveOrEnum(unary.Operand.Type) && IsTriviallyNonThrowing(unary.Operand), - IConditionalOperation { IsRef: false, WhenFalse: { } whenFalse } conditional => - IsTriviallyNonThrowing(conditional.Condition) - && IsTriviallyNonThrowing(conditional.WhenTrue) - && IsTriviallyNonThrowing(whenFalse), + IConditionalOperation { IsRef: false, WhenFalse: { } whenFalse } conditional => + IsTriviallyNonThrowing(conditional.Condition) + && IsTriviallyNonThrowing(conditional.WhenTrue) + && IsTriviallyNonThrowing(whenFalse), - _ => false - }; - } + _ => false + }; + } - // A closed list of built-in conversions that run no user code, allocate nothing and cannot fail: - // identity and the typing of a 'default' or 'null' literal; implicit numeric, reference and pointer - // conversions; wrapping into (or widening between) nullable primitives and wrapping a value into its own - // nullable type; and, outside a checked context, explicit conversions among primitives, enums and pointers - // ('(int)wide', '(int)status', '(nint)pointer'). - private static bool IsNonThrowingConversion(IConversionOperation operation) - { - var conversion = operation.GetConversion(); - if (!conversion.Exists || conversion.IsUserDefined || conversion.MethodSymbol is not null || - conversion.IsDynamic) return false; + // A closed list of built-in conversions that run no user code, allocate nothing and cannot fail: + // identity and the typing of a 'default' or 'null' literal; implicit numeric, reference and pointer + // conversions; wrapping into (or widening between) nullable primitives and wrapping a value into its own + // nullable type; and, outside a checked context, explicit conversions among primitives, enums and pointers + // ('(int)wide', '(int)status', '(nint)pointer'). + private static bool IsNonThrowingConversion(IConversionOperation operation) + { + Conversion conversion = operation.GetConversion(); + if (!conversion.Exists || conversion.IsUserDefined || conversion.MethodSymbol is not null || + conversion.IsDynamic) + { + return false; + } - // 'Guid id = default;' is a default-literal conversion around the default value, 'int? none = null;' a - // null-literal conversion: both only give the literal its type. - if (conversion.IsIdentity || conversion.IsDefaultLiteral || conversion.IsNullLiteral) return true; + // 'Guid id = default;' is a default-literal conversion around the default value, 'int? none = null;' a + // null-literal conversion: both only give the literal its type. + if (conversion.IsIdentity || conversion.IsDefaultLiteral || conversion.IsNullLiteral) + { + return true; + } - var source = operation.Operand.Type; - var target = operation.Type; - if (conversion.IsImplicit) - return conversion.IsNumeric - || conversion.IsReference - || conversion.IsPointer - || (conversion.IsNullable && IsNullableWrapping(source, target)); + ITypeSymbol? source = operation.Operand.Type; + ITypeSymbol? target = operation.Type; + if (conversion.IsImplicit) + { + return conversion.IsNumeric + || conversion.IsReference + || conversion.IsPointer + || (conversion.IsNullable && IsNullableWrapping(source, target)); + } - return !operation.IsChecked - && (conversion.IsNumeric || conversion.IsEnumeration || conversion.IsPointer) - && IsPrimitiveEnumOrPointer(source) - && IsPrimitiveEnumOrPointer(target); - } + return !operation.IsChecked + && (conversion.IsNumeric || conversion.IsEnumeration || conversion.IsPointer) + && IsPrimitiveEnumOrPointer(source) + && IsPrimitiveEnumOrPointer(target); + } - // 'int -> int?', 'int -> long?', 'int? -> long?', 'Guid -> Guid?'. An implicit nullable conversion can also - // carry any other implicit conversion of the underlying types (tuple element conversions with user-defined - // operators among them), and Roslyn does not expose which: only the two harmless cases are accepted. - private static bool IsNullableWrapping(ITypeSymbol? source, ITypeSymbol? target) - { - var from = UnwrapNullable(source); - var to = UnwrapNullable(target); - return from is not null - && to is not null - && (SymbolEqualityComparer.Default.Equals(from, to) || - (IsPrimitiveOrEnum(from) && IsPrimitiveOrEnum(to))); - } + // 'int -> int?', 'int -> long?', 'int? -> long?', 'Guid -> Guid?'. An implicit nullable conversion can also + // carry any other implicit conversion of the underlying types (tuple element conversions with user-defined + // operators among them), and Roslyn does not expose which: only the two harmless cases are accepted. + private static bool IsNullableWrapping(ITypeSymbol? source, ITypeSymbol? target) + { + ITypeSymbol? from = UnwrapNullable(source); + ITypeSymbol? to = UnwrapNullable(target); + return from is not null + && to is not null + && (SymbolEqualityComparer.Default.Equals(from, to) || + (IsPrimitiveOrEnum(from) && IsPrimitiveOrEnum(to))); + } - private static ITypeSymbol? UnwrapNullable(ITypeSymbol? type) - { - return type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable - ? nullable.TypeArguments[0] - : type; - } + private static ITypeSymbol? UnwrapNullable(ITypeSymbol? type) + { + return type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : type; + } - private static bool IsPrimitiveEnumOrPointer(ITypeSymbol? type) - { - return type is { TypeKind: TypeKind.Pointer or TypeKind.FunctionPointer } || IsPrimitiveOrEnum(type); - } + private static bool IsPrimitiveEnumOrPointer(ITypeSymbol? type) + { + return type is { TypeKind: TypeKind.Pointer or TypeKind.FunctionPointer } || IsPrimitiveOrEnum(type); + } - // 'decimal' is deliberately absent: its operators and conversions are methods of System.Decimal that throw - // OverflowException whatever the checked context. - private static bool IsPrimitiveOrEnum(ITypeSymbol? type) - { - return type is { TypeKind: TypeKind.Enum } - || type?.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char - or SpecialType.System_SByte or SpecialType.System_Byte - or SpecialType.System_Int16 or SpecialType.System_UInt16 - or SpecialType.System_Int32 or SpecialType.System_UInt32 - or SpecialType.System_Int64 or SpecialType.System_UInt64 - or SpecialType.System_IntPtr or SpecialType.System_UIntPtr - or SpecialType.System_Single or SpecialType.System_Double; - } + // 'decimal' is deliberately absent: its operators and conversions are methods of System.Decimal that throw + // OverflowException whatever the checked context. + private static bool IsPrimitiveOrEnum(ITypeSymbol? type) + { + return type is { TypeKind: TypeKind.Enum } + || type?.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char + or SpecialType.System_SByte or SpecialType.System_Byte + or SpecialType.System_Int16 or SpecialType.System_UInt16 + or SpecialType.System_Int32 or SpecialType.System_UInt32 + or SpecialType.System_Int64 or SpecialType.System_UInt64 + or SpecialType.System_IntPtr or SpecialType.System_UIntPtr + or SpecialType.System_Single or SpecialType.System_Double; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs index e6cef4db..2d1dc3d5 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.WellKnown; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; @@ -27,225 +29,307 @@ namespace CheatEngine.SDK.Analyzers.Usage; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class PluginLifecycleAndOwnershipAnalyzer : DiagnosticAnalyzer { - /// - public override ImmutableArray SupportedDiagnostics { get; } = - [ - DiagnosticDescriptors.RequiresPluginEnabledTooEarly, - DiagnosticDescriptors.DisposeBorrowedValue, - DiagnosticDescriptors.AsyncPluginLifecycle, - ]; - - /// - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(OnCompilationStart); - } - - private static void OnCompilationStart(CompilationStartAnalysisContext context) - { - var pluginAttribute = - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.CheatEnginePluginAttribute); - var pluginBase = SdkSymbolResolver.Hosting(context.Compilation, WellKnownTypeNames.CheatEnginePluginBase); - var requiresPluginEnabled = - SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.RequiresPluginEnabledAttribute); - var ceOwned = SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.CEOwnedAttribute); - if (pluginAttribute is null || pluginBase is null || (requiresPluginEnabled is null && ceOwned is null)) return; - - PluginLifecycleContractSymbols symbols = new(pluginAttribute, pluginBase, requiresPluginEnabled, ceOwned); - context.RegisterOperationAction(operationContext => AnalyzeInvocation(operationContext, symbols), - OperationKind.Invocation); - context.RegisterOperationAction(operationContext => AnalyzePropertyReference(operationContext, symbols), - OperationKind.PropertyReference); - context.RegisterOperationAction(operationContext => AnalyzeObjectCreation(operationContext, symbols), - OperationKind.ObjectCreation); - context.RegisterSymbolAction(symbolContext => AnalyzeMethod(symbolContext, symbols), SymbolKind.Method); - } - - private static void AnalyzeInvocation(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) - { - var invocation = (IInvocationOperation)context.Operation; - if (symbols.RequiresPluginEnabled is not null && IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute) - && RequiresEnabled(invocation.TargetMethod, - symbols.RequiresPluginEnabled)) - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, - invocation.Syntax.GetLocation(), DisplayName(invocation.TargetMethod))); - - if (symbols.CEOwned is null || !IsDisposal(invocation.TargetMethod) || invocation.Instance is null) return; - - if (!IsExplicitlyBorrowed(invocation.Instance, symbols.CEOwned)) return; - - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.DisposeBorrowedValue, - invocation.Syntax.GetLocation(), invocation.Instance.Syntax.ToString())); - } - - private static void AnalyzePropertyReference(OperationAnalysisContext context, - PluginLifecycleContractSymbols symbols) - { - if (symbols.RequiresPluginEnabled is null || - !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) return; - - var property = (IPropertyReferenceOperation)context.Operation; - if (!RequiresEnabled(property.Property, symbols.RequiresPluginEnabled)) return; - - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, - property.Syntax.GetLocation(), DisplayName(property.Property))); - } - - private static void AnalyzeObjectCreation(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) - { - if (symbols.RequiresPluginEnabled is null || - !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) return; - - var creation = (IObjectCreationOperation)context.Operation; - if (creation.Constructor is null || - !RequiresEnabled(creation.Constructor, symbols.RequiresPluginEnabled)) return; - - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, - creation.Syntax.GetLocation(), DisplayName(creation.Constructor))); - } - - private static void AnalyzeMethod(SymbolAnalysisContext context, PluginLifecycleContractSymbols symbols) - { - var method = (IMethodSymbol)context.Symbol; - if (!method.IsAsync || !method.ReturnsVoid || - !IsPluginClass(method.ContainingType, symbols.PluginAttribute)) return; - - if (!IsLifecycleOverride(method, symbols.PluginBase)) return; - - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.AsyncPluginLifecycle, FirstLocation(method), - method.Name)); - } - - private static bool IsTooEarly(ISymbol containingSymbol, INamedTypeSymbol pluginAttribute) - { - if (!IsPluginClass(containingSymbol.ContainingType, pluginAttribute)) return false; - - return containingSymbol switch - { - IMethodSymbol { MethodKind: MethodKind.Constructor or MethodKind.StaticConstructor } => true, - IFieldSymbol => true, - IPropertySymbol => true, - _ => false, - }; - } - - private static bool IsPluginClass(INamedTypeSymbol? type, INamedTypeSymbol pluginAttribute) - { - return type is not null && HasAttribute(type, pluginAttribute); - } - - private static bool IsLifecycleOverride(IMethodSymbol method, INamedTypeSymbol pluginBase) - { - if (method.Name is not "OnEnable" and not "OnDisable") return false; - - for (var overridden = method.OverriddenMethod; overridden is not null; overridden = overridden.OverriddenMethod) - if (SymbolEqualityComparer.Default.Equals(overridden.ContainingType, pluginBase)) - return true; - - return false; - } - - private static bool RequiresEnabled(ISymbol symbol, INamedTypeSymbol requiresPluginEnabled) - { - for (var current = symbol; current is not null; current = OverriddenMember(current)) - if (HasAttribute(current, requiresPluginEnabled)) - return true; - - for (var type = symbol.ContainingType; type is not null; type = type.BaseType) - if (HasAttribute(type, requiresPluginEnabled)) - return true; - - return false; - } - - private static ISymbol? OverriddenMember(ISymbol symbol) - { - return symbol switch - { - IMethodSymbol { AssociatedSymbol: IPropertySymbol property } => property, - IMethodSymbol { OverriddenMethod: { } overriddenMethod } => overriddenMethod, - IPropertySymbol { OverriddenProperty: { } overriddenProperty } => overriddenProperty, - _ => null, - }; - } - - private static bool IsDisposal(IMethodSymbol method) - { - return method.Name is "Dispose" or "DisposeAsync" && method.Parameters.IsEmpty && !method.IsStatic; - } - - private static bool IsExplicitlyBorrowed(IOperation operation, INamedTypeSymbol ceOwned) - { - var current = operation; - while (current is IConversionOperation or IParenthesizedOperation) - current = current switch - { - IConversionOperation conversion => conversion.Operand, - IParenthesizedOperation parenthesized => parenthesized.Operand, - _ => current, - }; - - return current switch - { - IParameterReferenceOperation parameter => HasAttribute(parameter.Parameter, ceOwned), - IPropertyReferenceOperation property => HasOwnedProperty(property.Property, ceOwned), - IInvocationOperation invocation => HasOwnedReturn(invocation.TargetMethod, ceOwned), - _ => false, - }; - } - - private static bool HasOwnedProperty(IPropertySymbol property, INamedTypeSymbol ceOwned) - { - for (var current = property; current is not null; current = current.OverriddenProperty) - if (HasAttribute(current, ceOwned)) - return true; - - return false; - } - - private static bool HasOwnedReturn(IMethodSymbol method, INamedTypeSymbol ceOwned) - { - for (var current = method; current is not null; current = current.OverriddenMethod) - foreach (var attribute in current.GetReturnTypeAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, ceOwned)) - return true; - - return false; - } - - private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType) - { - foreach (var attribute in symbol.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) - return true; - - return false; - } - - private static string DisplayName(ISymbol symbol) - { - return symbol.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); - } - - private static Location FirstLocation(ISymbol symbol) - { - return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; - } - - private sealed class PluginLifecycleContractSymbols( - INamedTypeSymbol pluginAttribute, - INamedTypeSymbol pluginBase, - INamedTypeSymbol? requiresPluginEnabled, - INamedTypeSymbol? ceOwned) - { - public INamedTypeSymbol PluginAttribute { get; } = pluginAttribute; - - public INamedTypeSymbol PluginBase { get; } = pluginBase; - - public INamedTypeSymbol? RequiresPluginEnabled { get; } = requiresPluginEnabled; - - public INamedTypeSymbol? CEOwned { get; } = ceOwned; - } + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = + [ + DiagnosticDescriptors.RequiresPluginEnabledTooEarly, + DiagnosticDescriptors.DisposeBorrowedValue, + DiagnosticDescriptors.AsyncPluginLifecycle + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + INamedTypeSymbol? pluginAttribute = + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.CheatEnginePluginAttribute); + INamedTypeSymbol? pluginBase = + SdkSymbolResolver.Hosting(context.Compilation, WellKnownTypeNames.CheatEnginePluginBase); + INamedTypeSymbol? requiresPluginEnabled = + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.RequiresPluginEnabledAttribute); + INamedTypeSymbol? ceOwned = + SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.CEOwnedAttribute); + if (pluginAttribute is null || pluginBase is null || (requiresPluginEnabled is null && ceOwned is null)) + { + return; + } + + PluginLifecycleContractSymbols symbols = new(pluginAttribute, pluginBase, requiresPluginEnabled, ceOwned); + context.RegisterOperationAction(operationContext => AnalyzeInvocation(operationContext, symbols), + OperationKind.Invocation); + context.RegisterOperationAction(operationContext => AnalyzePropertyReference(operationContext, symbols), + OperationKind.PropertyReference); + context.RegisterOperationAction(operationContext => AnalyzeObjectCreation(operationContext, symbols), + OperationKind.ObjectCreation); + context.RegisterSymbolAction(symbolContext => AnalyzeMethod(symbolContext, symbols), SymbolKind.Method); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) + { + IInvocationOperation invocation = (IInvocationOperation) context.Operation; + if (symbols.RequiresPluginEnabled is not null && IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute) + && RequiresEnabled(invocation.TargetMethod, + symbols.RequiresPluginEnabled)) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, + invocation.Syntax.GetLocation(), DisplayName(invocation.TargetMethod))); + } + + if (symbols.CEOwned is null || !IsDisposal(invocation.TargetMethod) || invocation.Instance is null) + { + return; + } + + if (!IsExplicitlyBorrowed(invocation.Instance, symbols.CEOwned)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.DisposeBorrowedValue, + invocation.Syntax.GetLocation(), invocation.Instance.Syntax.ToString())); + } + + private static void AnalyzePropertyReference(OperationAnalysisContext context, + PluginLifecycleContractSymbols symbols) + { + if (symbols.RequiresPluginEnabled is null || + !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) + { + return; + } + + IPropertyReferenceOperation property = (IPropertyReferenceOperation) context.Operation; + if (!RequiresEnabled(property.Property, symbols.RequiresPluginEnabled)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, + property.Syntax.GetLocation(), DisplayName(property.Property))); + } + + private static void AnalyzeObjectCreation(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) + { + if (symbols.RequiresPluginEnabled is null || + !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) + { + return; + } + + IObjectCreationOperation creation = (IObjectCreationOperation) context.Operation; + if (creation.Constructor is null || + !RequiresEnabled(creation.Constructor, symbols.RequiresPluginEnabled)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, + creation.Syntax.GetLocation(), DisplayName(creation.Constructor))); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context, PluginLifecycleContractSymbols symbols) + { + IMethodSymbol method = (IMethodSymbol) context.Symbol; + if (!method.IsAsync || !method.ReturnsVoid || + !IsPluginClass(method.ContainingType, symbols.PluginAttribute)) + { + return; + } + + if (!IsLifecycleOverride(method, symbols.PluginBase)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.AsyncPluginLifecycle, FirstLocation(method), + method.Name)); + } + + private static bool IsTooEarly(ISymbol containingSymbol, INamedTypeSymbol pluginAttribute) + { + if (!IsPluginClass(containingSymbol.ContainingType, pluginAttribute)) + { + return false; + } + + return containingSymbol switch + { + IMethodSymbol { MethodKind: MethodKind.Constructor or MethodKind.StaticConstructor } => true, + IFieldSymbol => true, + IPropertySymbol => true, + _ => false + }; + } + + private static bool IsPluginClass(INamedTypeSymbol? type, INamedTypeSymbol pluginAttribute) + { + return type is not null && HasAttribute(type, pluginAttribute); + } + + private static bool IsLifecycleOverride(IMethodSymbol method, INamedTypeSymbol pluginBase) + { + if (method.Name is not "OnEnable" and not "OnDisable") + { + return false; + } + + for (IMethodSymbol? overridden = method.OverriddenMethod; + overridden is not null; + overridden = overridden.OverriddenMethod) + { + if (SymbolEqualityComparer.Default.Equals(overridden.ContainingType, pluginBase)) + { + return true; + } + } + + return false; + } + + private static bool RequiresEnabled(ISymbol symbol, INamedTypeSymbol requiresPluginEnabled) + { + for (ISymbol? current = symbol; current is not null; current = OverriddenMember(current)) + { + if (HasAttribute(current, requiresPluginEnabled)) + { + return true; + } + } + + for (INamedTypeSymbol? type = symbol.ContainingType; type is not null; type = type.BaseType) + { + if (HasAttribute(type, requiresPluginEnabled)) + { + return true; + } + } + + return false; + } + + private static ISymbol? OverriddenMember(ISymbol symbol) + { + return symbol switch + { + IMethodSymbol { AssociatedSymbol: IPropertySymbol property } => property, + IMethodSymbol { OverriddenMethod: { } overriddenMethod } => overriddenMethod, + IPropertySymbol { OverriddenProperty: { } overriddenProperty } => overriddenProperty, + _ => null + }; + } + + private static bool IsDisposal(IMethodSymbol method) + { + return method.Name is "Dispose" or "DisposeAsync" && method.Parameters.IsEmpty && !method.IsStatic; + } + + private static bool IsExplicitlyBorrowed(IOperation operation, INamedTypeSymbol ceOwned) + { + IOperation current = operation; + while (current is IConversionOperation or IParenthesizedOperation) + { + current = current switch + { + IConversionOperation conversion => conversion.Operand, + IParenthesizedOperation parenthesized => parenthesized.Operand, + _ => current + }; + } + + return current switch + { + IParameterReferenceOperation parameter => HasAttribute(parameter.Parameter, ceOwned), + IPropertyReferenceOperation property => HasOwnedProperty(property.Property, ceOwned), + IInvocationOperation invocation => HasOwnedReturn(invocation.TargetMethod, ceOwned), + _ => false + }; + } + + private static bool HasOwnedProperty(IPropertySymbol property, INamedTypeSymbol ceOwned) + { + for (IPropertySymbol? current = property; current is not null; current = current.OverriddenProperty) + { + if (HasAttribute(current, ceOwned)) + { + return true; + } + } + + return false; + } + + private static bool HasOwnedReturn(IMethodSymbol method, INamedTypeSymbol ceOwned) + { + for (IMethodSymbol? current = method; current is not null; current = current.OverriddenMethod) + { + foreach (AttributeData attribute in current.GetReturnTypeAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, ceOwned)) + { + return true; + } + } + } + + return false; + } + + private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType) + { + foreach (AttributeData attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) + { + return true; + } + } + + return false; + } + + private static string DisplayName(ISymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); + } + + private static Location FirstLocation(ISymbol symbol) + { + return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; + } + + private sealed class PluginLifecycleContractSymbols( + INamedTypeSymbol pluginAttribute, + INamedTypeSymbol pluginBase, + INamedTypeSymbol? requiresPluginEnabled, + INamedTypeSymbol? ceOwned) + { + public INamedTypeSymbol PluginAttribute + { + get; + } = pluginAttribute; + + public INamedTypeSymbol PluginBase + { + get; + } = pluginBase; + + public INamedTypeSymbol? RequiresPluginEnabled + { + get; + } = requiresPluginEnabled; + + public INamedTypeSymbol? CEOwned + { + get; + } = ceOwned; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs index d1a558fc..1f037769 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.WellKnown; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; @@ -25,95 +27,115 @@ namespace CheatEngine.SDK.Analyzers.Usage; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class UnmanagedCallersOnlyGuardAnalyzer : DiagnosticAnalyzer { - /// - public override ImmutableArray SupportedDiagnostics { get; } = - [ - DiagnosticDescriptors.UnguardedUnmanagedCallersOnly - ]; - - /// - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(OnCompilationStart); - } - - private static void OnCompilationStart(CompilationStartAnalysisContext context) - { - var compilation = context.Compilation; - var referencesCheatEngineSdk = - compilation.GetTypeByMetadataName(WellKnownTypeNames.CheatEnginePluginAttribute) is not null - || compilation.GetTypeByMetadataName(WellKnownTypeNames.CheatEnginePluginBase) is not null; - if (!referencesCheatEngineSdk) return; - - var unmanagedCallersOnly = compilation.GetTypeByMetadataName(WellKnownTypeNames.UnmanagedCallersOnlyAttribute); - var exceptionType = compilation.GetTypeByMetadataName(WellKnownTypeNames.Exception); - if (unmanagedCallersOnly is null || exceptionType is null) return; - - // Optional: without them the guard still works, it only stops recognising [DoesNotReturn] calls as throws. - ExceptionGuard guard = new( - exceptionType, - compilation.GetTypeByMetadataName(WellKnownTypeNames.DoesNotReturnAttribute), - compilation.GetTypeByMetadataName(WellKnownTypeNames.Environment)); - - context.RegisterOperationAction( - operationContext => AnalyzeMethodBody(operationContext, unmanagedCallersOnly, guard), - OperationKind.MethodBody); - context.RegisterOperationAction( - operationContext => AnalyzeLocalFunction(operationContext, unmanagedCallersOnly, guard), - OperationKind.LocalFunction); - } - - private static void AnalyzeMethodBody(OperationAnalysisContext context, INamedTypeSymbol unmanagedCallersOnly, - ExceptionGuard guard) - { - var body = (IMethodBodyOperation)context.Operation; - if (context.ContainingSymbol is not IMethodSymbol method || - !IsUnmanagedCallersOnly(method, unmanagedCallersOnly)) return; - - if (IsUnguarded(body.BlockBody, guard) || IsUnguarded(body.ExpressionBody, guard)) - { - var location = body.Syntax is MethodDeclarationSyntax declaration - ? declaration.Identifier.GetLocation() - : FirstLocation(method); - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnguardedUnmanagedCallersOnly, location, - method.Name)); - } - } - - private static void AnalyzeLocalFunction(OperationAnalysisContext context, INamedTypeSymbol unmanagedCallersOnly, - ExceptionGuard guard) - { - var localFunction = (ILocalFunctionOperation)context.Operation; - if (!IsUnmanagedCallersOnly(localFunction.Symbol, unmanagedCallersOnly)) return; - - if (IsUnguarded(localFunction.Body, guard)) - { - var location = localFunction.Syntax is LocalFunctionStatementSyntax declaration - ? declaration.Identifier.GetLocation() - : FirstLocation(localFunction.Symbol); - context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnguardedUnmanagedCallersOnly, location, - localFunction.Symbol.Name)); - } - } - - private static bool IsUnguarded(IBlockOperation? body, ExceptionGuard guard) - { - return body is not null && !guard.IsGuarded(body); - } - - private static bool IsUnmanagedCallersOnly(IMethodSymbol method, INamedTypeSymbol unmanagedCallersOnly) - { - foreach (var attribute in method.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, unmanagedCallersOnly)) - return true; - - return false; - } - - private static Location FirstLocation(IMethodSymbol method) - { - return method.Locations.IsEmpty ? Location.None : method.Locations[0]; - } + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = + [ + DiagnosticDescriptors.UnguardedUnmanagedCallersOnly + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + Compilation compilation = context.Compilation; + bool referencesCheatEngineSdk = + compilation.GetTypeByMetadataName(WellKnownTypeNames.CheatEnginePluginAttribute) is not null + || compilation.GetTypeByMetadataName(WellKnownTypeNames.CheatEnginePluginBase) is not null; + if (!referencesCheatEngineSdk) + { + return; + } + + INamedTypeSymbol? unmanagedCallersOnly = + compilation.GetTypeByMetadataName(WellKnownTypeNames.UnmanagedCallersOnlyAttribute); + INamedTypeSymbol? exceptionType = compilation.GetTypeByMetadataName(WellKnownTypeNames.Exception); + if (unmanagedCallersOnly is null || exceptionType is null) + { + return; + } + + // Optional: without them the guard still works, it only stops recognising [DoesNotReturn] calls as throws. + ExceptionGuard guard = new( + exceptionType, + compilation.GetTypeByMetadataName(WellKnownTypeNames.DoesNotReturnAttribute), + compilation.GetTypeByMetadataName(WellKnownTypeNames.Environment)); + + context.RegisterOperationAction( + operationContext => AnalyzeMethodBody(operationContext, unmanagedCallersOnly, guard), + OperationKind.MethodBody); + context.RegisterOperationAction( + operationContext => AnalyzeLocalFunction(operationContext, unmanagedCallersOnly, guard), + OperationKind.LocalFunction); + } + + private static void AnalyzeMethodBody(OperationAnalysisContext context, INamedTypeSymbol unmanagedCallersOnly, + ExceptionGuard guard) + { + IMethodBodyOperation body = (IMethodBodyOperation) context.Operation; + if (context.ContainingSymbol is not IMethodSymbol method || + !IsUnmanagedCallersOnly(method, unmanagedCallersOnly)) + { + return; + } + + if (IsUnguarded(body.BlockBody, guard) || IsUnguarded(body.ExpressionBody, guard)) + { + Location location = body.Syntax is MethodDeclarationSyntax declaration + ? declaration.Identifier.GetLocation() + : FirstLocation(method); + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnguardedUnmanagedCallersOnly, location, + method.Name)); + } + } + + private static void AnalyzeLocalFunction(OperationAnalysisContext context, INamedTypeSymbol unmanagedCallersOnly, + ExceptionGuard guard) + { + ILocalFunctionOperation localFunction = (ILocalFunctionOperation) context.Operation; + if (!IsUnmanagedCallersOnly(localFunction.Symbol, unmanagedCallersOnly)) + { + return; + } + + if (IsUnguarded(localFunction.Body, guard)) + { + Location location = localFunction.Syntax is LocalFunctionStatementSyntax declaration + ? declaration.Identifier.GetLocation() + : FirstLocation(localFunction.Symbol); + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnguardedUnmanagedCallersOnly, location, + localFunction.Symbol.Name)); + } + } + + private static bool IsUnguarded(IBlockOperation? body, ExceptionGuard guard) + { + return body is not null && !guard.IsGuarded(body); + } + + private static bool IsUnmanagedCallersOnly(IMethodSymbol method, INamedTypeSymbol unmanagedCallersOnly) + { + foreach (AttributeData attribute in method.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, unmanagedCallersOnly)) + { + return true; + } + } + + return false; + } + + private static Location FirstLocation(IMethodSymbol method) + { + return method.Locations.IsEmpty ? Location.None : method.Locations[0]; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs index 077bd493..83b760c2 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs @@ -1,4 +1,5 @@ using System; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.Analyzers.WellKnown; @@ -10,38 +11,41 @@ namespace CheatEngine.SDK.Analyzers.WellKnown; /// internal static class SdkSymbolResolver { - private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; - private const string HostingAssemblyName = "CheatEngine.SDK.Hosting"; - private const string LuaAssemblyName = "CheatEngine.SDK.Lua"; - - /// Resolves an annotation that must be defined by CheatEngine.SDK.Annotations. - public static INamedTypeSymbol? Annotation(Compilation compilation, string metadataName) - { - return Resolve(compilation, metadataName, AnnotationsAssemblyName); - } - - /// Resolves a plugin-host contract that must be defined by CheatEngine.SDK.Hosting. - public static INamedTypeSymbol? Hosting(Compilation compilation, string metadataName) - { - return Resolve(compilation, metadataName, HostingAssemblyName); - } - - /// Resolves a Lua runtime contract that must be defined by CheatEngine.SDK.Lua. - public static INamedTypeSymbol? Lua(Compilation compilation, string metadataName) - { - return Resolve(compilation, metadataName, LuaAssemblyName); - } - - private static INamedTypeSymbol? Resolve(Compilation compilation, string metadataName, string assemblyName) - { - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly - || !string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) continue; - - return assembly.GetTypeByMetadataName(metadataName); - } - - return null; - } + private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; + private const string HostingAssemblyName = "CheatEngine.SDK.Hosting"; + private const string LuaAssemblyName = "CheatEngine.SDK.Lua"; + + /// Resolves an annotation that must be defined by CheatEngine.SDK.Annotations. + public static INamedTypeSymbol? Annotation(Compilation compilation, string metadataName) + { + return Resolve(compilation, metadataName, AnnotationsAssemblyName); + } + + /// Resolves a plugin-host contract that must be defined by CheatEngine.SDK.Hosting. + public static INamedTypeSymbol? Hosting(Compilation compilation, string metadataName) + { + return Resolve(compilation, metadataName, HostingAssemblyName); + } + + /// Resolves a Lua runtime contract that must be defined by CheatEngine.SDK.Lua. + public static INamedTypeSymbol? Lua(Compilation compilation, string metadataName) + { + return Resolve(compilation, metadataName, LuaAssemblyName); + } + + private static INamedTypeSymbol? Resolve(Compilation compilation, string metadataName, string assemblyName) + { + foreach (MetadataReference reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly + || !string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) + { + continue; + } + + return assembly.GetTypeByMetadataName(metadataName); + } + + return null; + } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs index 74a4890a..33dcb863 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs @@ -8,67 +8,67 @@ namespace CheatEngine.SDK.Analyzers.WellKnown; /// internal static class WellKnownTypeNames { - /// The marker attribute of a plugin class. - public const string CheatEnginePluginAttribute = AnnotationsMetadataNames.CheatEnginePluginAttribute; + /// The marker attribute of a plugin class. + public const string CheatEnginePluginAttribute = AnnotationsMetadataNames.CheatEnginePluginAttribute; - /// The base class of every plugin. - public const string CheatEnginePluginBase = AnnotationsMetadataNames.CheatEnginePluginBase; + /// The base class of every plugin. + public const string CheatEnginePluginBase = AnnotationsMetadataNames.CheatEnginePluginBase; - /// The attribute that turns a static method into a native-callable entry. - public const string UnmanagedCallersOnlyAttribute = "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute"; + /// The attribute that turns a static method into a native-callable entry. + public const string UnmanagedCallersOnlyAttribute = "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute"; - /// The root of the exception hierarchy: the type a catch-all clause names. - public const string Exception = "System.Exception"; + /// The root of the exception hierarchy: the type a catch-all clause names. + public const string Exception = "System.Exception"; - /// - /// Marks a method that never returns normally; in a catch or finally block CESDK1004 reads a call of it as a - /// throw. - /// - public const string DoesNotReturnAttribute = "System.Diagnostics.CodeAnalysis.DoesNotReturnAttribute"; + /// + /// Marks a method that never returns normally; in a catch or finally block CESDK1004 reads a call of it as a + /// throw. + /// + public const string DoesNotReturnAttribute = "System.Diagnostics.CodeAnalysis.DoesNotReturnAttribute"; - /// - /// Declares FailFast and Exit: marked [DoesNotReturn], but they end the process instead of - /// throwing, so CESDK1004 does not read them as a throw. - /// - public const string Environment = "System.Environment"; + /// + /// Declares FailFast and Exit: marked [DoesNotReturn], but they end the process instead of + /// throwing, so CESDK1004 does not read them as a throw. + /// + public const string Environment = "System.Environment"; - /// - /// On a constructor: it sets every member, so new T() needs no object - /// initializer (CESDK0001). - /// - public const string SetsRequiredMembersAttribute = "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute"; + /// + /// On a constructor: it sets every member, so new T() needs no object + /// initializer (CESDK0001). + /// + public const string SetsRequiredMembersAttribute = "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute"; - /// With error: true, naming the marked symbol is a compiler error that no pragma silences (CESDK0001). - public const string ObsoleteAttribute = "System.ObsoleteAttribute"; + /// With error: true, naming the marked symbol is a compiler error that no pragma silences (CESDK0001). + public const string ObsoleteAttribute = "System.ObsoleteAttribute"; - /// Exports a managed static method to Lua as a global C function (CESDK2xxx). - public const string LuaFunctionAttribute = AnnotationsMetadataNames.LuaFunctionAttribute; + /// Exports a managed static method to Lua as a global C function (CESDK2xxx). + public const string LuaFunctionAttribute = AnnotationsMetadataNames.LuaFunctionAttribute; - /// Binds a partial member to a Lua global (CESDK2xxx). - public const string LuaGlobalAttribute = AnnotationsMetadataNames.LuaGlobalAttribute; + /// Binds a partial member to a Lua global (CESDK2xxx). + public const string LuaGlobalAttribute = AnnotationsMetadataNames.LuaGlobalAttribute; - /// Selects a concrete static marshaller for one generated Lua binding value. - public const string LuaMarshallerAttribute = AnnotationsMetadataNames.LuaMarshallerAttribute; + /// Selects a concrete static marshaller for one generated Lua binding value. + public const string LuaMarshallerAttribute = AnnotationsMetadataNames.LuaMarshallerAttribute; - /// The static-abstract Lua marshaller contract used by the generated binding surface. - public const string ILuaMarshaller = "CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller`1"; + /// The static-abstract Lua marshaller contract used by the generated binding surface. + public const string ILuaMarshaller = "CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller`1"; - /// Marks an API that can only run after PluginHost enables the plugin (CESDK1001). - public const string RequiresPluginEnabledAttribute = - "CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute"; + /// Marks an API that can only run after PluginHost enables the plugin (CESDK1001). + public const string RequiresPluginEnabledAttribute = + "CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute"; - /// Marks a value that Cheat Engine owns and callers must only borrow (CESDK1003). - public const string CEOwnedAttribute = "CheatEngine.SDK.Annotations.Lifetime.CEOwnedAttribute"; + /// Marks a value that Cheat Engine owns and callers must only borrow (CESDK1003). + public const string CEOwnedAttribute = "CheatEngine.SDK.Annotations.Lifetime.CEOwnedAttribute"; - /// Marks a Lua object handle whose implementation is emitted by LuaBindings (CESDK2006/2007). - public const string LuaClassAttribute = "CheatEngine.SDK.Annotations.Lua.LuaClassAttribute"; + /// Marks a Lua object handle whose implementation is emitted by LuaBindings (CESDK2006/2007). + public const string LuaClassAttribute = "CheatEngine.SDK.Annotations.Lua.LuaClassAttribute"; - /// Marks a partial Lua object method whose body is emitted by LuaBindings (CESDK2006). - public const string LuaMethodAttribute = "CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute"; + /// Marks a partial Lua object method whose body is emitted by LuaBindings (CESDK2006). + public const string LuaMethodAttribute = "CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute"; - /// Marks a partial Lua object property whose accessors are emitted by LuaBindings (CESDK2006). - public const string LuaPropertyAttribute = "CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute"; + /// Marks a partial Lua object property whose accessors are emitted by LuaBindings (CESDK2006). + public const string LuaPropertyAttribute = "CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute"; - /// The real Lua state symbol that LuaMethod does not accept as an argument. - public const string LuaState = "CheatEngine.SDK.Lua.State.LuaState"; + /// The real Lua state symbol that LuaMethod does not accept as an argument. + public const string LuaState = "CheatEngine.SDK.Lua.State.LuaState"; } diff --git a/analyzers/docs/CESDK2004.md b/analyzers/docs/CESDK2004.md index e0c98adc..e5cb02ec 100644 --- a/analyzers/docs/CESDK2004.md +++ b/analyzers/docs/CESDK2004.md @@ -60,7 +60,7 @@ problem, on the method's own location: | `UnsupportedResultType` | Every `out` result is `int`, `long`, `float`, `double`, `bool`, `nuint` or `string`, or a `Span destination, out int written` copy-out pair. | | `SpanResult` | No result is `ReadOnlySpan` (an `out` parameter or the return type): it would point into a Lua string popped before the wrapper returns. Use the copy-out pair or `string`. | | `UnsupportedReturnType` | The return type of the throwing form is `void` or one of the same marshalled kinds (`bool` included) other than `ReadOnlySpan`. | -| `TryFormReturnNotBool` | A declaration with `out` results returns `bool` or `LuaOperationStatus`: these are the supported non-throwing forms. | +| `TryFormReturnNotBool` | A declaration with `out` results returns `bool` or `LuaOperationStatus`: these are the supported non-throwing forms. | ## Example diff --git a/analyzers/docs/internal-lua-direct-api-boundary.md b/analyzers/docs/internal-lua-direct-api-boundary.md index cd19447f..77734b78 100644 --- a/analyzers/docs/internal-lua-direct-api-boundary.md +++ b/analyzers/docs/internal-lua-direct-api-boundary.md @@ -16,8 +16,10 @@ The only structured conditional exception is `lua_pushcclosure(state, function, fast path for zero upvalues. The repository gate recognizes it only in the audited `LuaState.PushUncheckedFunction` implementation, only when the first argument is that instance's `Pointer`, and only when the immediately preceding statement is `if (lua_checkstack(Pointer, 1) == 0) throw ...`. Any other source file, -method, state expression, intervening Lua call, nonzero count or nonconstant count remains a violation. This deliberately -narrow structural proof avoids a false claim that a general control-flow analysis has established stack capacity. A future +method, state expression, intervening Lua call, nonzero count or nonconstant count remains a violation. This +deliberately +narrow structural proof avoids a false claim that a general control-flow analysis has established stack capacity. A +future public rule needs a sound semantic stack-capacity proof before exposing any conditional exception to consumers. The native bridge remains mandatory for every other catalogue entry marked `requiresBridge: true`, including string, diff --git a/eng/Tests.props b/eng/Tests.props index b709fe7a..a9d7ad9d 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -20,6 +20,7 @@ + diff --git a/exemples/01-first-plugin/README.md b/exemples/01-first-plugin/README.md index e61b6bc1..5080b56f 100644 --- a/exemples/01-first-plugin/README.md +++ b/exemples/01-first-plugin/README.md @@ -221,7 +221,8 @@ To see the host's log, start Sysinternals DebugView, turn on **Capture > Capture - Building generates `CESDK.CESDK.CEPluginInitialize`, the exact entry point Cheat Engine looks up. - An `OnEnable` exception is logged and reported to Cheat Engine as a failed call. An `OnDisable` exception is logged, cleanup continues, and Cheat Engine is told the plugin is disabled only when cleanup, including Lua detachment, - completes successfully. A detach failure returns `FALSE` and leaves the lifecycle in `Disabling`. Neither exception reaches Cheat + completes successfully. A detach failure returns `FALSE` and leaves the lifecycle in `Disabling`. Neither exception + reaches Cheat Engine itself. - A disable and a new enable reuse the same plugin instance. - The host lifecycle is explicit: `Uninitialized → Registered → Enabling → Enabled → Disabling → Registered`. diff --git a/exemples/05-aob-scans/README.md b/exemples/05-aob-scans/README.md index 32ca09d8..c485169d 100644 --- a/exemples/05-aob-scans/README.md +++ b/exemples/05-aob-scans/README.md @@ -137,14 +137,14 @@ flowchart LR E --> F["List of Address
plain managed data"] ``` -| Step | Why | -|----------------------------|--------------------------------------------------------------------------------------------| -| `AobScanner.TryScanOutcome` | Performs the protected CE call, distinguishes no-match from failure, and provides an owner only for a valid list | -| `Owned` | Is the factory-issued ownership proof; `Dispose` executes the documented destroy path once | -| `AobScanOutcome.ResultCount` | Is the valid list count observed immediately after CE returns; it is not an execution bound | -| `StringList.TryGetItem(i)` | Uses Cheat Engine's zero-based index and copies one address string | -| `Address.TryParse` | Decodes CE's hexadecimal address text into the target-address type | -| `using (owner)` | Releases the list before it can escape as a stale native handle | +| Step | Why | +|------------------------------|------------------------------------------------------------------------------------------------------------------| +| `AobScanner.TryScanOutcome` | Performs the protected CE call, distinguishes no-match from failure, and provides an owner only for a valid list | +| `Owned` | Is the factory-issued ownership proof; `Dispose` executes the documented destroy path once | +| `AobScanOutcome.ResultCount` | Is the valid list count observed immediately after CE returns; it is not an execution bound | +| `StringList.TryGetItem(i)` | Uses Cheat Engine's zero-based index and copies one address string | +| `Address.TryParse` | Decodes CE's hexadecimal address text into the target-address type | +| `using (owner)` | Releases the list before it can escape as a stale native handle | ### 4. Export it and patch with it @@ -238,13 +238,13 @@ signature, run the full `Signatures.Scan` and require a count of one, which is w A signature that survives updates follows a few habits: -| Habit | Why | -|-------------------------------------------------------------------------|------------------------------------------------------------------| -| Keep the opcode bytes and wildcard displacements and absolute addresses | Offsets and addresses move between builds, and opcodes rarely do | -| Use twelve or more bytes with several fixed anchors | A short pattern matches unrelated code | +| Habit | Why | +|--------------------------------------------------------------------------------|---------------------------------------------------------------------------------------| +| Keep the opcode bytes and wildcard displacements and absolute addresses | Offsets and addresses move between builds, and opcodes rarely do | +| Use twelve or more bytes with several fixed anchors | A short pattern matches unrelated code | | Use `AOBScanModuleUnique` only when its separate raw CE binding is appropriate | It narrows that raw CE primitive but does not prove uniqueness or extend `AobScanner` | -| Add `+X` when the target is code | Data that happens to hold the same bytes is skipped | -| Check the count after every game update | A count other than one means the signature drifted | +| Add `+X` when the target is code | Data that happens to hold the same bytes is skipped | +| Check the count after every game update | A count other than one means the signature drifted | > [!WARNING] > A patch writes into the target process. Try it on a disposable process first, keep the original bytes next to the new @@ -268,7 +268,8 @@ A signature that survives updates follows a few habits: ## Promise - The list object is destroyed exactly once, on every path that received one. -- A failed protected Lua call becomes a structured `ProtectedLuaFailure` and never leaves an error value on the Lua stack. +- A failed protected Lua call becomes a structured `ProtectedLuaFailure` and never leaves an error value on the Lua + stack. - The Lua stack returns to its previous height after every scan. - The result is plain `Address` data, so it stays valid after the list is gone. diff --git a/exemples/06-value-scans/README.md b/exemples/06-value-scans/README.md index f3b4fb22..5295b2c8 100644 --- a/exemples/06-value-scans/README.md +++ b/exemples/06-value-scans/README.md @@ -12,10 +12,10 @@ --- -| | | -|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| **You learn** | Why a MemScan and its FoundList form one parent/child state machine, and why an object pointer is not enough to establish ownership | -| **Cheat Engine surface** | `createMemScan`, `createFoundList`, `firstScan`, `nextScan`, `newScan`, `waitTillDone`, `initialize`, and `deinitialize` | +| | | +|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **You learn** | Why a MemScan and its FoundList form one parent/child state machine, and why an object pointer is not enough to establish ownership | +| **Cheat Engine surface** | `createMemScan`, `createFoundList`, `firstScan`, `nextScan`, `newScan`, `waitTillDone`, `initialize`, and `deinitialize` | | **Current SDK boundary** | `MemoryScanSessions.TryCreateDetailed` owns the created parent/child pair and reports factual creation outcomes; Client availability remains deferred pending the CE 7.7 x64 live gate | ## Status @@ -56,11 +56,11 @@ stateDiagram-v2 Invalidated --> Disposed: Abandon when context is stale ``` -| State | Operations that are safe by the session contract | Why | -|----------------|--------------------------------------------------|----------------------------------------------------------------------------------------------------------| -| `New` | First scan or ordered disposal | There is no readable result view yet | -| `Scanning` | Wait for completion or ordered disposal | The list must not be read while CE updates it | -| `ResultsReady` | Bounded copy, next scan, reset, or disposal | The same initialized FoundList represents this completed scan | +| State | Operations that are safe by the session contract | Why | +|----------------|---------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| `New` | First scan or ordered disposal | There is no readable result view yet | +| `Scanning` | Wait for completion or ordered disposal | The list must not be read while CE updates it | +| `ResultsReady` | Bounded copy, next scan, reset, or disposal | The same initialized FoundList represents this completed scan | | `Invalidated` | Reset or ordered disposal only if context matches | A protected error leaves native state ambiguous; a stale context instead requires explicit abandonment | The important order is `deinitialize` before a next scan/reset, and `waitTillDone` followed by `initialize` before diff --git a/exemples/10-logging-and-errors/README.md b/exemples/10-logging-and-errors/README.md index 17ebb9e0..5e8c96d7 100644 --- a/exemples/10-logging-and-errors/README.md +++ b/exemples/10-logging-and-errors/README.md @@ -117,11 +117,11 @@ push the file past the limit, the sink shifts every archive up by one, drops the lock covers the roll and the write, so entries from several threads never interleave. With a 200 byte limit and two archives, twenty short entries end like this: -| File | Holds | -|----------------|------------------------------------------------------------------------------------------------| -| `plugin.log` | The newest entries, including `entry 20` | -| `plugin.log.1` | The entries before them | -| `plugin.log.2` | The oldest entries still kept; ordinary entries stay within 200 bytes | +| File | Holds | +|----------------|-----------------------------------------------------------------------| +| `plugin.log` | The newest entries, including `entry 20` | +| `plugin.log.1` | The entries before them | +| `plugin.log.2` | The oldest entries still kept; ordinary entries stay within 200 bytes | Each line has a timestamp in UTC, the level and the message. An exception follows on the next lines, in full: diff --git a/exemples/api/README.md b/exemples/api/README.md index 1b143b29..ac298ec4 100644 --- a/exemples/api/README.md +++ b/exemples/api/README.md @@ -48,20 +48,20 @@ flowchart TB Hosting --> CE ``` -| Namespace | You use it for | Guide | -|----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------| -| `CheatEngine.SDK.Annotations.*` | The attributes the generators and analyzers read | [01](../01-first-plugin/README.md), [02](../02-lua-functions/README.md), [03](../03-calling-cheat-engine/README.md) | -| `CheatEngine.SDK.Hosting.Plugin` | The `CheatEnginePlugin` base class | [01](../01-first-plugin/README.md) | -| `CheatEngine.SDK.Hosting.Context`, `.Bootstrap` | `PluginContext` and the `PluginHost` readers | [09](../09-main-thread/README.md) | -| `CheatEngine.SDK.Hosting.Threading` | `MainThread` | [09](../09-main-thread/README.md) | -| `CheatEngine.SDK.Hosting.Diagnostics` | `HostLog` and log sinks | [10](../10-logging-and-errors/README.md) | -| `CheatEngine.SDK.Lua.State`, `.Runtime`, `.Calls` | `LuaState`, `LuaFrame`, `LuaRuntime`, `LuaStatus`, `LuaException` | [08](../08-running-lua/README.md) | -| `CheatEngine.SDK.Lua.Marshalling`, `.References`, `.Callbacks` | Marshallers, `LuaRef`, `LuaCallback` | [08](../08-running-lua/README.md) | -| `CheatEngine.SDK.Engine.Objects`, `.Values`, `.Enums` | `CEObject`, `Owned`, `Address`, enums and zero-based sequences | [04](../04-memory/README.md) to [07](../07-address-list/README.md) | -| `CheatEngine.SDK.Engine.Runtime` | `RuntimeInfo`, `RuntimeCapabilities`, architecture/ABI/pointer-size facts | Runtime/capability vertical slice | -| `CheatEngine.SDK.Engine.Memory`, `.Inspection`, `.Allocation` | Target/host memory, modules/sections/symbols/regions, explicit target allocations | Typed engine vertical slices | -| `CheatEngine.SDK.Engine.Assembly` | `AutoAssemblerPatcher`, `AutoAssemblerPatch`, and bounded profile-qualified instruction operations | Auto Assembler remains a low-level owner; instruction operations are fixture-qualified and not a live-target claim | -| `CheatEngine.SDK.Engine.Scanning`, `.AddressList` | AOB/StringList, `MemoryScanSessions` factory, scan sessions/results, borrowed address-list records | [05](../05-aob-scans/README.md) to [07](../07-address-list/README.md) | +| Namespace | You use it for | Guide | +|----------------------------------------------------------------|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------| +| `CheatEngine.SDK.Annotations.*` | The attributes the generators and analyzers read | [01](../01-first-plugin/README.md), [02](../02-lua-functions/README.md), [03](../03-calling-cheat-engine/README.md) | +| `CheatEngine.SDK.Hosting.Plugin` | The `CheatEnginePlugin` base class | [01](../01-first-plugin/README.md) | +| `CheatEngine.SDK.Hosting.Context`, `.Bootstrap` | `PluginContext` and the `PluginHost` readers | [09](../09-main-thread/README.md) | +| `CheatEngine.SDK.Hosting.Threading` | `MainThread` | [09](../09-main-thread/README.md) | +| `CheatEngine.SDK.Hosting.Diagnostics` | `HostLog` and log sinks | [10](../10-logging-and-errors/README.md) | +| `CheatEngine.SDK.Lua.State`, `.Runtime`, `.Calls` | `LuaState`, `LuaFrame`, `LuaRuntime`, `LuaStatus`, `LuaException` | [08](../08-running-lua/README.md) | +| `CheatEngine.SDK.Lua.Marshalling`, `.References`, `.Callbacks` | Marshallers, `LuaRef`, `LuaCallback` | [08](../08-running-lua/README.md) | +| `CheatEngine.SDK.Engine.Objects`, `.Values`, `.Enums` | `CEObject`, `Owned`, `Address`, enums and zero-based sequences | [04](../04-memory/README.md) to [07](../07-address-list/README.md) | +| `CheatEngine.SDK.Engine.Runtime` | `RuntimeInfo`, `RuntimeCapabilities`, architecture/ABI/pointer-size facts | Runtime/capability vertical slice | +| `CheatEngine.SDK.Engine.Memory`, `.Inspection`, `.Allocation` | Target/host memory, modules/sections/symbols/regions, explicit target allocations | Typed engine vertical slices | +| `CheatEngine.SDK.Engine.Assembly` | `AutoAssemblerPatcher`, `AutoAssemblerPatch`, and bounded profile-qualified instruction operations | Auto Assembler remains a low-level owner; instruction operations are fixture-qualified and not a live-target claim | +| `CheatEngine.SDK.Engine.Scanning`, `.AddressList` | AOB/StringList, `MemoryScanSessions` factory, scan sessions/results, borrowed address-list records | [05](../05-aob-scans/README.md) to [07](../07-address-list/README.md) | `CheatEngine.SDK.Abi` (the packed init record and host function table) and `CheatEngine.SDK.Lua.Interop` (the raw Lua 5.3 C API) are the foundation. A plugin author rarely calls them, and `CheatEngine.SDK.Lua.CompilerServices` is @@ -330,17 +330,17 @@ disabled plugin (a lifecycle error) from a CE-reported failure, an unavailable g invalid marshalled result. Its source, CE version, architecture, thread requirement, ownership, and normal return semantics belong in the [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md). -| Domain | Public direction | Ownership and thread boundary | -|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| Runtime and capabilities | `RuntimeInfo` is an immutable explicit snapshot; `RuntimeCapabilities` preserves unavailable and unknown separately | It does not invent a capability from a version, architecture, or missing global | -| Target and host memory | `TargetMemory` takes target `Address`; `HostMemory` takes the distinct host address type, with scalar, pointer, byte-span, and text operations | Caller owns span storage; use observed `PointerSize` for target-qualified pointers, and consume detailed partial/count outcomes rather than guessing completion | -| Inspection | Typed modules, sections, symbols, address resolution, and memory-region snapshots | Results are managed snapshots; no CE object/registry ownership escapes | -| Allocation/protection | A typed request returns `AllocatedRegion` and canonical Engine failures distinguish expected CE failure from binding/Lua/marshalling errors | The region owns exactly its allocation and releases deterministically; do not assume a thread contract beyond the evidence carried by the slice | -| Auto Assembler | `AutoAssemblerPatcher.TryApply` owns the returned `AutoAssemblerPatch` disable table; `AutoAssemblerPatch.Release` routes `[DISABLE]` once | The patch consumes ownership before disable, so partial failure is never retried; Client availability remains live-gated | -| Instructions | `InstructionProfiles`, `InstructionAssembler`, `InstructionDisassembler`, and `InstructionNavigator` use an explicit PID/ISA profile and structured outcomes | Returned bytes and disassembly fields are bounded caller/managed data; PID checks are coherence observations, not a CE target lock or live qualification | -| AOB/StringList | AOB results and created `StringList` objects use explicit `Owned` when CE documents caller ownership | Dispose the owner before disable; the wrapped handle itself stays borrowed | -| MemScan/FoundList | `MemoryScanSessions.TryCreate` owns the scanner/child pair and rolls the parent back if child creation fails | Dispose in child-before-parent order; it never hides asynchronous CE state changes; Client availability remains live-gated | -| Address list/records | Typed borrowed handles expose the safe address-list record slice | CE owns GUI list and records; mutations are not transactional and GUI affinity remains live-probe-sensitive | +| Domain | Public direction | Ownership and thread boundary | +|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Runtime and capabilities | `RuntimeInfo` is an immutable explicit snapshot; `RuntimeCapabilities` preserves unavailable and unknown separately | It does not invent a capability from a version, architecture, or missing global | +| Target and host memory | `TargetMemory` takes target `Address`; `HostMemory` takes the distinct host address type, with scalar, pointer, byte-span, and text operations | Caller owns span storage; use observed `PointerSize` for target-qualified pointers, and consume detailed partial/count outcomes rather than guessing completion | +| Inspection | Typed modules, sections, symbols, address resolution, and memory-region snapshots | Results are managed snapshots; no CE object/registry ownership escapes | +| Allocation/protection | A typed request returns `AllocatedRegion` and canonical Engine failures distinguish expected CE failure from binding/Lua/marshalling errors | The region owns exactly its allocation and releases deterministically; do not assume a thread contract beyond the evidence carried by the slice | +| Auto Assembler | `AutoAssemblerPatcher.TryApply` owns the returned `AutoAssemblerPatch` disable table; `AutoAssemblerPatch.Release` routes `[DISABLE]` once | The patch consumes ownership before disable, so partial failure is never retried; Client availability remains live-gated | +| Instructions | `InstructionProfiles`, `InstructionAssembler`, `InstructionDisassembler`, and `InstructionNavigator` use an explicit PID/ISA profile and structured outcomes | Returned bytes and disassembly fields are bounded caller/managed data; PID checks are coherence observations, not a CE target lock or live qualification | +| AOB/StringList | AOB results and created `StringList` objects use explicit `Owned` when CE documents caller ownership | Dispose the owner before disable; the wrapped handle itself stays borrowed | +| MemScan/FoundList | `MemoryScanSessions.TryCreate` owns the scanner/child pair and rolls the parent back if child creation fails | Dispose in child-before-parent order; it never hides asynchronous CE state changes; Client availability remains live-gated | +| Address list/records | Typed borrowed handles expose the safe address-list record slice | CE owns GUI list and records; mutations are not transactional and GUI affinity remains live-probe-sensitive | The owned `AutoAssemblerPatch` is the low-level primitive; high-level Auto Assembler capabilities, arbitrary execution/injection, debugger, DBK/DBVM, UI/D3D, network, and SQL domains remain intentionally opt-in and outside the diff --git a/exemples/recipes/assembly/README.md b/exemples/recipes/assembly/README.md index e4e7a55d..e3e3c058 100644 --- a/exemples/recipes/assembly/README.md +++ b/exemples/recipes/assembly/README.md @@ -2,7 +2,8 @@ # Recipe · Assembly -**Apply one reversible Auto Assembler patch, then inspect or assemble individual instructions through an explicit target profile.** +**Apply one reversible Auto Assembler patch, then inspect or assemble individual instructions through an explicit target +profile.** **Level** `Advanced` · **Time** `30 min` · **Needs** `Guide 03` @@ -12,10 +13,10 @@ --- -| | | -|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| -| **You build** | A reversible Auto Assembler patch, a bounded disassembly listing, and a bounded one-line assembler | -| **You learn** | The difference between a script lifecycle and profile-qualified instruction operations | +| | | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| +| **You build** | A reversible Auto Assembler patch, a bounded disassembly listing, and a bounded one-line assembler | +| **You learn** | The difference between a script lifecycle and profile-qualified instruction operations | | **You need** | [03 · Calling Cheat Engine](../../03-calling-cheat-engine/README.md) and the toolkit tour of [08 · Running Lua](../../08-running-lua/README.md) | | **Cheat Engine functions** | `autoAssemble`, `assemble`, `disassemble`, `splitDisassembledString`, `getInstructionSize`, and `getPreviousOpcode` | @@ -148,23 +149,30 @@ evidence has unresolved conflicts, so the SDK does not project them. ## Good to know -- Keep an `AutoAssemblerPatch` alive for the whole enabled period and dispose it while the Lua runtime is still attached. -- Treat a profile as an observed validation input, not a request to configure CE. Re-observe after a meaningful target transition. +- Keep an `AutoAssemblerPatch` alive for the whole enabled period and dispose it while the Lua runtime is still + attached. +- Treat a profile as an observed validation input, not a request to configure CE. Re-observe after a meaningful target + transition. - Select a raw UTF-8 disassembly bound suitable for your UI. A longer CE line, or collectively longer split fields, is rejected before the SDK decodes it. -- Retry assembly with a larger caller-owned buffer only after handling `DestinationTooSmall`; the initial call publishes no partial bytes. +- Retry assembly with a larger caller-owned buffer only after handling `DestinationTooSmall`; the initial call publishes + no partial bytes. - `getPreviousOpcode` is an estimate. Use it for display navigation, not patch planning. ## Promise -- Auto Assembler cleanup consumes the disable-info owner before its one disable attempt and does not replay uncertain work. -- A failed instruction operation leaves no borrowed Lua string, Lua table, native disassembler object, or partial assembly prefix in managed output. +- Auto Assembler cleanup consumes the disable-info owner before its one disable attempt and does not replay uncertain + work. +- A failed instruction operation leaves no borrowed Lua string, Lua table, native disassembler object, or partial + assembly prefix in managed output. - Every instruction address is checked against the target profile's explicit width, never `IntPtr.Size`. -- Fixture tests exercise the managed Lua shapes and negative paths; they are not evidence of a live Cheat Engine qualification. +- Fixture tests exercise the managed Lua shapes and negative paths; they are not evidence of a live Cheat Engine + qualification. ## Before you move on -- [ ] Verify the patch's `[DISABLE]` branch against an authorized disposable target before shipping an application workflow. +- [ ] Verify the patch's `[DISABLE]` branch against an authorized disposable target before shipping an application + workflow. - [ ] Choose a maximum UTF-8 display-line length and a caller-owned assembly buffer for your UI. - [ ] Record a controlled live capture before claiming support for a particular CE host and target ISA. diff --git a/exemples/recipes/injection/README.md b/exemples/recipes/injection/README.md index 2a1cd246..e96cabdc 100644 --- a/exemples/recipes/injection/README.md +++ b/exemples/recipes/injection/README.md @@ -182,14 +182,14 @@ print(my_plugin_inject("helper.dll")) -- true, or false when ## Good to know -| Topic | Detail | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Failure of a call | `TryExecuteCode` returns `false` when the call raises or returns `nil`; its synchronous return proves the target is no longer using this helper's buffer | +| Topic | Detail | +|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Failure of a call | `TryExecuteCode` returns `false` when the call raises or returns `nil`; its synchronous return proves the target is no longer using this helper's buffer | | Timeout | Do not put caller-owned arguments behind `executeCodeEx` and then free them after a timeout. Keep those allocations in an operation object until target completion is observed | -| Exceptions | A `[LuaFunction]` that throws reaches Lua as an error, here `System.InvalidOperationException: The remote call failed.`, and the buffer is already freed by then | -| Script text | `autoAssemble` runs whatever it receives. `Patch` accepts only symbol characters and two digit hex bytes, so a caller cannot smuggle a second command into the script | -| Protection | `fullAccess(address, size)` makes a block writable and executable. Use it on your own allocation, never on the game's code | -| Path | `Path.GetFullPath` resolves a relative name against the current directory of Cheat Engine, so pass an absolute path when you can | +| Exceptions | A `[LuaFunction]` that throws reaches Lua as an error, here `System.InvalidOperationException: The remote call failed.`, and the buffer is already freed by then | +| Script text | `autoAssemble` runs whatever it receives. `Patch` accepts only symbol characters and two digit hex bytes, so a caller cannot smuggle a second command into the script | +| Protection | `fullAccess(address, size)` makes a block writable and executable. Use it on your own allocation, never on the game's code | +| Path | `Path.GetFullPath` resolves a relative name against the current directory of Cheat Engine, so pass an absolute path when you can | > [!WARNING] > Injecting a DLL and calling target code are operations on someone else's process. Do them only against software you @@ -198,7 +198,8 @@ print(my_plugin_inject("helper.dll")) -- true, or false when ## Promise - A `Try` form never throws for a missing global, a raised error or a wrong result kind. -- The remote buffer is released after a failed write or after `executeCode` has returned, so it is never released while the target call may still read it. +- The remote buffer is released after a failed write or after `executeCode` has returned, so it is never released while + the target call may still read it. - Nothing you pass reaches an Auto Assembler script before `Patch` has checked it. - The generated thunk catches every exception, and the Lua stack returns to its previous height after every call. diff --git a/exemples/recipes/speed-and-hashing/README.md b/exemples/recipes/speed-and-hashing/README.md index 41b7148b..2b0bdd54 100644 --- a/exemples/recipes/speed-and-hashing/README.md +++ b/exemples/recipes/speed-and-hashing/README.md @@ -161,21 +161,22 @@ print(my_plugin_fingerprint("hello")) ## Good to know -| Topic | Detail | -|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| -| Nullable results | A `string?` result that is `null` reaches Lua as `nil`, so a failed hash is `nil` and never an empty string | -| Comparison | MD5 output is hexadecimal text, so the comparison ignores case | -| Path | `md5file` takes a path the way Cheat Engine resolves it. Pass the full path of the executable when the working folder is unclear | -| Memory | `my_plugin_verify_memory` hashes a range of the target, which detects a patched module that the file on disk does not show | +| Topic | Detail | +|------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Nullable results | A `string?` result that is `null` reaches Lua as `nil`, so a failed hash is `nil` and never an empty string | +| Comparison | MD5 output is hexadecimal text, so the comparison ignores case | +| Path | `md5file` takes a path the way Cheat Engine resolves it. Pass the full path of the executable when the working folder is unclear | +| Memory | `my_plugin_verify_memory` hashes a range of the target, which detects a patched module that the file on disk does not show | | Text | `ansiToUTF8` and `UTF8ToAnsi` exchange Windows-codepage bytes, not managed UTF-8 strings. Keep them as raw byte spans and decode only with the code page your integration selected | -| Byte tables | Cheat Engine's byte table converters, such as `dwordToByteTable`, need no binding. In C# use `BitConverter` or `BinaryPrimitives` on a `Span` | -| Whole system | `dbvm_speedhack_setSpeed` also exists and slows the whole system clock. It is covered in the [DBVM recipe](../dbvm/README.md) | +| Byte tables | Cheat Engine's byte table converters, such as `dwordToByteTable`, need no binding. In C# use `BitConverter` or `BinaryPrimitives` on a `Span` | +| Whole system | `dbvm_speedhack_setSpeed` also exists and slows the whole system clock. It is covered in the [DBVM recipe](../dbvm/README.md) | ## Promise - A Try form returns `false` and leaves `out` results at their defaults when a function is missing, raises or returns the wrong kind, so a failed hash is never mistaken for a match. -- `RestoreSpeed` runs in `OnDisable` before the functions are unregistered and retains its saved speed if the setter throws. +- `RestoreSpeed` runs in `OnDisable` before the functions are unregistered and retains its saved speed if the setter + throws. - The generated thunk catches every exception, and the Lua stack returns to its previous height after every call. - Hash results come back as `string`, so each hash call allocates one string; byte-span text conversion leaves decoding and allocation to the caller's chosen Windows code page. diff --git a/libs/CheatEngine.SDK.Abi/AbiArchitecture.cs b/libs/CheatEngine.SDK.Abi/AbiArchitecture.cs index c32bb8db..c868348d 100644 --- a/libs/CheatEngine.SDK.Abi/AbiArchitecture.cs +++ b/libs/CheatEngine.SDK.Abi/AbiArchitecture.cs @@ -21,37 +21,39 @@ namespace CheatEngine.SDK.Abi; /// public static class AbiArchitecture { - /// - /// Gets a value indicating whether the current process has the architecture this assembly is validated for - /// (x64). - /// - /// Thread-safe, allocation-free, never throws. - public static bool IsSupported => IsSupportedArchitecture(RuntimeInformation.ProcessArchitecture); + /// + /// Gets a value indicating whether the current process has the architecture this assembly is validated for + /// (x64). + /// + /// Thread-safe, allocation-free, never throws. + public static bool IsSupported => IsSupportedArchitecture(RuntimeInformation.ProcessArchitecture); - /// - /// Throws when the current process does not have the architecture this assembly is validated for. - /// - /// - /// Thread-safe. Must not be called from a frame that native code called directly, unless that frame catches - /// the exception: use there. - /// - /// The process is not an x64 process. - public static void ThrowIfUnsupported() - { - ThrowIfUnsupported(RuntimeInformation.ProcessArchitecture); - } + /// + /// Throws when the current process does not have the architecture this assembly is validated for. + /// + /// + /// Thread-safe. Must not be called from a frame that native code called directly, unless that frame catches + /// the exception: use there. + /// + /// The process is not an x64 process. + public static void ThrowIfUnsupported() + { + ThrowIfUnsupported(RuntimeInformation.ProcessArchitecture); + } - /// The policy itself, separated from the process query so that every branch is testable on any machine. - internal static bool IsSupportedArchitecture(Architecture architecture) - { - return architecture == Architecture.X64; - } + /// The policy itself, separated from the process query so that every branch is testable on any machine. + internal static bool IsSupportedArchitecture(Architecture architecture) + { + return architecture == Architecture.X64; + } - /// Throwing form of . - internal static void ThrowIfUnsupported(Architecture architecture) - { - if (!IsSupportedArchitecture(architecture)) - throw new PlatformNotSupportedException( - $"CheatEngine.SDK.Abi is validated for x64 processes only; this process is {architecture}."); - } + /// Throwing form of . + internal static void ThrowIfUnsupported(Architecture architecture) + { + if (!IsSupportedArchitecture(architecture)) + { + throw new PlatformNotSupportedException( + $"CheatEngine.SDK.Abi is validated for x64 processes only; this process is {architecture}."); + } + } } diff --git a/libs/CheatEngine.SDK.Abi/AbiConstants.cs b/libs/CheatEngine.SDK.Abi/AbiConstants.cs index 0a09815a..4507d70a 100644 --- a/libs/CheatEngine.SDK.Abi/AbiConstants.cs +++ b/libs/CheatEngine.SDK.Abi/AbiConstants.cs @@ -9,23 +9,23 @@ namespace CheatEngine.SDK.Abi; /// Pure compile-time constants: usable from any thread, at any time, including before the plugin is enabled. public static class AbiConstants { - /// - /// The plugin SDK version described by this assembly, and the value a plugin reports back to Cheat Engine in - /// and . - /// - /// - /// - /// Evidence (verified, three sources agree): the version macro of cepluginsdk.h, the version - /// constant - /// of cepluginsdk.pas and the private version constant of the official managed bootstrap - /// (c# template/SDK/CESDK.cs), all read from the CE 7.7.0.10621 installation, are 6. - /// - /// - /// Inferred from the public 7.5 host source (the 7.7 host is closed source): the host refuses a - /// plugin that reports a version greater than its own, and it hands the 6-field - /// record only to a managed plugin that reports 6 or more. A - /// managed plugin therefore has to report exactly this value. - /// - /// - public const int SdkVersion = 6; + /// + /// The plugin SDK version described by this assembly, and the value a plugin reports back to Cheat Engine in + /// and . + /// + /// + /// + /// Evidence (verified, three sources agree): the version macro of cepluginsdk.h, the version + /// constant + /// of cepluginsdk.pas and the private version constant of the official managed bootstrap + /// (c# template/SDK/CESDK.cs), all read from the CE 7.7.0.10621 installation, are 6. + /// + /// + /// Inferred from the public 7.5 host source (the 7.7 host is closed source): the host refuses a + /// plugin that reports a version greater than its own, and it hands the 6-field + /// record only to a managed plugin that reports 6 or more. A + /// managed plugin therefore has to report exactly this value. + /// + /// + public const int SdkVersion = 6; } diff --git a/libs/CheatEngine.SDK.Abi/Bool32.cs b/libs/CheatEngine.SDK.Abi/Bool32.cs index 4cb2e7fb..31bd6545 100644 --- a/libs/CheatEngine.SDK.Abi/Bool32.cs +++ b/libs/CheatEngine.SDK.Abi/Bool32.cs @@ -35,113 +35,116 @@ namespace CheatEngine.SDK.Abi; [StructLayout(LayoutKind.Sequential)] public readonly struct Bool32(int rawValue) : IEquatable { - /// Gets the false value (raw 0). - public static Bool32 False => default; - - /// Gets the canonical true value written by this SDK (raw 1, the Win32 TRUE). - public static Bool32 True => new(1); - - /// Gets the raw 32-bit pattern. Diagnostic use only: do not compare it with 1. - public int RawValue { get; } = rawValue; - - /// Gets a value indicating whether the raw pattern is non-zero. - public bool IsTrue => RawValue != 0; - - /// Converts a managed boolean to the canonical raw 1 / raw 0 representation. - /// The managed boolean. - /// or . - public static Bool32 FromBoolean(bool value) - { - return new Bool32(value ? 1 : 0); - } - - /// Reads the value as a managed boolean (non-zero is true). - /// when the raw pattern is non-zero. - public bool ToBoolean() - { - return RawValue != 0; - } - - /// Lossless conversion from a managed boolean (raw 1 / raw 0). - /// The managed boolean. - public static implicit operator Bool32(bool value) - { - return FromBoolean(value); - } - - /// Truthiness of the raw pattern. Explicit because the raw bits are dropped. - /// The ABI boolean. - public static explicit operator bool(Bool32 value) - { - return value.RawValue != 0; - } - - /// Lets the value be used directly as a condition (if (result) ...). - /// The ABI boolean. - /// when the raw pattern is non-zero. - public static bool operator true(Bool32 value) - { - return value.RawValue != 0; - } - - /// Counterpart of the operator. - /// The ABI boolean. - /// when the raw pattern is zero. - public static bool operator false(Bool32 value) - { - return value.RawValue == 0; - } - - /// Logical negation (if (!result) ...). - /// The ABI boolean. - /// when the raw pattern is zero. - public static bool operator !(Bool32 value) - { - return value.RawValue == 0; - } - - /// Compares truthiness, not raw bits: raw 1 and raw -1 are equal. - /// The first value. - /// The second value. - /// when both values are true or both are false. - public static bool operator ==(Bool32 left, Bool32 right) - { - return left.Equals(right); - } - - /// Compares truthiness, not raw bits. - /// The first value. - /// The second value. - /// when exactly one of the values is true. - public static bool operator !=(Bool32 left, Bool32 right) - { - return !left.Equals(right); - } - - /// Compares truthiness, not raw bits. - /// The value to compare with. - /// when both values are true or both are false. - public bool Equals(Bool32 other) - { - return RawValue != 0 == (other.RawValue != 0); - } - - /// - public override bool Equals(object? obj) - { - return obj is Bool32 other && Equals(other); - } - - /// - public override int GetHashCode() - { - return RawValue != 0 ? 1 : 0; - } - - /// Returns "True" or "False". Allocation-free (both strings are literals). - /// The truthiness of the value as text. - public override string ToString() - { - return RawValue != 0 ? "True" : "False"; - } + /// Gets the false value (raw 0). + public static Bool32 False => default; + + /// Gets the canonical true value written by this SDK (raw 1, the Win32 TRUE). + public static Bool32 True => new(1); + + /// Gets the raw 32-bit pattern. Diagnostic use only: do not compare it with 1. + public int RawValue + { + get; + } = rawValue; + + /// Gets a value indicating whether the raw pattern is non-zero. + public bool IsTrue => RawValue != 0; + + /// Converts a managed boolean to the canonical raw 1 / raw 0 representation. + /// The managed boolean. + /// or . + public static Bool32 FromBoolean(bool value) + { + return new Bool32(value ? 1 : 0); + } + + /// Reads the value as a managed boolean (non-zero is true). + /// when the raw pattern is non-zero. + public bool ToBoolean() + { + return RawValue != 0; + } + + /// Lossless conversion from a managed boolean (raw 1 / raw 0). + /// The managed boolean. + public static implicit operator Bool32(bool value) + { + return FromBoolean(value); + } + + /// Truthiness of the raw pattern. Explicit because the raw bits are dropped. + /// The ABI boolean. + public static explicit operator bool(Bool32 value) + { + return value.RawValue != 0; + } + + /// Lets the value be used directly as a condition (if (result) ...). + /// The ABI boolean. + /// when the raw pattern is non-zero. + public static bool operator true(Bool32 value) + { + return value.RawValue != 0; + } + + /// Counterpart of the operator. + /// The ABI boolean. + /// when the raw pattern is zero. + public static bool operator false(Bool32 value) + { + return value.RawValue == 0; + } + + /// Logical negation (if (!result) ...). + /// The ABI boolean. + /// when the raw pattern is zero. + public static bool operator !(Bool32 value) + { + return value.RawValue == 0; + } + + /// Compares truthiness, not raw bits: raw 1 and raw -1 are equal. + /// The first value. + /// The second value. + /// when both values are true or both are false. + public static bool operator ==(Bool32 left, Bool32 right) + { + return left.Equals(right); + } + + /// Compares truthiness, not raw bits. + /// The first value. + /// The second value. + /// when exactly one of the values is true. + public static bool operator !=(Bool32 left, Bool32 right) + { + return !left.Equals(right); + } + + /// Compares truthiness, not raw bits. + /// The value to compare with. + /// when both values are true or both are false. + public bool Equals(Bool32 other) + { + return RawValue != 0 == (other.RawValue != 0); + } + + /// + public override bool Equals(object? obj) + { + return obj is Bool32 other && Equals(other); + } + + /// + public override int GetHashCode() + { + return RawValue != 0 ? 1 : 0; + } + + /// Returns "True" or "False". Allocation-free (both strings are literals). + /// The truthiness of the value as text. + public override string ToString() + { + return RawValue != 0 ? "True" : "False"; + } } diff --git a/libs/CheatEngine.SDK.Abi/Bool8.cs b/libs/CheatEngine.SDK.Abi/Bool8.cs index ab9a1bd1..d109e2fe 100644 --- a/libs/CheatEngine.SDK.Abi/Bool8.cs +++ b/libs/CheatEngine.SDK.Abi/Bool8.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Managed; namespace CheatEngine.SDK.Abi; @@ -30,113 +31,116 @@ namespace CheatEngine.SDK.Abi; [StructLayout(LayoutKind.Sequential)] public readonly struct Bool8(byte rawValue) : IEquatable { - /// Gets the false value (raw 0). - public static Bool8 False => default; - - /// Gets the canonical true value written by this SDK (raw 1, the Pascal ). - public static Bool8 True => new(1); - - /// Gets the raw byte. Diagnostic use only: do not compare it with 1. - public byte RawValue { get; } = rawValue; - - /// Gets a value indicating whether the raw byte is non-zero. - public bool IsTrue => RawValue != 0; - - /// Converts a managed boolean to the canonical raw 1 / raw 0 representation. - /// The managed boolean. - /// or . - public static Bool8 FromBoolean(bool value) - { - return new Bool8(value ? (byte)1 : (byte)0); - } - - /// Reads the value as a managed boolean (non-zero is true). - /// when the raw byte is non-zero. - public bool ToBoolean() - { - return RawValue != 0; - } - - /// Lossless conversion from a managed boolean (raw 1 / raw 0). - /// The managed boolean. - public static implicit operator Bool8(bool value) - { - return FromBoolean(value); - } - - /// Truthiness of the raw byte. Explicit because the raw bits are dropped. - /// The ABI boolean. - public static explicit operator bool(Bool8 value) - { - return value.RawValue != 0; - } - - /// Lets the value be used directly as a condition (if (result) ...). - /// The ABI boolean. - /// when the raw byte is non-zero. - public static bool operator true(Bool8 value) - { - return value.RawValue != 0; - } - - /// Counterpart of the operator. - /// The ABI boolean. - /// when the raw byte is zero. - public static bool operator false(Bool8 value) - { - return value.RawValue == 0; - } - - /// Logical negation (if (!result) ...). - /// The ABI boolean. - /// when the raw byte is zero. - public static bool operator !(Bool8 value) - { - return value.RawValue == 0; - } - - /// Compares truthiness, not raw bits. - /// The first value. - /// The second value. - /// when both values are true or both are false. - public static bool operator ==(Bool8 left, Bool8 right) - { - return left.Equals(right); - } - - /// Compares truthiness, not raw bits. - /// The first value. - /// The second value. - /// when exactly one of the values is true. - public static bool operator !=(Bool8 left, Bool8 right) - { - return !left.Equals(right); - } - - /// Compares truthiness, not raw bits. - /// The value to compare with. - /// when both values are true or both are false. - public bool Equals(Bool8 other) - { - return RawValue != 0 == (other.RawValue != 0); - } - - /// - public override bool Equals(object? obj) - { - return obj is Bool8 other && Equals(other); - } - - /// - public override int GetHashCode() - { - return RawValue != 0 ? 1 : 0; - } - - /// Returns "True" or "False". Allocation-free (both strings are literals). - /// The truthiness of the value as text. - public override string ToString() - { - return RawValue != 0 ? "True" : "False"; - } + /// Gets the false value (raw 0). + public static Bool8 False => default; + + /// Gets the canonical true value written by this SDK (raw 1, the Pascal ). + public static Bool8 True => new(1); + + /// Gets the raw byte. Diagnostic use only: do not compare it with 1. + public byte RawValue + { + get; + } = rawValue; + + /// Gets a value indicating whether the raw byte is non-zero. + public bool IsTrue => RawValue != 0; + + /// Converts a managed boolean to the canonical raw 1 / raw 0 representation. + /// The managed boolean. + /// or . + public static Bool8 FromBoolean(bool value) + { + return new Bool8(value ? (byte) 1 : (byte) 0); + } + + /// Reads the value as a managed boolean (non-zero is true). + /// when the raw byte is non-zero. + public bool ToBoolean() + { + return RawValue != 0; + } + + /// Lossless conversion from a managed boolean (raw 1 / raw 0). + /// The managed boolean. + public static implicit operator Bool8(bool value) + { + return FromBoolean(value); + } + + /// Truthiness of the raw byte. Explicit because the raw bits are dropped. + /// The ABI boolean. + public static explicit operator bool(Bool8 value) + { + return value.RawValue != 0; + } + + /// Lets the value be used directly as a condition (if (result) ...). + /// The ABI boolean. + /// when the raw byte is non-zero. + public static bool operator true(Bool8 value) + { + return value.RawValue != 0; + } + + /// Counterpart of the operator. + /// The ABI boolean. + /// when the raw byte is zero. + public static bool operator false(Bool8 value) + { + return value.RawValue == 0; + } + + /// Logical negation (if (!result) ...). + /// The ABI boolean. + /// when the raw byte is zero. + public static bool operator !(Bool8 value) + { + return value.RawValue == 0; + } + + /// Compares truthiness, not raw bits. + /// The first value. + /// The second value. + /// when both values are true or both are false. + public static bool operator ==(Bool8 left, Bool8 right) + { + return left.Equals(right); + } + + /// Compares truthiness, not raw bits. + /// The first value. + /// The second value. + /// when exactly one of the values is true. + public static bool operator !=(Bool8 left, Bool8 right) + { + return !left.Equals(right); + } + + /// Compares truthiness, not raw bits. + /// The value to compare with. + /// when both values are true or both are false. + public bool Equals(Bool8 other) + { + return RawValue != 0 == (other.RawValue != 0); + } + + /// + public override bool Equals(object? obj) + { + return obj is Bool8 other && Equals(other); + } + + /// + public override int GetHashCode() + { + return RawValue != 0 ? 1 : 0; + } + + /// Returns "True" or "False". Allocation-free (both strings are literals). + /// The truthiness of the value as text. + public override string ToString() + { + return RawValue != 0 ? "True" : "False"; + } } diff --git a/libs/CheatEngine.SDK.Abi/Managed/ManagedEntryPoint.cs b/libs/CheatEngine.SDK.Abi/Managed/ManagedEntryPoint.cs index 39c6624b..5c90a888 100644 --- a/libs/CheatEngine.SDK.Abi/Managed/ManagedEntryPoint.cs +++ b/libs/CheatEngine.SDK.Abi/Managed/ManagedEntryPoint.cs @@ -23,21 +23,21 @@ namespace CheatEngine.SDK.Abi.Managed; /// public static class ManagedEntryPoint { - /// The namespace of the bootstrap type. - public const string Namespace = "CESDK"; + /// The namespace of the bootstrap type. + public const string Namespace = "CESDK"; - /// The simple name of the bootstrap type. - public const string TypeName = "CESDK"; + /// The simple name of the bootstrap type. + public const string TypeName = "CESDK"; - /// The full name of the bootstrap type, as the host requests it. - public const string FullTypeName = Namespace + "." + TypeName; + /// The full name of the bootstrap type, as the host requests it. + public const string FullTypeName = Namespace + "." + TypeName; - /// The name of the public static bootstrap method. - public const string MethodName = "CEPluginInitialize"; + /// The name of the public static bootstrap method. + public const string MethodName = "CEPluginInitialize"; - /// Bootstrap result: the init record was filled in. - public const int Success = 1; + /// Bootstrap result: the init record was filled in. + public const int Success = 1; - /// Bootstrap result: the plugin could not initialise; the init record content is unspecified. - public const int Failure = 0; + /// Bootstrap result: the plugin could not initialise; the init record content is unspecified. + public const int Failure = 0; } diff --git a/libs/CheatEngine.SDK.Abi/Managed/ManagedExportedFunctions.cs b/libs/CheatEngine.SDK.Abi/Managed/ManagedExportedFunctions.cs index 9cae4087..c4d2e5ae 100644 --- a/libs/CheatEngine.SDK.Abi/Managed/ManagedExportedFunctions.cs +++ b/libs/CheatEngine.SDK.Abi/Managed/ManagedExportedFunctions.cs @@ -32,65 +32,65 @@ namespace CheatEngine.SDK.Abi.Managed; [StructLayout(LayoutKind.Sequential)] public unsafe struct ManagedExportedFunctions { - /// - /// Byte size of the record as the host sees it (offset 0). 48 for the only known revision on 64-bit. - /// - public int SizeOfExportedFunctions; + /// + /// Byte size of the record as the host sees it (offset 0). 48 for the only known revision on 64-bit. + /// + public int SizeOfExportedFunctions; - /// - /// Returns the lua_State* that belongs to the calling OS thread (offset 8). No arguments. - /// - /// - /// The result is typed void* because this assembly references nothing; cast it to the Lua state type of - /// CheatEngine.SDK.Lua.Interop. The host creates one Lua thread per OS thread on demand (inferred - /// from the 7.5 host source; consistent with the official Lua binding, which re-queries the state on every access), so - /// the value must never be cached across threads: fetch it once per operation, and inside a Lua callback use - /// the state the callback received. Callable from any thread. Borrowed: never close the returned state. - /// - public delegate* unmanaged[Stdcall] GetLuaState; + /// + /// Returns the lua_State* that belongs to the calling OS thread (offset 8). No arguments. + /// + /// + /// The result is typed void* because this assembly references nothing; cast it to the Lua state type of + /// CheatEngine.SDK.Lua.Interop. The host creates one Lua thread per OS thread on demand (inferred + /// from the 7.5 host source; consistent with the official Lua binding, which re-queries the state on every access), so + /// the value must never be cached across threads: fetch it once per operation, and inside a Lua callback use + /// the state the callback received. Callable from any thread. Borrowed: never close the returned state. + /// + public delegate* unmanaged[Stdcall] GetLuaState; - /// - /// Host helper that registers a global Lua C function (offset 16). Do not call. Kept untyped on - /// purpose. - /// - /// - /// In the 7.5 host source this slot points at a Pascal routine declared without an explicit convention, i.e. - /// the compiler's default register-based one, while the official managed binding calls it as stdcall. - /// The two coincide on x64 only. The slot is also redundant: pushing a C closure and setting a global through - /// the Lua C API does the same thing with a known convention. Leaving the field as a plain pointer makes an - /// accidental call impossible to write without a cast. - /// - public void* LuaRegister; + /// + /// Host helper that registers a global Lua C function (offset 16). Do not call. Kept untyped on + /// purpose. + /// + /// + /// In the 7.5 host source this slot points at a Pascal routine declared without an explicit convention, i.e. + /// the compiler's default register-based one, while the official managed binding calls it as stdcall. + /// The two coincide on x64 only. The slot is also redundant: pushing a C closure and setting a global through + /// the Lua C API does the same thing with a known convention. Leaving the field as a plain pointer makes an + /// accidental call impossible to write without a cast. + /// + public void* LuaRegister; - /// - /// Pushes the Lua userdata wrapper of a native Cheat Engine object onto a Lua stack (offset 24). - /// Arguments: the lua_State*, then the native object pointer. - /// - /// - /// Pushes exactly one value. The object pointer is borrowed, the host does not take ownership through this - /// call. Use the state that belongs to the current thread. Whether it may raise a Lua error for an invalid - /// object is unknown: only pass pointers obtained from Cheat Engine. - /// - public delegate* unmanaged[Stdcall] LuaPushClassInstance; + /// + /// Pushes the Lua userdata wrapper of a native Cheat Engine object onto a Lua stack (offset 24). + /// Arguments: the lua_State*, then the native object pointer. + /// + /// + /// Pushes exactly one value. The object pointer is borrowed, the host does not take ownership through this + /// call. Use the state that belongs to the current thread. Whether it may raise a Lua error for an invalid + /// object is unknown: only pass pointers obtained from Cheat Engine. + /// + public delegate* unmanaged[Stdcall] LuaPushClassInstance; - /// - /// Pumps the pending window messages of Cheat Engine's GUI (offset 32). No arguments, no result. - /// - /// - /// Main thread only: it exists so that long-running main-thread work can keep the GUI responsive. Re-entrancy - /// applies: handlers of the pumped messages run inside the call. - /// - public delegate* unmanaged[Stdcall] ProcessMessages; + /// + /// Pumps the pending window messages of Cheat Engine's GUI (offset 32). No arguments, no result. + /// + /// + /// Main thread only: it exists so that long-running main-thread work can keep the GUI responsive. Re-entrancy + /// applies: handlers of the pumped messages run inside the call. + /// + public delegate* unmanaged[Stdcall] ProcessMessages; - /// - /// Runs the calls that other threads have queued for the main thread, waiting up to the given number of - /// milliseconds for one to arrive (offset 40). Returns true when at least one queued call was executed. - /// - /// - /// Main thread only: used while the main thread is blocked waiting for a worker that itself needs to - /// synchronise with the GUI. The result is a 1-byte Pascal boolean, see for the evidence - /// and for why it must not be read as 4 bytes. The meaning of the result is inferred from the 7.5 host - /// source. - /// - public delegate* unmanaged[Stdcall] CheckSynchronize; + /// + /// Runs the calls that other threads have queued for the main thread, waiting up to the given number of + /// milliseconds for one to arrive (offset 40). Returns true when at least one queued call was executed. + /// + /// + /// Main thread only: used while the main thread is blocked waiting for a worker that itself needs to + /// synchronise with the GUI. The result is a 1-byte Pascal boolean, see for the evidence + /// and for why it must not be read as 4 bytes. The meaning of the result is inferred from the 7.5 host + /// source. + /// + public delegate* unmanaged[Stdcall] CheckSynchronize; } diff --git a/libs/CheatEngine.SDK.Abi/Managed/PluginInitRecord.cs b/libs/CheatEngine.SDK.Abi/Managed/PluginInitRecord.cs index 7b61a0c2..76146caf 100644 --- a/libs/CheatEngine.SDK.Abi/Managed/PluginInitRecord.cs +++ b/libs/CheatEngine.SDK.Abi/Managed/PluginInitRecord.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Native; namespace CheatEngine.SDK.Abi.Managed; @@ -35,58 +36,58 @@ namespace CheatEngine.SDK.Abi.Managed; [StructLayout(LayoutKind.Sequential, Pack = 1)] public unsafe struct PluginInitRecord { - /// - /// NUL-terminated plugin name in the process ANSI code page (offset 0). - /// - /// - /// Ownership: the plugin allocates it and must keep it valid for the lifetime of the process; the host - /// may read through this pointer after the bootstrap call has returned and never frees it. Allocate once in - /// native memory and reuse the same pointer on the second bootstrap call. Evidence: the official bootstrap - /// produces the buffer once with an ANSI string conversion into unmanaged memory and never releases it - /// (verified). - /// - public byte* Name; + /// + /// NUL-terminated plugin name in the process ANSI code page (offset 0). + /// + /// + /// Ownership: the plugin allocates it and must keep it valid for the lifetime of the process; the host + /// may read through this pointer after the bootstrap call has returned and never frees it. Allocate once in + /// native memory and reuse the same pointer on the second bootstrap call. Evidence: the official bootstrap + /// produces the buffer once with an ANSI string conversion into unmanaged memory and never releases it + /// (verified). + /// + public byte* Name; - /// - /// Version query callback (offset 8). Receives a host-owned to fill in and the byte - /// size the host reserved for it; returns true on success. - /// - /// - /// Must point at an [UnmanagedCallersOnly] static method declared with the stdcall convention, - /// which stays valid for the lifetime of the process by construction. The callee must not let an exception - /// escape. Called on Cheat Engine's main thread (inferred from the 7.5 host source). - /// - public delegate* unmanaged[Stdcall] GetVersion; + /// + /// Version query callback (offset 8). Receives a host-owned to fill in and the byte + /// size the host reserved for it; returns true on success. + /// + /// + /// Must point at an [UnmanagedCallersOnly] static method declared with the stdcall convention, + /// which stays valid for the lifetime of the process by construction. The callee must not let an exception + /// escape. Called on Cheat Engine's main thread (inferred from the 7.5 host source). + /// + public delegate* unmanaged[Stdcall] GetVersion; - /// - /// Enable callback (offset 16). Receives the address of a record and - /// the plugin id assigned by the host; returns true when the plugin enabled successfully. - /// - /// - /// The record lives in a host stack frame: copy it during the call (honouring - /// ) and never keep the pointer. Same function - /// pointer requirements and threading as . The plugin id is declared unsigned by the - /// official bootstrap (verified) and by the 7.5 host (inferred for 7.7). - /// - public delegate* unmanaged[Stdcall] EnablePlugin; + /// + /// Enable callback (offset 16). Receives the address of a record and + /// the plugin id assigned by the host; returns true when the plugin enabled successfully. + /// + /// + /// The record lives in a host stack frame: copy it during the call (honouring + /// ) and never keep the pointer. Same function + /// pointer requirements and threading as . The plugin id is declared unsigned by the + /// official bootstrap (verified) and by the 7.5 host (inferred for 7.7). + /// + public delegate* unmanaged[Stdcall] EnablePlugin; - /// - /// Disable callback (offset 24). No arguments; returns true when the plugin disabled successfully. - /// - /// - /// The assembly is never unloaded: a later enable calls again in the same loaded - /// assembly, with all static state intact. Same function pointer requirements and threading as - /// . - /// - public delegate* unmanaged[Stdcall] DisablePlugin; + /// + /// Disable callback (offset 24). No arguments; returns true when the plugin disabled successfully. + /// + /// + /// The assembly is never unloaded: a later enable calls again in the same loaded + /// assembly, with all static state intact. Same function pointer requirements and threading as + /// . + /// + public delegate* unmanaged[Stdcall] DisablePlugin; - /// - /// SDK version the plugin was built against (offset 32): write . - /// - /// - /// Width 4 bytes (verified in the official bootstrap). Declared unsigned here because the 7.5 host - /// declares it unsigned (inferred for 7.7); the official bootstrap uses a signed 32-bit field, which is - /// the same bits for every valid value. - /// - public uint Version; + /// + /// SDK version the plugin was built against (offset 32): write . + /// + /// + /// Width 4 bytes (verified in the official bootstrap). Declared unsigned here because the 7.5 host + /// declares it unsigned (inferred for 7.7); the official bootstrap uses a signed 32-bit field, which is + /// the same bits for every valid value. + /// + public uint Version; } diff --git a/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs index b111775a..c0c46036 100644 --- a/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs @@ -27,19 +27,19 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct AddressListPluginInit { - /// NUL-terminated ANSI caption of the menu entry (offset 0). - /// - /// The 7.5 host copies the text during registration (inferred for 7.7); keeping the buffer alive longer is - /// harmless. - /// - public byte* Name; + /// NUL-terminated ANSI caption of the menu entry (offset 0). + /// + /// The 7.5 host copies the text during registration (inferred for 7.7); keeping the buffer alive longer is + /// harmless. + /// + public byte* Name; - /// Opaque address of the address-list callback (offset 8). - /// - /// The C declaration suggests a stdcall callback taking a selected-record pointer and returning a - /// four-byte BOOL. The historical Pascal declaration disagrees about the selected-record address width. - /// Keeping this slot untyped preserves the record layout while preventing an unsupported callback invocation. - /// Do not assign or invoke it until a CE 7.7 host canary qualifies the record and callback together. - /// - public void* Callback; + /// Opaque address of the address-list callback (offset 8). + /// + /// The C declaration suggests a stdcall callback taking a selected-record pointer and returning a + /// four-byte BOOL. The historical Pascal declaration disagrees about the selected-record address width. + /// Keeping this slot untyped preserves the record layout while preventing an unsupported callback invocation. + /// Do not assign or invoke it until a CE 7.7 host canary qualifies the record and callback together. + /// + public void* Callback; } diff --git a/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPhase.cs b/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPhase.cs index 3ae9ac70..4c3540e3 100644 --- a/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPhase.cs +++ b/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPhase.cs @@ -18,15 +18,15 @@ namespace CheatEngine.SDK.Abi.Native; /// public enum AutoAssemblerPhase { - /// Upstream aaInitialize (0): start of a run. - Initialize = 0, + /// Upstream aaInitialize (0): start of a run. + Initialize = 0, - /// Upstream aaPhase1 (1): first pass. - Phase1 = 1, + /// Upstream aaPhase1 (1): first pass. + Phase1 = 1, - /// Upstream aaPhase2 (2): second pass. - Phase2 = 2, + /// Upstream aaPhase2 (2): second pass. + Phase2 = 2, - /// Upstream aaFinalize (3): end of a run. - Finalize = 3 + /// Upstream aaFinalize (3): end of a run. + Finalize = 3 } diff --git a/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPluginInit.cs index 5e9d9dfe..72343424 100644 --- a/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/AutoAssemblerPluginInit.cs @@ -25,15 +25,15 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct AutoAssemblerPluginInit { - /// - /// Invoked per script line and per phase (offset 0). Arguments: in/out pointer to the ANSI text of the line, - /// the current , and a 32-bit id whose meaning is not documented upstream - /// (presumably it identifies the run, so that state can be kept between phases). - /// - /// - /// A replacement line written through the first argument stays owned by the plugin and has to outlive the - /// call. Thread affinity is not documented upstream: assume any thread. Must stay valid until the function is - /// unregistered. Must not let an exception escape. - /// - public delegate* unmanaged[Stdcall] Callback; + /// + /// Invoked per script line and per phase (offset 0). Arguments: in/out pointer to the ANSI text of the line, + /// the current , and a 32-bit id whose meaning is not documented upstream + /// (presumably it identifies the run, so that state can be kept between phases). + /// + /// + /// A replacement line written through the first argument stays owned by the plugin and has to outlive the + /// call. Thread affinity is not documented upstream: assume any thread. Must stay valid until the function is + /// unregistered. Must not let an exception escape. + /// + public delegate* unmanaged[Stdcall] Callback; } diff --git a/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs b/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs index 78b9f4ee..73db789e 100644 --- a/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs +++ b/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs @@ -14,93 +14,104 @@ namespace CheatEngine.SDK.Abi.Native; /// public sealed class BoundedDebugEventObservationBuffer { - private readonly Lock _gate = new(); - private readonly Queue _items; - private readonly DebugEventObservationOverflowPolicy _overflowPolicy; - private long _droppedObservationCount; + private readonly Lock _gate = new(); + private readonly Queue _items; + private long _droppedObservationCount; - /// - /// Initializes a bounded copied-observation buffer. - /// - /// The maximum number of copied observations retained at once. - /// The policy to apply when observations are retained. - /// - /// is not positive or is not a defined policy. - /// - public BoundedDebugEventObservationBuffer(int capacity, DebugEventObservationOverflowPolicy overflowPolicy) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); - if (overflowPolicy is not DebugEventObservationOverflowPolicy.DropNewest and not DebugEventObservationOverflowPolicy.DropOldest) - throw new ArgumentOutOfRangeException(nameof(overflowPolicy)); + /// + /// Initializes a bounded copied-observation buffer. + /// + /// The maximum number of copied observations retained at once. + /// The policy to apply when observations are retained. + /// + /// is not positive or is not a defined policy. + /// + public BoundedDebugEventObservationBuffer(int capacity, DebugEventObservationOverflowPolicy overflowPolicy) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + if (overflowPolicy is not DebugEventObservationOverflowPolicy.DropNewest + and not DebugEventObservationOverflowPolicy.DropOldest) + { + throw new ArgumentOutOfRangeException(nameof(overflowPolicy)); + } - Capacity = capacity; - _overflowPolicy = overflowPolicy; - _items = new Queue(capacity); - } + Capacity = capacity; + OverflowPolicy = overflowPolicy; + _items = new Queue(capacity); + } - /// Gets the maximum number of copied observations the buffer retains. - public int Capacity { get; } + /// Gets the maximum number of copied observations the buffer retains. + public int Capacity + { + get; + } - /// Gets the configured full-buffer policy. - public DebugEventObservationOverflowPolicy OverflowPolicy => _overflowPolicy; + /// Gets the configured full-buffer policy. + public DebugEventObservationOverflowPolicy OverflowPolicy + { + get; + } - /// Gets the number of observations dropped because the bounded buffer was full. - public long DroppedObservationCount => Interlocked.Read(ref _droppedObservationCount); + /// Gets the number of observations dropped because the bounded buffer was full. + public long DroppedObservationCount => Interlocked.Read(ref _droppedObservationCount); - /// Gets the current number of retained observations. - public int Count - { - get - { - lock (_gate) - { - return _items.Count; - } - } - } + /// Gets the current number of retained observations. + public int Count + { + get + { + lock (_gate) + { + return _items.Count; + } + } + } - /// - /// Attempts to publish a copied observation without waiting for a reader. - /// - /// The scalar observation to retain. - /// - /// when the input was retained; when - /// discarded it. - /// - public bool TryPublish(in DebugEventObservation observation) - { - lock (_gate) - { - if (_items.Count == Capacity) - { - Interlocked.Increment(ref _droppedObservationCount); - if (_overflowPolicy is DebugEventObservationOverflowPolicy.DropNewest) return false; + /// + /// Attempts to publish a copied observation without waiting for a reader. + /// + /// The scalar observation to retain. + /// + /// when the input was retained; when + /// discarded it. + /// + public bool TryPublish(in DebugEventObservation observation) + { + lock (_gate) + { + if (_items.Count == Capacity) + { + Interlocked.Increment(ref _droppedObservationCount); + if (OverflowPolicy is DebugEventObservationOverflowPolicy.DropNewest) + { + return false; + } - _items.Dequeue(); - } + _items.Dequeue(); + } - _items.Enqueue(observation); - return true; - } - } + _items.Enqueue(observation); + return true; + } + } - /// - /// Attempts to remove the oldest retained observation. - /// - /// The removed observation, or the default value when no observation was retained. - /// when an observation was removed. - public bool TryRead(out DebugEventObservation observation) - { - lock (_gate) - { - if (_items.Count == 0) - { - observation = default; - return false; - } + /// + /// Attempts to remove the oldest retained observation. + /// + /// The removed observation, or the default value when no observation was retained. + /// when an observation was removed. + public bool TryRead(out DebugEventObservation observation) + { + lock (_gate) + { + if (_items.Count == 0) + { + observation = default; + return false; + } - observation = _items.Dequeue(); - return true; - } - } + observation = _items.Dequeue(); + return true; + } + } } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs index 9907a321..3e977645 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs @@ -23,312 +23,386 @@ namespace CheatEngine.SDK.Abi.Native; /// internal sealed unsafe class ClassicDebugEventDispatcher : IDisposable { - private static readonly Lock SRegistrationGate = new(); - private static ClassicDebugEventDispatcher? s_active; - private static long s_nextSequenceNumber; - - [ThreadStatic] - private static ClassicDebugEventDispatcher? t_dispatcher; - - private readonly Lock _gate = new(); - private readonly ManualResetEventSlim _callbacksDrained = new(initialState: true); - private readonly delegate* unmanaged[Stdcall] _registerFunction; - private readonly delegate* unmanaged[Stdcall] _unregisterFunction; - private readonly int _pluginId; - private readonly CancellationToken _cancellationToken; - private DebugEventDecisionHandler? _handler; - private BoundedDebugEventObservationBuffer? _observations; - private DebugEventPluginInit* _initialization; - private int _functionId; - private int _activeCallbacks; - private bool _acceptingCallbacks; - private bool _releaseInProgress; - private bool _released; - private long _callbackFailureCount; - private long _unsupportedContinuationRequestCount; - - private ClassicDebugEventDispatcher( - in ExportedFunctionsPrefix exports, - int pluginId, - DebugEventDecisionHandler handler, - BoundedDebugEventObservationBuffer? observations, - CancellationToken cancellationToken) - { - _registerFunction = exports.RegisterFunction; - _unregisterFunction = exports.UnregisterFunction; - _pluginId = pluginId; - _handler = handler; - _observations = observations; - _cancellationToken = cancellationToken; - _acceptingCallbacks = true; - } - - /// Gets the number of caught managed handler failures. - internal long CallbackFailureCount => Interlocked.Read(ref _callbackFailureCount); - - /// Gets the number of unsupported plugin-continuation requests rejected to CE continuation. - internal long UnsupportedContinuationRequestCount => Interlocked.Read(ref _unsupportedContinuationRequestCount); - - /// Gets the number of callback invocations admitted before the close boundary. - internal int ActiveCallbackCount - { - get - { - lock (_gate) - { - return _activeCallbacks; - } - } - } - - /// - /// Installs the static callback thunk through the direct, qualified prefix slots. - /// - internal static ClassicDebugEventRegistrationStatus TryRegister( - in ExportedFunctionsPrefix exports, - int pluginId, - DebugEventDecisionHandler handler, - BoundedDebugEventObservationBuffer? observations, - CancellationToken cancellationToken, - out ClassicDebugEventDispatcher? dispatcher) - { - ArgumentNullException.ThrowIfNull(handler); - dispatcher = null; - - if (!AbiArchitecture.IsSupported) return ClassicDebugEventRegistrationStatus.UnsupportedArchitecture; - if (exports.RegisterFunction is null || exports.UnregisterFunction is null) - return ClassicDebugEventRegistrationStatus.MissingHostFunction; - - ClassicDebugEventDispatcher created = new(in exports, pluginId, handler, observations, cancellationToken); - lock (SRegistrationGate) - { - if (s_active is not null) return ClassicDebugEventRegistrationStatus.AnotherRegistrationIsActive; - - // Publish the strong root before registration because a native host is permitted to call its callback - // synchronously while RegisterFunction is still on the stack. - s_active = created; - } - - try - { - created._initialization = (DebugEventPluginInit*)NativeMemory.Alloc((nuint)sizeof(DebugEventPluginInit)); - *created._initialization = new DebugEventPluginInit { Callback = &DispatchUnmanaged }; - var functionId = created._registerFunction(pluginId, PluginType.OnDebugEvent, created._initialization); - if (functionId < 0) - { - created.AbandonFailedRegistration(); - return ClassicDebugEventRegistrationStatus.HostRejectedRegistration; - } - - created._functionId = functionId; - dispatcher = created; - return ClassicDebugEventRegistrationStatus.Registered; - } - catch (Exception) - { - created.AbandonFailedRegistration(); - return ClassicDebugEventRegistrationStatus.RegistrationFault; - } - } - - /// - /// Closes callback admission, unregisters through the host, drains admitted callbacks, and only then clears the - /// managed root and native registration record. - /// - internal ClassicDebugEventReleaseStatus TryRelease() - { - if (ReferenceEquals(t_dispatcher, this)) return ClassicDebugEventReleaseStatus.CallbackIsExecuting; - - lock (_gate) - { - if (_released) return ClassicDebugEventReleaseStatus.Released; - if (_releaseInProgress) return ClassicDebugEventReleaseStatus.ReleaseInProgress; - - _acceptingCallbacks = false; - _releaseInProgress = true; - } - - try - { - if (!_unregisterFunction(_pluginId, _functionId).IsTrue) - { - EndUnconfirmedReleaseAttempt(); - return ClassicDebugEventReleaseStatus.UnregisterUnconfirmed; - } - } - catch (Exception) - { - EndUnconfirmedReleaseAttempt(); - return ClassicDebugEventReleaseStatus.UnregisterUnconfirmed; - } - - _callbacksDrained.Wait(); - lock (SRegistrationGate) - { - if (ReferenceEquals(s_active, this)) s_active = null; - } - - DebugEventPluginInit* initialization; - lock (_gate) - { - initialization = _initialization; - _initialization = null; - _handler = null; - _observations = null; - _released = true; - _releaseInProgress = false; - } - - if (initialization is not null) NativeMemory.Free(initialization); - _callbacksDrained.Dispose(); - return ClassicDebugEventReleaseStatus.Released; - } - - /// Attempts the same conservative release sequence as an explicit owner teardown. - public void Dispose() - { - _ = TryRelease(); - } - - private static int Dispatch(void* nativeEvent) - { - // Exact native value: zero asks Cheat Engine to handle and continue the event. Every rejection/failure below - // deliberately returns this fallback, so neither a queue nor user code can claim continuation ownership. - if (nativeEvent is null) return 0; - - ClassicDebugEventDispatcher? dispatcher; - lock (SRegistrationGate) - { - dispatcher = s_active; - } - - if (dispatcher is null || !dispatcher.TryEnterCallback(out var handler, out var observations)) return 0; - - var previous = t_dispatcher; - t_dispatcher = dispatcher; - try - { - var header = Unsafe.ReadUnaligned(nativeEvent); - DebugEventObservation observation = new( - Interlocked.Increment(ref s_nextSequenceNumber), - header.EventCode, - header.ProcessId, - header.ThreadId); - - // This is a bounded copy, never a continuation path. It cannot call a consumer or await work. - observations?.TryPublish(in observation); - - DebugEventDecision decision; - try - { - decision = handler(in observation); - } - catch (Exception) - { - Interlocked.Increment(ref dispatcher._callbackFailureCount); - return 0; - } - - if (decision is not DebugEventDecision.ContinueWithCheatEngine) - Interlocked.Increment(ref dispatcher._unsupportedContinuationRequestCount); - - return 0; - } - catch (Exception) - { - // Includes defensive failure while copying or publishing. Nothing escapes a native stdcall frame. - Interlocked.Increment(ref dispatcher._callbackFailureCount); - return 0; - } - finally - { - t_dispatcher = previous; - dispatcher.ExitCallback(); - } - } - - private bool TryEnterCallback( - out DebugEventDecisionHandler handler, - out BoundedDebugEventObservationBuffer? observations) - { - lock (_gate) - { - if (!_acceptingCallbacks || _cancellationToken.IsCancellationRequested || _handler is null) - { - handler = null!; - observations = null; - return false; - } - - if (_activeCallbacks == 0) _callbacksDrained.Reset(); - - checked - { - _activeCallbacks++; - } - - handler = _handler; - observations = _observations; - return true; - } - } - - private void ExitCallback() - { - lock (_gate) - { - // This cannot occur through the SDK thunk, but an unmanaged callback must never throw across the boundary. - if (_activeCallbacks <= 0) return; - - _activeCallbacks--; - if (_activeCallbacks == 0) _callbacksDrained.Set(); - } - } - - private void AbandonFailedRegistration() - { - DebugEventPluginInit* initialization; - lock (_gate) - { - _acceptingCallbacks = false; - initialization = _initialization; - _initialization = null; - _handler = null; - _observations = null; - _released = true; - _releaseInProgress = false; - } - - lock (SRegistrationGate) - { - if (ReferenceEquals(s_active, this)) s_active = null; - } - - if (initialization is not null) NativeMemory.Free(initialization); - _callbacksDrained.Dispose(); - } - - private void EndUnconfirmedReleaseAttempt() - { - lock (_gate) - { - _releaseInProgress = false; - } - } - - [StructLayout(LayoutKind.Sequential)] - private readonly struct DebugEventHeader(uint eventCode, uint processId, uint threadId) - { - public readonly uint EventCode = eventCode; - public readonly uint ProcessId = processId; - public readonly uint ThreadId = threadId; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int DispatchUnmanaged(void* nativeEvent) - { - // Kept as a separately attributed method so the function-pointer declaration on DebugEventPluginInit remains - // the only exported ABI shape. Dispatch is called through the native-compatible address below. - return Dispatch(nativeEvent); - } + private static readonly Lock SRegistrationGate = new(); + private static ClassicDebugEventDispatcher? s_active; + private static long s_nextSequenceNumber; + + [ThreadStatic] private static ClassicDebugEventDispatcher? t_dispatcher; + + private readonly ManualResetEventSlim _callbacksDrained = new(true); + private readonly CancellationToken _cancellationToken; + + private readonly Lock _gate = new(); + private readonly int _pluginId; + private readonly delegate* unmanaged[Stdcall] _registerFunction; + private readonly delegate* unmanaged[Stdcall] _unregisterFunction; + private bool _acceptingCallbacks; + private int _activeCallbacks; + private long _callbackFailureCount; + private int _functionId; + private DebugEventDecisionHandler? _handler; + private DebugEventPluginInit* _initialization; + private BoundedDebugEventObservationBuffer? _observations; + private bool _releaseInProgress; + private bool _released; + private long _unsupportedContinuationRequestCount; + + private ClassicDebugEventDispatcher( + in ExportedFunctionsPrefix exports, + int pluginId, + DebugEventDecisionHandler handler, + BoundedDebugEventObservationBuffer? observations, + CancellationToken cancellationToken) + { + _registerFunction = exports.RegisterFunction; + _unregisterFunction = exports.UnregisterFunction; + _pluginId = pluginId; + _handler = handler; + _observations = observations; + _cancellationToken = cancellationToken; + _acceptingCallbacks = true; + } + + /// Gets the number of caught managed handler failures. + internal long CallbackFailureCount => Interlocked.Read(ref _callbackFailureCount); + + /// Gets the number of unsupported plugin-continuation requests rejected to CE continuation. + internal long UnsupportedContinuationRequestCount => Interlocked.Read(ref _unsupportedContinuationRequestCount); + + /// Gets the number of callback invocations admitted before the close boundary. + internal int ActiveCallbackCount + { + get + { + lock (_gate) + { + return _activeCallbacks; + } + } + } + + /// Attempts the same conservative release sequence as an explicit owner teardown. + public void Dispose() + { + _ = TryRelease(); + } + + /// + /// Installs the static callback thunk through the direct, qualified prefix slots. + /// + internal static ClassicDebugEventRegistrationStatus TryRegister( + in ExportedFunctionsPrefix exports, + int pluginId, + DebugEventDecisionHandler handler, + BoundedDebugEventObservationBuffer? observations, + CancellationToken cancellationToken, + out ClassicDebugEventDispatcher? dispatcher) + { + ArgumentNullException.ThrowIfNull(handler); + dispatcher = null; + + if (!AbiArchitecture.IsSupported) + { + return ClassicDebugEventRegistrationStatus.UnsupportedArchitecture; + } + + if (exports.RegisterFunction is null || exports.UnregisterFunction is null) + { + return ClassicDebugEventRegistrationStatus.MissingHostFunction; + } + + ClassicDebugEventDispatcher created = new(in exports, pluginId, handler, observations, cancellationToken); + lock (SRegistrationGate) + { + if (s_active is not null) + { + return ClassicDebugEventRegistrationStatus.AnotherRegistrationIsActive; + } + + // Publish the strong root before registration because a native host is permitted to call its callback + // synchronously while RegisterFunction is still on the stack. + s_active = created; + } + + try + { + created._initialization = (DebugEventPluginInit*) NativeMemory.Alloc((nuint) sizeof(DebugEventPluginInit)); + *created._initialization = new DebugEventPluginInit { Callback = &DispatchUnmanaged }; + int functionId = created._registerFunction(pluginId, PluginType.OnDebugEvent, created._initialization); + if (functionId < 0) + { + created.AbandonFailedRegistration(); + return ClassicDebugEventRegistrationStatus.HostRejectedRegistration; + } + + created._functionId = functionId; + dispatcher = created; + return ClassicDebugEventRegistrationStatus.Registered; + } + catch (Exception) + { + created.AbandonFailedRegistration(); + return ClassicDebugEventRegistrationStatus.RegistrationFault; + } + } + + /// + /// Closes callback admission, unregisters through the host, drains admitted callbacks, and only then clears the + /// managed root and native registration record. + /// + internal ClassicDebugEventReleaseStatus TryRelease() + { + if (ReferenceEquals(t_dispatcher, this)) + { + return ClassicDebugEventReleaseStatus.CallbackIsExecuting; + } + + lock (_gate) + { + if (_released) + { + return ClassicDebugEventReleaseStatus.Released; + } + + if (_releaseInProgress) + { + return ClassicDebugEventReleaseStatus.ReleaseInProgress; + } + + _acceptingCallbacks = false; + _releaseInProgress = true; + } + + if (!TryUnregister()) + { + return ClassicDebugEventReleaseStatus.UnregisterUnconfirmed; + } + + CompleteRelease(); + return ClassicDebugEventReleaseStatus.Released; + } + + private bool TryUnregister() + { + try + { + if (_unregisterFunction(_pluginId, _functionId).IsTrue) + { + return true; + } + } + catch (Exception) + { + // The host can fail without throwing or can throw from the unmanaged call. + } + + EndUnconfirmedReleaseAttempt(); + return false; + } + + private void CompleteRelease() + { + _callbacksDrained.Wait(); + lock (SRegistrationGate) + { + if (ReferenceEquals(s_active, this)) + { + s_active = null; + } + } + + DebugEventPluginInit* initialization; + lock (_gate) + { + initialization = _initialization; + _initialization = null; + _handler = null; + _observations = null; + _released = true; + _releaseInProgress = false; + } + + if (initialization is not null) + { + NativeMemory.Free(initialization); + } + + _callbacksDrained.Dispose(); + } + + private static int Dispatch(void* nativeEvent) + { + // Exact native value: zero asks Cheat Engine to handle and continue the event. Every rejection/failure below + // deliberately returns this fallback, so neither a queue nor user code can claim continuation ownership. + if (nativeEvent is null) + { + return 0; + } + + ClassicDebugEventDispatcher? dispatcher; + lock (SRegistrationGate) + { + dispatcher = s_active; + } + + if (dispatcher is null || !dispatcher.TryEnterCallback(out DebugEventDecisionHandler handler, + out BoundedDebugEventObservationBuffer? observations)) + { + return 0; + } + + ClassicDebugEventDispatcher? previous = t_dispatcher; + t_dispatcher = dispatcher; + try + { + return InvokeHandler(dispatcher, handler, observations, nativeEvent); + } + catch (Exception) + { + // Includes defensive failure while copying or publishing. Nothing escapes a native stdcall frame. + Interlocked.Increment(ref dispatcher._callbackFailureCount); + return 0; + } + finally + { + t_dispatcher = previous; + dispatcher.ExitCallback(); + } + } + + private static int InvokeHandler( + ClassicDebugEventDispatcher dispatcher, + DebugEventDecisionHandler handler, + BoundedDebugEventObservationBuffer? observations, + void* nativeEvent) + { + DebugEventHeader header = Unsafe.ReadUnaligned(nativeEvent); + DebugEventObservation observation = new( + Interlocked.Increment(ref s_nextSequenceNumber), + header.EventCode, + header.ProcessId, + header.ThreadId); + + // This is a bounded copy, never a continuation path. It cannot call a consumer or await work. + observations?.TryPublish(in observation); + + DebugEventDecision decision; + try + { + decision = handler(in observation); + } + catch (Exception) + { + Interlocked.Increment(ref dispatcher._callbackFailureCount); + return 0; + } + + if (decision is not DebugEventDecision.ContinueWithCheatEngine) + { + Interlocked.Increment(ref dispatcher._unsupportedContinuationRequestCount); + } + + return 0; + } + + private bool TryEnterCallback( + out DebugEventDecisionHandler handler, + out BoundedDebugEventObservationBuffer? observations) + { + lock (_gate) + { + if (!_acceptingCallbacks || _cancellationToken.IsCancellationRequested || _handler is null) + { + handler = null!; + observations = null; + return false; + } + + if (_activeCallbacks == 0) + { + _callbacksDrained.Reset(); + } + + checked + { + _activeCallbacks++; + } + + handler = _handler; + observations = _observations; + return true; + } + } + + private void ExitCallback() + { + lock (_gate) + { + // This cannot occur through the SDK thunk, but an unmanaged callback must never throw across the boundary. + if (_activeCallbacks <= 0) + { + return; + } + + _activeCallbacks--; + if (_activeCallbacks == 0) + { + _callbacksDrained.Set(); + } + } + } + + private void AbandonFailedRegistration() + { + DebugEventPluginInit* initialization; + lock (_gate) + { + _acceptingCallbacks = false; + initialization = _initialization; + _initialization = null; + _handler = null; + _observations = null; + _released = true; + _releaseInProgress = false; + } + + lock (SRegistrationGate) + { + if (ReferenceEquals(s_active, this)) + { + s_active = null; + } + } + + if (initialization is not null) + { + NativeMemory.Free(initialization); + } + + _callbacksDrained.Dispose(); + } + + private void EndUnconfirmedReleaseAttempt() + { + lock (_gate) + { + _releaseInProgress = false; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int DispatchUnmanaged(void* nativeEvent) + { + // Kept as a separately attributed method so the function-pointer declaration on DebugEventPluginInit remains + // the only exported ABI shape. Dispatch is called through the native-compatible address below. + return Dispatch(nativeEvent); + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct DebugEventHeader(uint eventCode, uint processId, uint threadId) + { + public readonly uint EventCode = eventCode; + public readonly uint ProcessId = processId; + public readonly uint ThreadId = threadId; + } } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventRegistrationStatus.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventRegistrationStatus.cs index 6acc4f67..60ace2a9 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventRegistrationStatus.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventRegistrationStatus.cs @@ -6,10 +6,10 @@ namespace CheatEngine.SDK.Abi.Native; /// internal enum ClassicDebugEventRegistrationStatus { - Registered, - UnsupportedArchitecture, - MissingHostFunction, - AnotherRegistrationIsActive, - HostRejectedRegistration, - RegistrationFault, + Registered, + UnsupportedArchitecture, + MissingHostFunction, + AnotherRegistrationIsActive, + HostRejectedRegistration, + RegistrationFault } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventReleaseStatus.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventReleaseStatus.cs index 76735b4a..094d4af5 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventReleaseStatus.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventReleaseStatus.cs @@ -3,8 +3,8 @@ namespace CheatEngine.SDK.Abi.Native; /// Result of a callback-registration teardown attempt. internal enum ClassicDebugEventReleaseStatus { - Released, - CallbackIsExecuting, - ReleaseInProgress, - UnregisterUnconfirmed, + Released, + CallbackIsExecuting, + ReleaseInProgress, + UnregisterUnconfirmed } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs index 434b89b8..c224ed43 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs @@ -12,25 +12,31 @@ namespace CheatEngine.SDK.Abi.Native; /// internal static class ClassicExportedFunctionsPrefixReader { - /// Number of bytes occupied by the table's declared-size field. - internal const int DeclaredSizeByteCount = sizeof(int); + /// Number of bytes occupied by the table's declared-size field. + internal const int DeclaredSizeByteCount = sizeof(int); - /// Number of bytes in the only physically mapped classic table prefix. - internal const int DirectPrefixByteCount = 144; + /// Number of bytes in the only physically mapped classic table prefix. + internal const int DirectPrefixByteCount = 144; - /// - /// Attempts to copy the mapped prefix when both its declaration and the caller-provided physical buffer prove - /// that all prefix bytes are available. - /// - internal static bool TryCopy(ReadOnlySpan tableBytes, out ExportedFunctionsPrefix prefix) - { - prefix = default; - if (tableBytes.Length < DeclaredSizeByteCount) return false; + /// + /// Attempts to copy the mapped prefix when both its declaration and the caller-provided physical buffer prove + /// that all prefix bytes are available. + /// + internal static bool TryCopy(ReadOnlySpan tableBytes, out ExportedFunctionsPrefix prefix) + { + prefix = default; + if (tableBytes.Length < DeclaredSizeByteCount) + { + return false; + } - var declaredSize = MemoryMarshal.Read(tableBytes); - if (declaredSize < DirectPrefixByteCount || tableBytes.Length < DirectPrefixByteCount) return false; + int declaredSize = MemoryMarshal.Read(tableBytes); + if (declaredSize < DirectPrefixByteCount || tableBytes.Length < DirectPrefixByteCount) + { + return false; + } - prefix = MemoryMarshal.Read(tableBytes); - return true; - } + prefix = MemoryMarshal.Read(tableBytes); + return true; + } } diff --git a/libs/CheatEngine.SDK.Abi/Native/DebugEventDecision.cs b/libs/CheatEngine.SDK.Abi/Native/DebugEventDecision.cs index 12b38192..d9d7a21a 100644 --- a/libs/CheatEngine.SDK.Abi/Native/DebugEventDecision.cs +++ b/libs/CheatEngine.SDK.Abi/Native/DebugEventDecision.cs @@ -12,16 +12,16 @@ namespace CheatEngine.SDK.Abi.Native; /// public enum DebugEventDecision { - /// - /// Lets Cheat Engine handle and continue the event. The native callback returns zero and no SDK continuation is - /// invoked. - /// - ContinueWithCheatEngine = 0, + /// + /// Lets Cheat Engine handle and continue the event. The native callback returns zero and no SDK continuation is + /// invoked. + /// + ContinueWithCheatEngine = 0, - /// - /// Requests plugin-owned continuation. Unsupported by the current profile, so the dispatcher records the - /// request and safely returns instead of claiming ownership it cannot - /// discharge. - /// - PluginOwnsContinuation = 1, + /// + /// Requests plugin-owned continuation. Unsupported by the current profile, so the dispatcher records the + /// request and safely returns instead of claiming ownership it cannot + /// discharge. + /// + PluginOwnsContinuation = 1 } diff --git a/libs/CheatEngine.SDK.Abi/Native/DebugEventObservation.cs b/libs/CheatEngine.SDK.Abi/Native/DebugEventObservation.cs index a8980e0c..a7f78114 100644 --- a/libs/CheatEngine.SDK.Abi/Native/DebugEventObservation.cs +++ b/libs/CheatEngine.SDK.Abi/Native/DebugEventObservation.cs @@ -13,30 +13,30 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public readonly struct DebugEventObservation { - /// - /// Initializes a new copied debug-event observation. - /// - /// The SDK-local callback sequence number. - /// The Windows debug-event code. - /// The debuggee process identifier from the event header. - /// The debuggee thread identifier from the event header. - public DebugEventObservation(long sequenceNumber, uint eventCode, uint processId, uint threadId) - { - SequenceNumber = sequenceNumber; - EventCode = eventCode; - ProcessId = processId; - ThreadId = threadId; - } + /// + /// Initializes a new copied debug-event observation. + /// + /// The SDK-local callback sequence number. + /// The Windows debug-event code. + /// The debuggee process identifier from the event header. + /// The debuggee thread identifier from the event header. + public DebugEventObservation(long sequenceNumber, uint eventCode, uint processId, uint threadId) + { + SequenceNumber = sequenceNumber; + EventCode = eventCode; + ProcessId = processId; + ThreadId = threadId; + } - /// The SDK-local sequence number assigned while the callback was admitted. - public readonly long SequenceNumber; + /// The SDK-local sequence number assigned while the callback was admitted. + public readonly long SequenceNumber; - /// The Windows debug-event code copied from the native header. - public readonly uint EventCode; + /// The Windows debug-event code copied from the native header. + public readonly uint EventCode; - /// The debuggee process identifier copied from the native header. - public readonly uint ProcessId; + /// The debuggee process identifier copied from the native header. + public readonly uint ProcessId; - /// The debuggee thread identifier copied from the native header. - public readonly uint ThreadId; + /// The debuggee thread identifier copied from the native header. + public readonly uint ThreadId; } diff --git a/libs/CheatEngine.SDK.Abi/Native/DebugEventObservationOverflowPolicy.cs b/libs/CheatEngine.SDK.Abi/Native/DebugEventObservationOverflowPolicy.cs index 6b49ba92..4ff18998 100644 --- a/libs/CheatEngine.SDK.Abi/Native/DebugEventObservationOverflowPolicy.cs +++ b/libs/CheatEngine.SDK.Abi/Native/DebugEventObservationOverflowPolicy.cs @@ -5,9 +5,9 @@ namespace CheatEngine.SDK.Abi.Native; /// public enum DebugEventObservationOverflowPolicy { - /// Drops the incoming observation when the buffer is full. - DropNewest = 0, + /// Drops the incoming observation when the buffer is full. + DropNewest = 0, - /// Drops the oldest retained observation before storing the incoming observation when the buffer is full. - DropOldest = 1, + /// Drops the oldest retained observation before storing the incoming observation when the buffer is full. + DropOldest = 1 } diff --git a/libs/CheatEngine.SDK.Abi/Native/DebugEventPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/DebugEventPluginInit.cs index ec0779f7..7ddbc02e 100644 --- a/libs/CheatEngine.SDK.Abi/Native/DebugEventPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/DebugEventPluginInit.cs @@ -20,15 +20,15 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct DebugEventPluginInit { - /// - /// Invoked for each debug event (offset 0). Argument: pointer to the operating system's DEBUG_EVENT - /// structure (an OS type, deliberately not mapped here). Result: 0 lets Cheat Engine handle the event; 1 - /// means the plugin handled it and is then responsible for continuing the debug event itself (stated by the - /// official C sample plugin). - /// - /// - /// Runs on a thread other than the main thread (stated by the official C sample plugin): no GUI work. Must - /// stay valid until the function is unregistered. Must not let an exception escape. - /// - public delegate* unmanaged[Stdcall] Callback; + /// + /// Invoked for each debug event (offset 0). Argument: pointer to the operating system's DEBUG_EVENT + /// structure (an OS type, deliberately not mapped here). Result: 0 lets Cheat Engine handle the event; 1 + /// means the plugin handled it and is then responsible for continuing the debug event itself (stated by the + /// official C sample plugin). + /// + /// + /// Runs on a thread other than the main thread (stated by the official C sample plugin): no GUI work. Must + /// stay valid until the function is unregistered. Must not let an exception escape. + /// + public delegate* unmanaged[Stdcall] Callback; } diff --git a/libs/CheatEngine.SDK.Abi/Native/DisassemblerContextPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/DisassemblerContextPluginInit.cs index f66164c9..ec95e820 100644 --- a/libs/CheatEngine.SDK.Abi/Native/DisassemblerContextPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/DisassemblerContextPluginInit.cs @@ -28,40 +28,40 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct DisassemblerContextPluginInit { - /// NUL-terminated ANSI caption of the menu entry (offset 0). - /// The 7.5 host copies the text during registration (inferred for 7.7). - public byte* Name; + /// NUL-terminated ANSI caption of the menu entry (offset 0). + /// The 7.5 host copies the text during registration (inferred for 7.7). + public byte* Name; - /// Opaque address of the click callback (offset 8). - /// - /// The C header uses a pointer-sized address and a four-byte BOOL result, while the historical Pascal - /// declaration uses a different boolean representation. Keeping this slot untyped preserves the record layout - /// without publishing a callback signature that no exact CE 7.7 host profile has qualified. - /// - public void* Callback; + /// Opaque address of the click callback (offset 8). + /// + /// The C header uses a pointer-sized address and a four-byte BOOL result, while the historical Pascal + /// declaration uses a different boolean representation. Keeping this slot untyped preserves the record layout + /// without publishing a callback signature that no exact CE 7.7 host profile has qualified. + /// + public void* Callback; - /// - /// Opaque address of the popup callback (offset 16). - /// - /// - /// The installed header declares BOOL (stdcall *)(UINT_PTR, char**, BOOL*); the pinned Pascal host uses - /// PBool for the final argument. Since neither establishes the actual CE 7.7 x64 pointee width, the slot - /// has no callable managed signature. Do not assign or invoke it in a production plugin until the required live - /// canary establishes the write boundary, callback result, and caption lifetime. - /// - public void* CallbackOnPopup; + /// + /// Opaque address of the popup callback (offset 16). + /// + /// + /// The installed header declares BOOL (stdcall *)(UINT_PTR, char**, BOOL*); the pinned Pascal host uses + /// PBool for the final argument. Since neither establishes the actual CE 7.7 x64 pointee width, the slot + /// has no callable managed signature. Do not assign or invoke it in a production plugin until the required live + /// canary establishes the write boundary, callback result, and caption lifetime. + /// + public void* CallbackOnPopup; - /// NUL-terminated ANSI shortcut in text form (offset 24). - /// - /// - /// Both upstream declarations type the field as a C string and the header describes the parsing as best-effort - /// (verified); the shipped C example does not register this plugin type. - /// - /// - /// Whether the host tolerates a null pointer here is not established: no local file says so and the 7.7 - /// host is closed source. Do not pass null. To register without a shortcut, point at an empty NUL-terminated - /// string: that stays inside the declared contract (its effect, expected to be "no shortcut", is unverified). - /// - /// - public byte* Shortcut; + /// NUL-terminated ANSI shortcut in text form (offset 24). + /// + /// + /// Both upstream declarations type the field as a C string and the header describes the parsing as best-effort + /// (verified); the shipped C example does not register this plugin type. + /// + /// + /// Whether the host tolerates a null pointer here is not established: no local file says so and the 7.7 + /// host is closed source. Do not pass null. To register without a shortcut, point at an empty NUL-terminated + /// string: that stays inside the declared contract (its effect, expected to be "no shortcut", is unverified). + /// + /// + public byte* Shortcut; } diff --git a/libs/CheatEngine.SDK.Abi/Native/DisassemblerRenderLinePluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/DisassemblerRenderLinePluginInit.cs index 900ffda2..cf333484 100644 --- a/libs/CheatEngine.SDK.Abi/Native/DisassemblerRenderLinePluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/DisassemblerRenderLinePluginInit.cs @@ -20,14 +20,14 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct DisassemblerRenderLinePluginInit { - /// - /// Invoked for every rendered line (offset 0). Arguments: the line's address; in/out pointers to the ANSI - /// texts of the address, bytes, opcode and extra columns; in/out pointer to the 32-bit text colour. - /// - /// - /// Hot path of the GUI: runs on the main thread for each visible line on each repaint (inferred). - /// Replacement strings stay owned by the plugin and have to outlive the call. Must stay valid until the - /// function is unregistered. Must not let an exception escape. - /// - public delegate* unmanaged[Stdcall] Callback; + /// + /// Invoked for every rendered line (offset 0). Arguments: the line's address; in/out pointers to the ANSI + /// texts of the address, bytes, opcode and extra columns; in/out pointer to the 32-bit text colour. + /// + /// + /// Hot path of the GUI: runs on the main thread for each visible line on each repaint (inferred). + /// Replacement strings stay owned by the plugin and have to outlive the call. Must stay valid until the + /// function is unregistered. Must not let an exception escape. + /// + public delegate* unmanaged[Stdcall] Callback; } diff --git a/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs b/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs index 94a29b64..5ea7255e 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs @@ -30,71 +30,71 @@ namespace CheatEngine.SDK.Abi.Native; /// /// [SuppressMessage("Meziantou.Analyzer", "MA0182", - Justification = - "This intentionally internal ABI prefix is retained as a CE 7.7 C-header contract, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains until a safe classic hosting facade owns the host table.")] + Justification = + "This intentionally internal ABI prefix is retained as a CE 7.7 C-header contract, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains until a safe classic hosting facade owns the host table.")] [StructLayout(LayoutKind.Sequential)] internal unsafe struct ExportedFunctionsPrefix { - /// Number of bytes the host initialized in the complete table (offset 0). - public int SizeOfExportedFunctions; + /// Number of bytes the host initialized in the complete table (offset 0). + public int SizeOfExportedFunctions; - /// Displays a host message (offset 8 on x64). - public delegate* unmanaged[Stdcall] ShowMessage; + /// Displays a host message (offset 8 on x64). + public delegate* unmanaged[Stdcall] ShowMessage; - /// Registers a classic plugin function (offset 16 on x64). - public delegate* unmanaged[Stdcall] RegisterFunction; + /// Registers a classic plugin function (offset 16 on x64). + public delegate* unmanaged[Stdcall] RegisterFunction; - /// Unregisters a classic plugin function (offset 24 on x64). - public delegate* unmanaged[Stdcall] UnregisterFunction; + /// Unregisters a classic plugin function (offset 24 on x64). + public delegate* unmanaged[Stdcall] UnregisterFunction; - /// Pointer to the host's current process identifier (offset 32 on x64). - public uint* OpenedProcessId; + /// Pointer to the host's current process identifier (offset 32 on x64). + public uint* OpenedProcessId; - /// Pointer to the host's current process handle (offset 40 on x64). - public void** OpenedProcessHandle; + /// Pointer to the host's current process handle (offset 40 on x64). + public void** OpenedProcessHandle; - /// Gets Cheat Engine's main window handle (offset 48 on x64). - public delegate* unmanaged[Stdcall] GetMainWindowHandle; + /// Gets Cheat Engine's main window handle (offset 48 on x64). + public delegate* unmanaged[Stdcall] GetMainWindowHandle; - /// Runs an auto-assembler script (offset 56 on x64). - public delegate* unmanaged[Stdcall] AutoAssemble; + /// Runs an auto-assembler script (offset 56 on x64). + public delegate* unmanaged[Stdcall] AutoAssemble; - /// Assembles one instruction at an address (offset 64 on x64). - public delegate* unmanaged[Stdcall] Assembler; + /// Assembles one instruction at an address (offset 64 on x64). + public delegate* unmanaged[Stdcall] Assembler; - /// Disassembles an instruction at an address (offset 72 on x64). - public delegate* unmanaged[Stdcall] Disassembler; + /// Disassembles an instruction at an address (offset 72 on x64). + public delegate* unmanaged[Stdcall] Disassembler; - /// Requests a register change at an address (offset 80 on x64). - public delegate* unmanaged[Stdcall] ChangeRegistersAtAddress; + /// Requests a register change at an address (offset 80 on x64). + public delegate* unmanaged[Stdcall] ChangeRegistersAtAddress; - /// Injects a DLL and invokes its exported function (offset 88 on x64). - public delegate* unmanaged[Stdcall] InjectDll; + /// Injects a DLL and invokes its exported function (offset 88 on x64). + public delegate* unmanaged[Stdcall] InjectDll; - /// Creates a memory freeze and returns its host identifier (offset 96 on x64). - public delegate* unmanaged[Stdcall] FreezeMemory; + /// Creates a memory freeze and returns its host identifier (offset 96 on x64). + public delegate* unmanaged[Stdcall] FreezeMemory; - /// Removes a memory freeze by host identifier (offset 104 on x64). - public delegate* unmanaged[Stdcall] UnfreezeMemory; + /// Removes a memory freeze by host identifier (offset 104 on x64). + public delegate* unmanaged[Stdcall] UnfreezeMemory; - /// Opaque address of the historically nullable FixMem slot (offset 112 on x64). - /// - /// The historical Pascal host initializes this slot to nil. A non-null C-header declaration is not - /// evidence that the host exposes a callable implementation, so this SDK never invokes it. - /// - public void* FixMemory; + /// Opaque address of the historically nullable FixMem slot (offset 112 on x64). + /// + /// The historical Pascal host initializes this slot to nil. A non-null C-header declaration is not + /// evidence that the host exposes a callable implementation, so this SDK never invokes it. + /// + public void* FixMemory; - /// Writes the host process list to a caller-provided byte buffer (offset 120 on x64). - public delegate* unmanaged[Stdcall] ProcessList; + /// Writes the host process list to a caller-provided byte buffer (offset 120 on x64). + public delegate* unmanaged[Stdcall] ProcessList; - /// Reloads Cheat Engine settings (offset 128 on x64). - public delegate* unmanaged[Stdcall] ReloadSettings; + /// Reloads Cheat Engine settings (offset 128 on x64). + public delegate* unmanaged[Stdcall] ReloadSettings; - /// Opaque address of the conflicting GetAddressFromPointer slot (offset 136 on x64). - /// - /// The C header returns UINT_PTR, whereas the historical Pascal declaration returns a 32-bit - /// dword. The SDK therefore preserves only the physical slot and does not publish or invoke a - /// pointer-chain signature until a controlled CE 7.7 host canary resolves the return width. - /// - public void* GetAddressFromPointer; + /// Opaque address of the conflicting GetAddressFromPointer slot (offset 136 on x64). + /// + /// The C header returns UINT_PTR, whereas the historical Pascal declaration returns a 32-bit + /// dword. The SDK therefore preserves only the physical slot and does not publish or invoke a + /// pointer-chain signature until a controlled CE 7.7 host canary resolves the return width. + /// + public void* GetAddressFromPointer; } diff --git a/libs/CheatEngine.SDK.Abi/Native/FunctionPointerChangePluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/FunctionPointerChangePluginInit.cs index b6363a3b..3316a462 100644 --- a/libs/CheatEngine.SDK.Abi/Native/FunctionPointerChangePluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/FunctionPointerChangePluginInit.cs @@ -23,7 +23,7 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct FunctionPointerChangePluginInit { - /// Address of the stdcall callback (offset 0). Untyped, see the type remarks. - /// Must stay valid until the function is unregistered. Must not let an exception escape. - public void* Callback; + /// Address of the stdcall callback (offset 0). Untyped, see the type remarks. + /// Must stay valid until the function is unregistered. Must not let an exception escape. + public void* Callback; } diff --git a/libs/CheatEngine.SDK.Abi/Native/MainMenuPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/MainMenuPluginInit.cs index 273fe7b6..2744c4da 100644 --- a/libs/CheatEngine.SDK.Abi/Native/MainMenuPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/MainMenuPluginInit.cs @@ -23,25 +23,25 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct MainMenuPluginInit { - /// NUL-terminated ANSI caption of the menu entry (offset 0). - /// The 7.5 host copies the text during registration (inferred for 7.7). - public byte* Name; + /// NUL-terminated ANSI caption of the menu entry (offset 0). + /// The 7.5 host copies the text during registration (inferred for 7.7). + public byte* Name; - /// Invoked when the user picks the menu entry (offset 8). - /// Must stay valid until the function is unregistered. Must not let an exception escape. - public delegate* unmanaged[Stdcall] Callback; + /// Invoked when the user picks the menu entry (offset 8). + /// Must stay valid until the function is unregistered. Must not let an exception escape. + public delegate* unmanaged[Stdcall] Callback; - /// NUL-terminated ANSI shortcut in text form, for example Ctrl+R (offset 16). - /// - /// - /// Both upstream declarations type the field as a C string and the header describes the parsing as best-effort - /// (verified); the shipped C example always passes text. - /// - /// - /// Whether the host tolerates a null pointer here is not established: no local file says so and the 7.7 - /// host is closed source. Do not pass null. To register without a shortcut, point at an empty NUL-terminated - /// string: that stays inside the declared contract (its effect, expected to be "no shortcut", is unverified). - /// - /// - public byte* Shortcut; + /// NUL-terminated ANSI shortcut in text form, for example Ctrl+R (offset 16). + /// + /// + /// Both upstream declarations type the field as a C string and the header describes the parsing as best-effort + /// (verified); the shipped C example always passes text. + /// + /// + /// Whether the host tolerates a null pointer here is not established: no local file says so and the 7.7 + /// host is closed source. Do not pass null. To register without a shortcut, point at an empty NUL-terminated + /// string: that stays inside the declared contract (its effect, expected to be "no shortcut", is unverified). + /// + /// + public byte* Shortcut; } diff --git a/libs/CheatEngine.SDK.Abi/Native/MemoryViewPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/MemoryViewPluginInit.cs index b793b2bf..27c9d688 100644 --- a/libs/CheatEngine.SDK.Abi/Native/MemoryViewPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/MemoryViewPluginInit.cs @@ -23,32 +23,32 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct MemoryViewPluginInit { - /// NUL-terminated ANSI caption of the menu entry (offset 0). - /// The 7.5 host copies the text during registration (inferred for 7.7). - public byte* Name; + /// NUL-terminated ANSI caption of the menu entry (offset 0). + /// The 7.5 host copies the text during registration (inferred for 7.7). + public byte* Name; - /// - /// Invoked when the user picks the menu entry (offset 8). Arguments, all in/out and host-owned: the - /// disassembler view's top address, the disassembler view's selected address, the hex view's address. - /// - /// - /// Writing through the pointers moves the views; whether the result gates that update is not documented - /// upstream. Must stay valid until the function is unregistered. Must not let an exception escape. - /// - public delegate* unmanaged[Stdcall] Callback; + /// + /// Invoked when the user picks the menu entry (offset 8). Arguments, all in/out and host-owned: the + /// disassembler view's top address, the disassembler view's selected address, the hex view's address. + /// + /// + /// Writing through the pointers moves the views; whether the result gates that update is not documented + /// upstream. Must stay valid until the function is unregistered. Must not let an exception escape. + /// + public delegate* unmanaged[Stdcall] Callback; - /// NUL-terminated ANSI shortcut in text form, for example Ctrl+Q (offset 16). - /// - /// - /// Both upstream declarations type the field as a C string and the header describes the parsing as best-effort - /// (verified); the shipped C example always passes text. The 7.5 host reads this field only when the - /// plugin reports a version above 1 (inferred for 7.7). - /// - /// - /// Whether the host tolerates a null pointer here is not established: no local file says so and the 7.7 - /// host is closed source. Do not pass null. To register without a shortcut, point at an empty NUL-terminated - /// string: that stays inside the declared contract (its effect, expected to be "no shortcut", is unverified). - /// - /// - public byte* Shortcut; + /// NUL-terminated ANSI shortcut in text form, for example Ctrl+Q (offset 16). + /// + /// + /// Both upstream declarations type the field as a C string and the header describes the parsing as best-effort + /// (verified); the shipped C example always passes text. The 7.5 host reads this field only when the + /// plugin reports a version above 1 (inferred for 7.7). + /// + /// + /// Whether the host tolerates a null pointer here is not established: no local file says so and the 7.7 + /// host is closed source. Do not pass null. To register without a shortcut, point at an empty NUL-terminated + /// string: that stays inside the declared contract (its effect, expected to be "no shortcut", is unverified). + /// + /// + public byte* Shortcut; } diff --git a/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs b/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs index 8f284897..7cdf4951 100644 --- a/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs +++ b/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs @@ -36,12 +36,12 @@ namespace CheatEngine.SDK.Abi.Native; /// public static class NativeExportNames { - /// Export that fills in a . - public const string GetVersion = "CEPlugin_GetVersion"; + /// Export that fills in a . + public const string GetVersion = "CEPlugin_GetVersion"; - /// Export that receives the classic exported-functions table and the plugin id, and enables the plugin. - public const string InitializePlugin = "CEPlugin_InitializePlugin"; + /// Export that receives the classic exported-functions table and the plugin id, and enables the plugin. + public const string InitializePlugin = "CEPlugin_InitializePlugin"; - /// Export that disables the plugin. - public const string DisablePlugin = "CEPlugin_DisablePlugin"; + /// Export that disables the plugin. + public const string DisablePlugin = "CEPlugin_DisablePlugin"; } diff --git a/libs/CheatEngine.SDK.Abi/Native/PluginType.cs b/libs/CheatEngine.SDK.Abi/Native/PluginType.cs index effc8ec9..2810123e 100644 --- a/libs/CheatEngine.SDK.Abi/Native/PluginType.cs +++ b/libs/CheatEngine.SDK.Abi/Native/PluginType.cs @@ -19,51 +19,51 @@ namespace CheatEngine.SDK.Abi.Native; /// public enum PluginType { - /// - /// Upstream ptAddressList: context-menu entry of the address list. Record: - /// . - /// - AddressList = 0, + /// + /// Upstream ptAddressList: context-menu entry of the address list. Record: + /// . + /// + AddressList = 0, - /// - /// Upstream ptMemoryView: menu entry of the memory view window. Record: - /// . - /// - MemoryView = 1, + /// + /// Upstream ptMemoryView: menu entry of the memory view window. Record: + /// . + /// + MemoryView = 1, - /// Upstream ptOnDebugEvent: debug event filter. Record: . - OnDebugEvent = 2, + /// Upstream ptOnDebugEvent: debug event filter. Record: . + OnDebugEvent = 2, - /// - /// Upstream ptProcesswatcherEvent: process creation/termination notification. Record: - /// . - /// - ProcessWatcherEvent = 3, + /// + /// Upstream ptProcesswatcherEvent: process creation/termination notification. Record: + /// . + /// + ProcessWatcherEvent = 3, - /// - /// Upstream ptFunctionPointerchange: notification that an API hook slot changed. Record: - /// . - /// - FunctionPointerChange = 4, + /// + /// Upstream ptFunctionPointerchange: notification that an API hook slot changed. Record: + /// . + /// + FunctionPointerChange = 4, - /// Upstream ptMainMenu: entry in the main window's plugin menu. Record: . - MainMenu = 5, + /// Upstream ptMainMenu: entry in the main window's plugin menu. Record: . + MainMenu = 5, - /// - /// Upstream ptDisassemblerContext: context-menu entry of the disassembler view. Record: - /// . - /// - DisassemblerContext = 6, + /// + /// Upstream ptDisassemblerContext: context-menu entry of the disassembler view. Record: + /// . + /// + DisassemblerContext = 6, - /// - /// Upstream ptDisassemblerRenderLine: per-line rendering hook of the disassembler view. Record: - /// . - /// - DisassemblerRenderLine = 7, + /// + /// Upstream ptDisassemblerRenderLine: per-line rendering hook of the disassembler view. Record: + /// . + /// + DisassemblerRenderLine = 7, - /// - /// Upstream ptAutoAssembler: auto-assembler line preprocessor. Record: - /// . - /// - AutoAssembler = 8 + /// + /// Upstream ptAutoAssembler: auto-assembler line preprocessor. Record: + /// . + /// + AutoAssembler = 8 } diff --git a/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs b/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs index c158b299..61363e22 100644 --- a/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs +++ b/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs @@ -26,32 +26,32 @@ namespace CheatEngine.SDK.Abi.Native; /// /// [SuppressMessage("Meziantou.Analyzer", "MA0182", - Justification = - "This intentionally internal C-header mirror is retained as the type-0 callback ABI contract, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains opaque until a safe hosting facade can own the borrowed record lifetime.")] + Justification = + "This intentionally internal C-header mirror is retained as the type-0 callback ABI contract, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains opaque until a safe hosting facade can own the borrowed record lifetime.")] [StructLayout(LayoutKind.Sequential)] internal unsafe struct PluginType0Record { - /// Host-owned NUL-terminated interpreted address text (offset 0). - public byte* InterpretedAddress; + /// Host-owned NUL-terminated interpreted address text (offset 0). + public byte* InterpretedAddress; - /// Read-only pointer-sized target address (offset 8 on x64). - public nuint Address; + /// Read-only pointer-sized target address (offset 8 on x64). + public nuint Address; - /// Read-only Win32 BOOL pointer flag (offset 16 on x64). - public Bool32 IsPointer; + /// Read-only Win32 BOOL pointer flag (offset 16 on x64). + public Bool32 IsPointer; - /// Read-only count of entries (offset 20 on x64). - public int CountOffsets; + /// Read-only count of entries (offset 20 on x64). + public int CountOffsets; - /// Host-owned read-only array of 32-bit offsets (offset 24 on x64). - public uint* Offsets; + /// Host-owned read-only array of 32-bit offsets (offset 24 on x64). + public uint* Offsets; - /// Host-owned NUL-terminated description text (offset 32 on x64). - public byte* Description; + /// Host-owned NUL-terminated description text (offset 32 on x64). + public byte* Description; - /// Host value-type discriminator (offset 40 on x64); its numeric semantics remain outside this ABI type. - public byte ValueType; + /// Host value-type discriminator (offset 40 on x64); its numeric semantics remain outside this ABI type. + public byte ValueType; - /// Host string- or bit-length byte (offset 41 on x64). - public byte Size; + /// Host string- or bit-length byte (offset 41 on x64). + public byte Size; } diff --git a/libs/CheatEngine.SDK.Abi/Native/PluginVersion.cs b/libs/CheatEngine.SDK.Abi/Native/PluginVersion.cs index 31f0f973..6b0a9dcb 100644 --- a/libs/CheatEngine.SDK.Abi/Native/PluginVersion.cs +++ b/libs/CheatEngine.SDK.Abi/Native/PluginVersion.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Managed; namespace CheatEngine.SDK.Abi.Native; @@ -28,22 +29,22 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct PluginVersion { - /// - /// SDK version the plugin is compatible with (offset 0): write . - /// - /// - /// The explanatory note next to this field in the C header still talks about versions 1 and 2; it predates - /// the current value of the version constant in the same file and is not a constraint. - /// - public uint Version; + /// + /// SDK version the plugin is compatible with (offset 0): write . + /// + /// + /// The explanatory note next to this field in the C header still talks about versions 1 and 2; it predates + /// the current value of the version constant in the same file and is not a constraint. + /// + public uint Version; - /// - /// NUL-terminated ANSI display name of the plugin (offset 8). - /// - /// - /// Ownership: the plugin's. The pointer must outlive the call (static or never-freed native memory, not a - /// stack buffer, not a pinned managed array). On the managed path use the same buffer as - /// . - /// - public byte* PluginName; + /// + /// NUL-terminated ANSI display name of the plugin (offset 8). + /// + /// + /// Ownership: the plugin's. The pointer must outlive the call (static or never-freed native memory, not a + /// stack buffer, not a pinned managed array). On the managed path use the same buffer as + /// . + /// + public byte* PluginName; } diff --git a/libs/CheatEngine.SDK.Abi/Native/ProcessWatcherPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/ProcessWatcherPluginInit.cs index 301372f3..f60c2c3a 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ProcessWatcherPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ProcessWatcherPluginInit.cs @@ -24,10 +24,10 @@ namespace CheatEngine.SDK.Abi.Native; [StructLayout(LayoutKind.Sequential)] public unsafe struct ProcessWatcherPluginInit { - /// Address of the stdcall callback (offset 0). Untyped, see the type remarks. - /// - /// Runs on a thread other than the main thread (stated by the official C sample plugin): no GUI work. Must - /// stay valid until the function is unregistered. Must not let an exception escape. - /// - public void* Callback; + /// Address of the stdcall callback (offset 0). Untyped, see the type remarks. + /// + /// Runs on a thread other than the main thread (stated by the official C sample plugin): no GUI work. Must + /// stay valid until the function is unregistered. Must not let an exception escape. + /// + public void* Callback; } diff --git a/libs/CheatEngine.SDK.Abi/Native/RegisterModificationInfo.cs b/libs/CheatEngine.SDK.Abi/Native/RegisterModificationInfo.cs index 6d554785..cf569ee3 100644 --- a/libs/CheatEngine.SDK.Abi/Native/RegisterModificationInfo.cs +++ b/libs/CheatEngine.SDK.Abi/Native/RegisterModificationInfo.cs @@ -26,149 +26,149 @@ namespace CheatEngine.SDK.Abi.Native; /// /// [SuppressMessage("Meziantou.Analyzer", "MA0182", - Justification = - "This intentionally internal C-header mirror is the register-change function-pointer argument in ExportedFunctionsPrefix, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains internal until a safe facade owns this dangerous host-call contract.")] + Justification = + "This intentionally internal C-header mirror is the register-change function-pointer argument in ExportedFunctionsPrefix, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains internal until a safe facade owns this dangerous host-call contract.")] [StructLayout(LayoutKind.Sequential)] internal struct RegisterModificationInfo { - /// Address at which Cheat Engine should apply the requested changes (offset 0 on x64). - public nuint Address; + /// Address at which Cheat Engine should apply the requested changes (offset 0 on x64). + public nuint Address; - /// Whether to replace the EAX register (offset 8 on x64). - public Bool32 ChangeEax; + /// Whether to replace the EAX register (offset 8 on x64). + public Bool32 ChangeEax; - /// Whether to replace the EBX register (offset 12 on x64). - public Bool32 ChangeEbx; + /// Whether to replace the EBX register (offset 12 on x64). + public Bool32 ChangeEbx; - /// Whether to replace the ECX register (offset 16 on x64). - public Bool32 ChangeEcx; + /// Whether to replace the ECX register (offset 16 on x64). + public Bool32 ChangeEcx; - /// Whether to replace the EDX register (offset 20 on x64). - public Bool32 ChangeEdx; + /// Whether to replace the EDX register (offset 20 on x64). + public Bool32 ChangeEdx; - /// Whether to replace the ESI register (offset 24 on x64). - public Bool32 ChangeEsi; + /// Whether to replace the ESI register (offset 24 on x64). + public Bool32 ChangeEsi; - /// Whether to replace the EDI register (offset 28 on x64). - public Bool32 ChangeEdi; + /// Whether to replace the EDI register (offset 28 on x64). + public Bool32 ChangeEdi; - /// Whether to replace the EBP register (offset 32 on x64). - public Bool32 ChangeEbp; + /// Whether to replace the EBP register (offset 32 on x64). + public Bool32 ChangeEbp; - /// Whether to replace the ESP register (offset 36 on x64). - public Bool32 ChangeEsp; + /// Whether to replace the ESP register (offset 36 on x64). + public Bool32 ChangeEsp; - /// Whether to replace the EIP register (offset 40 on x64). - public Bool32 ChangeEip; + /// Whether to replace the EIP register (offset 40 on x64). + public Bool32 ChangeEip; - /// Whether to replace the R8 register (offset 44 on x64). - public Bool32 ChangeR8; + /// Whether to replace the R8 register (offset 44 on x64). + public Bool32 ChangeR8; - /// Whether to replace the R9 register (offset 48 on x64). - public Bool32 ChangeR9; + /// Whether to replace the R9 register (offset 48 on x64). + public Bool32 ChangeR9; - /// Whether to replace the R10 register (offset 52 on x64). - public Bool32 ChangeR10; + /// Whether to replace the R10 register (offset 52 on x64). + public Bool32 ChangeR10; - /// Whether to replace the R11 register (offset 56 on x64). - public Bool32 ChangeR11; + /// Whether to replace the R11 register (offset 56 on x64). + public Bool32 ChangeR11; - /// Whether to replace the R12 register (offset 60 on x64). - public Bool32 ChangeR12; + /// Whether to replace the R12 register (offset 60 on x64). + public Bool32 ChangeR12; - /// Whether to replace the R13 register (offset 64 on x64). - public Bool32 ChangeR13; + /// Whether to replace the R13 register (offset 64 on x64). + public Bool32 ChangeR13; - /// Whether to replace the R14 register (offset 68 on x64). - public Bool32 ChangeR14; + /// Whether to replace the R14 register (offset 68 on x64). + public Bool32 ChangeR14; - /// Whether to replace the R15 register (offset 72 on x64). - public Bool32 ChangeR15; + /// Whether to replace the R15 register (offset 72 on x64). + public Bool32 ChangeR15; - /// Whether to replace the carry flag (offset 76 on x64). - public Bool32 ChangeCf; + /// Whether to replace the carry flag (offset 76 on x64). + public Bool32 ChangeCf; - /// Whether to replace the parity flag (offset 80 on x64). - public Bool32 ChangePf; + /// Whether to replace the parity flag (offset 80 on x64). + public Bool32 ChangePf; - /// Whether to replace the auxiliary carry flag (offset 84 on x64). - public Bool32 ChangeAf; + /// Whether to replace the auxiliary carry flag (offset 84 on x64). + public Bool32 ChangeAf; - /// Whether to replace the zero flag (offset 88 on x64). - public Bool32 ChangeZf; + /// Whether to replace the zero flag (offset 88 on x64). + public Bool32 ChangeZf; - /// Whether to replace the sign flag (offset 92 on x64). - public Bool32 ChangeSf; + /// Whether to replace the sign flag (offset 92 on x64). + public Bool32 ChangeSf; - /// Whether to replace the overflow flag (offset 96 on x64). - public Bool32 ChangeOf; + /// Whether to replace the overflow flag (offset 96 on x64). + public Bool32 ChangeOf; - /// New EAX value (offset 104 on x64). - public nuint NewEax; + /// New EAX value (offset 104 on x64). + public nuint NewEax; - /// New EBX value (offset 112 on x64). - public nuint NewEbx; + /// New EBX value (offset 112 on x64). + public nuint NewEbx; - /// New ECX value (offset 120 on x64). - public nuint NewEcx; + /// New ECX value (offset 120 on x64). + public nuint NewEcx; - /// New EDX value (offset 128 on x64). - public nuint NewEdx; + /// New EDX value (offset 128 on x64). + public nuint NewEdx; - /// New ESI value (offset 136 on x64). - public nuint NewEsi; + /// New ESI value (offset 136 on x64). + public nuint NewEsi; - /// New EDI value (offset 144 on x64). - public nuint NewEdi; + /// New EDI value (offset 144 on x64). + public nuint NewEdi; - /// New EBP value (offset 152 on x64). - public nuint NewEbp; + /// New EBP value (offset 152 on x64). + public nuint NewEbp; - /// New ESP value (offset 160 on x64). - public nuint NewEsp; + /// New ESP value (offset 160 on x64). + public nuint NewEsp; - /// New EIP value (offset 168 on x64). - public nuint NewEip; + /// New EIP value (offset 168 on x64). + public nuint NewEip; - /// New R8 value (offset 176 on x64). - public nuint NewR8; + /// New R8 value (offset 176 on x64). + public nuint NewR8; - /// New R9 value (offset 184 on x64). - public nuint NewR9; + /// New R9 value (offset 184 on x64). + public nuint NewR9; - /// New R10 value (offset 192 on x64). - public nuint NewR10; + /// New R10 value (offset 192 on x64). + public nuint NewR10; - /// New R11 value (offset 200 on x64). - public nuint NewR11; + /// New R11 value (offset 200 on x64). + public nuint NewR11; - /// New R12 value (offset 208 on x64). - public nuint NewR12; + /// New R12 value (offset 208 on x64). + public nuint NewR12; - /// New R13 value (offset 216 on x64). - public nuint NewR13; + /// New R13 value (offset 216 on x64). + public nuint NewR13; - /// New R14 value (offset 224 on x64). - public nuint NewR14; + /// New R14 value (offset 224 on x64). + public nuint NewR14; - /// New R15 value (offset 232 on x64). - public nuint NewR15; + /// New R15 value (offset 232 on x64). + public nuint NewR15; - /// New carry flag (offset 240 on x64). - public Bool32 NewCf; + /// New carry flag (offset 240 on x64). + public Bool32 NewCf; - /// New parity flag (offset 244 on x64). - public Bool32 NewPf; + /// New parity flag (offset 244 on x64). + public Bool32 NewPf; - /// New auxiliary carry flag (offset 248 on x64). - public Bool32 NewAf; + /// New auxiliary carry flag (offset 248 on x64). + public Bool32 NewAf; - /// New zero flag (offset 252 on x64). - public Bool32 NewZf; + /// New zero flag (offset 252 on x64). + public Bool32 NewZf; - /// New sign flag (offset 256 on x64). - public Bool32 NewSf; + /// New sign flag (offset 256 on x64). + public Bool32 NewSf; - /// New overflow flag (offset 260 on x64). - public Bool32 NewOf; + /// New overflow flag (offset 260 on x64). + public Bool32 NewOf; } diff --git a/libs/CheatEngine.SDK.Abi/README.md b/libs/CheatEngine.SDK.Abi/README.md index 4163a169..8b337c92 100644 --- a/libs/CheatEngine.SDK.Abi/README.md +++ b/libs/CheatEngine.SDK.Abi/README.md @@ -68,7 +68,8 @@ The source index records the installed-file hashes reviewed for the historic CE compiled fixture is deliberately more limited: it compiles a checked-in transcription of the pinned upstream C-header subset under MSVC x64, validates 104 facts, and compares its `sizeof`, `offsetof`, alignment, export, and topology facts with a versioned expectation. The native CI job also passes that facts file into a compiled managed test, which measures -the matching managed record sizes, offsets, and alignments directly. It is therefore a `compiled-transcription-fixture` proof, not proof that a live CE host +the matching managed record sizes, offsets, and alignments directly. It is therefore a `compiled-transcription-fixture` +proof, not proof that a live CE host loads a slot, uses a given Pascal boolean width, or provides a non-null table entry. The records stay internal until a dedicated owning facade and exact-host canary exist. diff --git a/libs/CheatEngine.SDK.Annotations/Lifetime/RequiresPluginEnabledAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lifetime/RequiresPluginEnabledAttribute.cs index ffb19f28..5fb31fdd 100644 --- a/libs/CheatEngine.SDK.Annotations/Lifetime/RequiresPluginEnabledAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lifetime/RequiresPluginEnabledAttribute.cs @@ -40,9 +40,9 @@ namespace CheatEngine.SDK.Annotations.Lifetime; /// /// [AttributeUsage( - AttributeTargets.Method - | AttributeTargets.Property - | AttributeTargets.Constructor - | AttributeTargets.Class - | AttributeTargets.Struct)] + AttributeTargets.Method + | AttributeTargets.Property + | AttributeTargets.Constructor + | AttributeTargets.Class + | AttributeTargets.Struct)] public sealed class RequiresPluginEnabledAttribute : Attribute; diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs index c84141f5..f79aa8c6 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs @@ -30,28 +30,31 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Struct, Inherited = false)] public sealed class LuaClassAttribute : Attribute { - /// - /// Initializes the attribute with the name of the wrapped Cheat Engine class. - /// - /// - /// The class name as Cheat Engine's Lua object model spells it, for example MemScan. Must not be - /// or empty. - /// - /// is . - /// is empty. - /// - /// The constructor only runs when something materialises the attribute through reflection. The compiler stores - /// the argument without executing this check, so [LuaClass(null!)] and [LuaClass("")] compile; a - /// generator reads a constant or an empty string and has to validate the name itself. - /// - public LuaClassAttribute(string name) - { - ArgumentException.ThrowIfNullOrEmpty(name); - Name = name; - } + /// + /// Initializes the attribute with the name of the wrapped Cheat Engine class. + /// + /// + /// The class name as Cheat Engine's Lua object model spells it, for example MemScan. Must not be + /// or empty. + /// + /// is . + /// is empty. + /// + /// The constructor only runs when something materialises the attribute through reflection. The compiler stores + /// the argument without executing this check, so [LuaClass(null!)] and [LuaClass("")] compile; a + /// generator reads a constant or an empty string and has to validate the name itself. + /// + public LuaClassAttribute(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + } - /// - /// Gets the name of the wrapped Cheat Engine class. Never or empty. - /// - public string Name { get; } + /// + /// Gets the name of the wrapped Cheat Engine class. Never or empty. + /// + public string Name + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaFunctionAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaFunctionAttribute.cs index f57854f7..edfce61b 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaFunctionAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaFunctionAttribute.cs @@ -41,29 +41,32 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class LuaFunctionAttribute : Attribute { - /// - /// Initializes the attribute with the name of the Lua global that receives the function. - /// - /// - /// The global name, exactly as Lua scripts will spell it (Lua names are case-sensitive). Must not be - /// or empty. - /// - /// is . - /// is empty. - /// - /// The constructor only runs when something materialises the attribute through reflection. The compiler stores - /// the argument without executing this check, so [LuaFunction(null!)] and [LuaFunction("")] compile; - /// a generator reads a constant or an empty string and has to validate the name itself. - /// - public LuaFunctionAttribute(string name) - { - ArgumentException.ThrowIfNullOrEmpty(name); - Name = name; - } + /// + /// Initializes the attribute with the name of the Lua global that receives the function. + /// + /// + /// The global name, exactly as Lua scripts will spell it (Lua names are case-sensitive). Must not be + /// or empty. + /// + /// is . + /// is empty. + /// + /// The constructor only runs when something materialises the attribute through reflection. The compiler stores + /// the argument without executing this check, so [LuaFunction(null!)] and [LuaFunction("")] compile; + /// a generator reads a constant or an empty string and has to validate the name itself. + /// + public LuaFunctionAttribute(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + } - /// - /// Gets the name of the Lua global under which the function is registered. Never or - /// empty. - /// - public string Name { get; } + /// + /// Gets the name of the Lua global under which the function is registered. Never or + /// empty. + /// + public string Name + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaGlobalAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaGlobalAttribute.cs index d9c56e0b..15eb2535 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaGlobalAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaGlobalAttribute.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; @@ -41,28 +42,31 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class LuaGlobalAttribute : Attribute { - /// - /// Initializes the attribute with the name of the Lua global to bind. - /// - /// - /// The global name, spelled exactly as Cheat Engine registers it (Lua names are case-sensitive; the external - /// spelling is contract, for example readInteger). Must not be or empty. - /// - /// is . - /// is empty. - /// - /// The constructor only runs when something materialises the attribute through reflection. The compiler stores - /// the argument without executing this check, so [LuaGlobal(null!)] and [LuaGlobal("")] compile; a - /// generator reads a constant or an empty string and has to validate the name itself. - /// - public LuaGlobalAttribute(string name) - { - ArgumentException.ThrowIfNullOrEmpty(name); - Name = name; - } + /// + /// Initializes the attribute with the name of the Lua global to bind. + /// + /// + /// The global name, spelled exactly as Cheat Engine registers it (Lua names are case-sensitive; the external + /// spelling is contract, for example readInteger). Must not be or empty. + /// + /// is . + /// is empty. + /// + /// The constructor only runs when something materialises the attribute through reflection. The compiler stores + /// the argument without executing this check, so [LuaGlobal(null!)] and [LuaGlobal("")] compile; a + /// generator reads a constant or an empty string and has to validate the name itself. + /// + public LuaGlobalAttribute(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + } - /// - /// Gets the name of the bound Lua global. Never or empty. - /// - public string Name { get; } + /// + /// Gets the name of the bound Lua global. Never or empty. + /// + public string Name + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs index eea5f42f..8df6b4c5 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs @@ -23,15 +23,18 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] public sealed class LuaMarshallerAttribute : Attribute { - /// Initializes the attribute with the concrete static marshaller type. - /// A type implementing ILuaMarshaller<T> for the annotated value. - /// is . - public LuaMarshallerAttribute(Type marshallerType) - { - ArgumentNullException.ThrowIfNull(marshallerType); - MarshallerType = marshallerType; - } + /// Initializes the attribute with the concrete static marshaller type. + /// A type implementing ILuaMarshaller<T> for the annotated value. + /// is . + public LuaMarshallerAttribute(Type marshallerType) + { + ArgumentNullException.ThrowIfNull(marshallerType); + MarshallerType = marshallerType; + } - /// Gets the concrete marshaller type named by this declaration. - public Type MarshallerType { get; } + /// Gets the concrete marshaller type named by this declaration. + public Type MarshallerType + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaMethodAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaMethodAttribute.cs index 074cacd8..126e6e29 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaMethodAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaMethodAttribute.cs @@ -30,28 +30,31 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class LuaMethodAttribute : Attribute { - /// - /// Initializes the attribute with the name of the Cheat Engine method to bind. - /// - /// - /// The method name as Cheat Engine's Lua object model spells it, for example firstScan; the external - /// spelling is contract. Must not be or empty. - /// - /// is . - /// is empty. - /// - /// The constructor only runs when something materialises the attribute through reflection. The compiler stores - /// the argument without executing this check, so [LuaMethod(null!)] and [LuaMethod("")] compile; a - /// generator reads a constant or an empty string and has to validate the name itself. - /// - public LuaMethodAttribute(string name) - { - ArgumentException.ThrowIfNullOrEmpty(name); - Name = name; - } + /// + /// Initializes the attribute with the name of the Cheat Engine method to bind. + /// + /// + /// The method name as Cheat Engine's Lua object model spells it, for example firstScan; the external + /// spelling is contract. Must not be or empty. + /// + /// is . + /// is empty. + /// + /// The constructor only runs when something materialises the attribute through reflection. The compiler stores + /// the argument without executing this check, so [LuaMethod(null!)] and [LuaMethod("")] compile; a + /// generator reads a constant or an empty string and has to validate the name itself. + /// + public LuaMethodAttribute(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + } - /// - /// Gets the name of the bound Cheat Engine method. Never or empty. - /// - public string Name { get; } + /// + /// Gets the name of the bound Cheat Engine method. Never or empty. + /// + public string Name + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs index 8cf871e3..17145ed5 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs @@ -29,28 +29,31 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Property, Inherited = false)] public sealed class LuaPropertyAttribute : Attribute { - /// - /// Initializes the attribute with the name of the Cheat Engine property to bind. - /// - /// - /// The property name as Cheat Engine's Lua object model spells it, for example Count; the external - /// spelling is contract. Must not be or empty. - /// - /// is . - /// is empty. - /// - /// The constructor only runs when something materialises the attribute through reflection. The compiler stores - /// the argument without executing this check, so [LuaProperty(null!)] and [LuaProperty("")] compile; - /// a generator reads a constant or an empty string and has to validate the name itself. - /// - public LuaPropertyAttribute(string name) - { - ArgumentException.ThrowIfNullOrEmpty(name); - Name = name; - } + /// + /// Initializes the attribute with the name of the Cheat Engine property to bind. + /// + /// + /// The property name as Cheat Engine's Lua object model spells it, for example Count; the external + /// spelling is contract. Must not be or empty. + /// + /// is . + /// is empty. + /// + /// The constructor only runs when something materialises the attribute through reflection. The compiler stores + /// the argument without executing this check, so [LuaProperty(null!)] and [LuaProperty("")] compile; + /// a generator reads a constant or an empty string and has to validate the name itself. + /// + public LuaPropertyAttribute(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + } - /// - /// Gets the name of the bound Cheat Engine property. Never or empty. - /// - public string Name { get; } + /// + /// Gets the name of the bound Cheat Engine property. Never or empty. + /// + public string Name + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaStackEffectAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaStackEffectAttribute.cs index 5aa09dec..58986138 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaStackEffectAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaStackEffectAttribute.cs @@ -45,8 +45,11 @@ namespace CheatEngine.SDK.Annotations.Lua; [AttributeUsage(AttributeTargets.Method)] public sealed class LuaStackEffectAttribute(int delta) : Attribute { - /// - /// Gets the net change in stack height: slots after the call minus slots before it. - /// - public int Delta { get; } = delta; + /// + /// Gets the net change in stack height: slots after the call minus slots before it. + /// + public int Delta + { + get; + } = delta; } diff --git a/libs/CheatEngine.SDK.Annotations/Plugin/CheatEnginePluginAttribute.cs b/libs/CheatEngine.SDK.Annotations/Plugin/CheatEnginePluginAttribute.cs index 14ee4685..eb04c75a 100644 --- a/libs/CheatEngine.SDK.Annotations/Plugin/CheatEnginePluginAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Plugin/CheatEnginePluginAttribute.cs @@ -39,30 +39,33 @@ namespace CheatEngine.SDK.Annotations.Plugin; [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class CheatEnginePluginAttribute : Attribute { - /// - /// Initializes the attribute with the name the plugin reports to Cheat Engine. - /// - /// - /// The display name of the plugin. Must not be or empty. Prefer ASCII: how Cheat Engine - /// decodes other characters in a plugin name has not been verified. - /// - /// is . - /// is empty. - /// - /// The constructor only runs when something materialises the attribute through reflection. The compiler stores - /// the argument without executing this check, and generators and analyzers read it from there, so - /// [CheatEnginePlugin(null!)] and [CheatEnginePlugin("")] compile. The consumers validate the - /// argument themselves: a , empty or blank name is reported as CESDK0001 and no entry point - /// is generated for it. - /// - public CheatEnginePluginAttribute(string name) - { - ArgumentException.ThrowIfNullOrEmpty(name); - Name = name; - } + /// + /// Initializes the attribute with the name the plugin reports to Cheat Engine. + /// + /// + /// The display name of the plugin. Must not be or empty. Prefer ASCII: how Cheat Engine + /// decodes other characters in a plugin name has not been verified. + /// + /// is . + /// is empty. + /// + /// The constructor only runs when something materialises the attribute through reflection. The compiler stores + /// the argument without executing this check, and generators and analyzers read it from there, so + /// [CheatEnginePlugin(null!)] and [CheatEnginePlugin("")] compile. The consumers validate the + /// argument themselves: a , empty or blank name is reported as CESDK0001 and no entry point + /// is generated for it. + /// + public CheatEnginePluginAttribute(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + } - /// - /// Gets the name the plugin reports to Cheat Engine. Never or empty. - /// - public string Name { get; } + /// + /// Gets the name the plugin reports to Cheat Engine. Never or empty. + /// + public string Name + { + get; + } } diff --git a/libs/CheatEngine.SDK.Annotations/Threading/MainThreadOnlyAttribute.cs b/libs/CheatEngine.SDK.Annotations/Threading/MainThreadOnlyAttribute.cs index 173cdc2f..4d735d5a 100644 --- a/libs/CheatEngine.SDK.Annotations/Threading/MainThreadOnlyAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Threading/MainThreadOnlyAttribute.cs @@ -41,10 +41,10 @@ namespace CheatEngine.SDK.Annotations.Threading; /// /// [AttributeUsage( - AttributeTargets.Method - | AttributeTargets.Property - | AttributeTargets.Constructor - | AttributeTargets.Class - | AttributeTargets.Struct - | AttributeTargets.Interface)] + AttributeTargets.Method + | AttributeTargets.Property + | AttributeTargets.Constructor + | AttributeTargets.Class + | AttributeTargets.Struct + | AttributeTargets.Interface)] public sealed class MainThreadOnlyAttribute : Attribute; diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressList.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressList.cs index 7fce3789..9d0b3673 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressList.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressList.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Objects; @@ -37,151 +38,154 @@ namespace CheatEngine.SDK.Engine.AddressList; /// Wraps an untyped Cheat Engine object handle without validating its runtime class. /// The handle; gives . [SuppressMessage("Meziantou.Analyzer", "MA0049", - Justification = - "The namespace groups the address-list API, while this type mirrors Cheat Engine's Addresslist class.")] + Justification = + "The namespace groups the address-list API, while this type mirrors Cheat Engine's Addresslist class.")] public readonly struct AddressList(CEObject handle) : IEquatable, ICEObject, - ILuaMarshaller + ILuaMarshaller { - /// Gets the handle that names no address list. - public static AddressList Null => default; - - /// - public CEObject Handle { get; } = handle; - - /// Gets a value indicating whether this value names no address list. - public bool IsNull => Handle.IsNull; - - /// - public static AddressList FromHandle(CEObject handle) - { - return new AddressList(handle); - } - - /// Tests two address-list handles for native-object identity. - /// The first handle. - /// The second handle. - /// when both handles name the same object. - public static bool operator ==(AddressList left, AddressList right) - { - return left.Handle == right.Handle; - } - - /// Tests two address-list handles for native-object inequality. - /// The first handle. - /// The second handle. - /// when the handles name different objects. - public static bool operator !=(AddressList left, AddressList right) - { - return !(left == right); - } - - /// - public bool Equals(AddressList other) - { - return this == other; - } - - /// - public override bool Equals(object? obj) - { - return obj is AddressList other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Handle.GetHashCode(); - } - - /// Formats the underlying native-object identity for diagnostics. - /// AddressList(CEObject@0x...), or AddressList(null). - public override string ToString() - { - return IsNull ? "AddressList(null)" : "AddressList(" + Handle + ")"; - } - - /// - [LuaStackEffect(1)] - public static void Push(LuaState state, AddressList value) - { - value.Handle.Push(state); - } - - /// - [LuaStackEffect(0)] - public static bool TryRead(LuaState state, int index, out AddressList value) - { - if (CEObject.TryRead(state, index, out var handle)) - { - value = new AddressList(handle); - return true; - } - - value = default; - return false; - } - - /// Gets the number of top-level records through CE's getCount() method. - /// The count; 0 on failure. - /// when CE returned a 32-bit integer. - [RequiresPluginEnabled] - public bool TryGetCount(out int count) - { - return Handle.TryCallMethod("getCount"u8, out count); - } - - /// Gets a record by its zero-based address-list position. - /// The index CE's address-list object uses; 0 is the first record. - /// A borrowed, Cheat-Engine-owned record; default on failure. - /// when a record was returned. - /// is negative. - [RequiresPluginEnabled] - public bool TryGetMemoryRecord(int zeroBasedIndex, [CEOwned] out MemoryRecord record) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - return AddressListCalls.TryCall(Handle, "getMemoryRecord"u8, - zeroBasedIndex, out record); - } - - /// Gets a record by CE's unique memory-record identifier. - /// The identifier from . - /// A borrowed, Cheat-Engine-owned record; default when no matching record exists. - /// when CE returned a record rather than nil. - [RequiresPluginEnabled] - public bool TryGetMemoryRecordById(MemoryRecordId id, [CEOwned] out MemoryRecord record) - { - return AddressListCalls.TryCall(Handle, - "getMemoryRecordByID"u8, id, out record); - } - - /// Gets the main selected record. - /// A borrowed, Cheat-Engine-owned record; default when no record is selected. - /// when CE returned a record rather than nil. - [RequiresPluginEnabled] - public bool TryGetSelectedRecord([CEOwned] out MemoryRecord record) - { - return Handle.TryCallMethod("getSelectedRecord"u8, out record); - } - - /// Sets CE's main selected record and clears other selections as CE specifies. - /// The borrowed record to select. - /// when CE completed the call without a protected Lua error. - [RequiresPluginEnabled] - public bool TrySetSelectedRecord([CEOwned] MemoryRecord record) - { - return AddressListCalls.TryCall(Handle, "setSelectedRecord"u8, record); - } - - /// Creates a generic record and adds it to this address list. - /// The newly added, borrowed, Cheat-Engine-owned record; default on failure. - /// when CE returned the added record rather than nil. - /// - /// CE attaches this object to the address list during creation. It is deliberately not returned as - /// : destroying it independently would leave the address list with a dangling object. - /// - [RequiresPluginEnabled] - public bool TryCreateMemoryRecord([CEOwned] out MemoryRecord record) - { - return Handle.TryCallMethod("createMemoryRecord"u8, out record); - } + /// Gets the handle that names no address list. + public static AddressList Null => default; + + /// + public CEObject Handle + { + get; + } = handle; + + /// Gets a value indicating whether this value names no address list. + public bool IsNull => Handle.IsNull; + + /// + public static AddressList FromHandle(CEObject handle) + { + return new AddressList(handle); + } + + /// Tests two address-list handles for native-object identity. + /// The first handle. + /// The second handle. + /// when both handles name the same object. + public static bool operator ==(AddressList left, AddressList right) + { + return left.Handle == right.Handle; + } + + /// Tests two address-list handles for native-object inequality. + /// The first handle. + /// The second handle. + /// when the handles name different objects. + public static bool operator !=(AddressList left, AddressList right) + { + return !(left == right); + } + + /// + public bool Equals(AddressList other) + { + return this == other; + } + + /// + public override bool Equals(object? obj) + { + return obj is AddressList other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Handle.GetHashCode(); + } + + /// Formats the underlying native-object identity for diagnostics. + /// AddressList(CEObject@0x...), or AddressList(null). + public override string ToString() + { + return IsNull ? "AddressList(null)" : "AddressList(" + Handle + ")"; + } + + /// + [LuaStackEffect(1)] + public static void Push(LuaState state, AddressList value) + { + value.Handle.Push(state); + } + + /// + [LuaStackEffect(0)] + public static bool TryRead(LuaState state, int index, out AddressList value) + { + if (CEObject.TryRead(state, index, out CEObject handle)) + { + value = new AddressList(handle); + return true; + } + + value = default; + return false; + } + + /// Gets the number of top-level records through CE's getCount() method. + /// The count; 0 on failure. + /// when CE returned a 32-bit integer. + [RequiresPluginEnabled] + public bool TryGetCount(out int count) + { + return Handle.TryCallMethod("getCount"u8, out count); + } + + /// Gets a record by its zero-based address-list position. + /// The index CE's address-list object uses; 0 is the first record. + /// A borrowed, Cheat-Engine-owned record; default on failure. + /// when a record was returned. + /// is negative. + [RequiresPluginEnabled] + public bool TryGetMemoryRecord(int zeroBasedIndex, [CEOwned] out MemoryRecord record) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + return AddressListCalls.TryCall(Handle, "getMemoryRecord"u8, + zeroBasedIndex, out record); + } + + /// Gets a record by CE's unique memory-record identifier. + /// The identifier from . + /// A borrowed, Cheat-Engine-owned record; default when no matching record exists. + /// when CE returned a record rather than nil. + [RequiresPluginEnabled] + public bool TryGetMemoryRecordById(MemoryRecordId id, [CEOwned] out MemoryRecord record) + { + return AddressListCalls.TryCall(Handle, + "getMemoryRecordByID"u8, id, out record); + } + + /// Gets the main selected record. + /// A borrowed, Cheat-Engine-owned record; default when no record is selected. + /// when CE returned a record rather than nil. + [RequiresPluginEnabled] + public bool TryGetSelectedRecord([CEOwned] out MemoryRecord record) + { + return Handle.TryCallMethod("getSelectedRecord"u8, out record); + } + + /// Sets CE's main selected record and clears other selections as CE specifies. + /// The borrowed record to select. + /// when CE completed the call without a protected Lua error. + [RequiresPluginEnabled] + public bool TrySetSelectedRecord([CEOwned] MemoryRecord record) + { + return AddressListCalls.TryCall(Handle, "setSelectedRecord"u8, record); + } + + /// Creates a generic record and adds it to this address list. + /// The newly added, borrowed, Cheat-Engine-owned record; default on failure. + /// when CE returned the added record rather than nil. + /// + /// CE attaches this object to the address list during creation. It is deliberately not returned as + /// : destroying it independently would leave the address list with a dangling object. + /// + [RequiresPluginEnabled] + public bool TryCreateMemoryRecord([CEOwned] out MemoryRecord record) + { + return Handle.TryCallMethod("createMemoryRecord"u8, out record); + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListAccess.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListAccess.cs index e37b0fc8..1d43571a 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListAccess.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListAccess.cs @@ -11,24 +11,24 @@ namespace CheatEngine.SDK.Engine.AddressList; /// public static class AddressListAccess { - private static readonly LuaRef SGetAddressList = new(); + private static readonly LuaRef SGetAddressList = new(); - /// Gets Cheat Engine's address-list object. - /// A borrowed, Cheat-Engine-owned address list; default on failure. - /// - /// when the getAddressList global resolved, completed, and returned host userdata; - /// otherwise . - /// - /// - /// Evidence: exact installed CE 7.7.0.10621 x64 celua.txt, SHA-256 - /// AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00, line 774. The GUI-thread restriction is - /// inferred from the returned Addresslist panel. This wrapper deliberately does not annotate the call as - /// MainThreadOnly until a CE 7.7 dispatcher probe establishes an enforceable check. - /// - [RequiresPluginEnabled] - public static bool TryGetCurrent([CEOwned] out AddressList addressList) - { - return AddressListCalls.TryGetGlobal(SGetAddressList, "getAddressList"u8, - out addressList); - } + /// Gets Cheat Engine's address-list object. + /// A borrowed, Cheat-Engine-owned address list; default on failure. + /// + /// when the getAddressList global resolved, completed, and returned host userdata; + /// otherwise . + /// + /// + /// Evidence: exact installed CE 7.7.0.10621 x64 celua.txt, SHA-256 + /// AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00, line 774. The GUI-thread restriction is + /// inferred from the returned Addresslist panel. This wrapper deliberately does not annotate the call as + /// MainThreadOnly until a CE 7.7 dispatcher probe establishes an enforceable check. + /// + [RequiresPluginEnabled] + public static bool TryGetCurrent([CEOwned] out AddressList addressList) + { + return AddressListCalls.TryGetGlobal(SGetAddressList, "getAddressList"u8, + out addressList); + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs index 6c509536..ca6eaa62 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs @@ -1,11 +1,13 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; namespace CheatEngine.SDK.Engine.AddressList; @@ -20,108 +22,119 @@ namespace CheatEngine.SDK.Engine.AddressList; /// internal static class AddressListCalls { - public static bool TryGetGlobal(LuaRef cache, ReadOnlySpan global, - [MaybeNullWhen(false)] out TResult result) - where TMarshaller : struct, ILuaMarshaller - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!LuaGlobalFunctions.TryPush(state, cache, global) || !state.TryCall(0, 1).IsOk || - !TMarshaller.TryRead(state, -1, out result)) - return LuaCallSupport.Fail(state, top, out result); + public static bool TryGetGlobal(LuaRef cache, ReadOnlySpan global, + [MaybeNullWhen(false)] out TResult result) + where TMarshaller : struct, ILuaMarshaller + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!LuaGlobalFunctions.TryPush(state, cache, global) || !state.TryCall(0, 1).IsOk || + !TMarshaller.TryRead(state, -1, out result)) + { + return LuaCallSupport.Fail(state, top, out result); + } - return true; - } - catch (LuaException) - { - result = default; - return false; - } - finally - { - state.SetTop(top); - } - } + return true; + } + catch (LuaException) + { + result = default; + return false; + } + finally + { + state.SetTop(top); + } + } - public static bool TryCall(CEObject receiver, - ReadOnlySpan method, TArgument argument, [MaybeNullWhen(false)] out TResult result) - where TArgumentMarshaller : struct, ILuaMarshaller - where TResultMarshaller : struct, ILuaMarshaller - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!receiver.TryPushMethodLeavingObject(state, method).IsOk) - return LuaCallSupport.Fail(state, top, out result); + public static bool TryCall(CEObject receiver, + ReadOnlySpan method, TArgument argument, [MaybeNullWhen(false)] out TResult result) + where TArgumentMarshaller : struct, ILuaMarshaller + where TResultMarshaller : struct, ILuaMarshaller + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!receiver.TryPushMethodLeavingObject(state, method).IsOk) + { + return LuaCallSupport.Fail(state, top, out result); + } - TArgumentMarshaller.Push(state, argument); - if (!state.TryCall(1, 1).IsOk || !TResultMarshaller.TryRead(state, -1, out result)) - return LuaCallSupport.Fail(state, top, out result); + TArgumentMarshaller.Push(state, argument); + if (!state.TryCall(1, 1).IsOk || !TResultMarshaller.TryRead(state, -1, out result)) + { + return LuaCallSupport.Fail(state, top, out result); + } - return true; - } - catch (LuaException) - { - result = default; - return false; - } - finally - { - state.SetTop(top); - } - } + return true; + } + catch (LuaException) + { + result = default; + return false; + } + finally + { + state.SetTop(top); + } + } - public static bool TryCall(CEObject receiver, ReadOnlySpan method, - TArgument argument) - where TArgumentMarshaller : struct, ILuaMarshaller - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!receiver.TryPushMethodLeavingObject(state, method).IsOk) return LuaCallSupport.Fail(state, top); + public static bool TryCall(CEObject receiver, ReadOnlySpan method, + TArgument argument) + where TArgumentMarshaller : struct, ILuaMarshaller + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!receiver.TryPushMethodLeavingObject(state, method).IsOk) + { + return LuaCallSupport.Fail(state, top); + } - TArgumentMarshaller.Push(state, argument); - return state.TryCall(1, 0).IsOk || LuaCallSupport.Fail(state, top); - } - catch (LuaException) - { - return false; - } - finally - { - state.SetTop(top); - } - } + TArgumentMarshaller.Push(state, argument); + return state.TryCall(1, 0).IsOk || LuaCallSupport.Fail(state, top); + } + catch (LuaException) + { + return false; + } + finally + { + state.SetTop(top); + } + } - public static bool TryGetIndex(CEObject receiver, int zeroBasedIndex, - [MaybeNullWhen(false)] out TResult result) - where TMarshaller : struct, ILuaMarshaller - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!receiver.TryGetIndex(state, zeroBasedIndex).IsOk || !TMarshaller.TryRead(state, -1, out result)) - return LuaCallSupport.Fail(state, top, out result); + public static bool TryGetIndex(CEObject receiver, int zeroBasedIndex, + [MaybeNullWhen(false)] out TResult result) + where TMarshaller : struct, ILuaMarshaller + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!receiver.TryGetIndex(state, zeroBasedIndex).IsOk || !TMarshaller.TryRead(state, -1, out result)) + { + return LuaCallSupport.Fail(state, top, out result); + } - return true; - } - catch (LuaException) - { - result = default; - return false; - } - finally - { - state.SetTop(top); - } - } + return true; + } + catch (LuaException) + { + result = default; + return false; + } + finally + { + state.SetTop(top); + } + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs index c5b75de2..c218cc2e 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.Calls; @@ -26,229 +28,293 @@ namespace CheatEngine.SDK.Engine.AddressList; /// public static class AddressListMutations { - private static readonly LuaRef SGetAddressList = new(); - - /// Deletes the current address-list record with . - /// The CE MemoryRecord.ID to resolve in the current list. - /// A command result that distinguishes preflight rejection from an invoked but indeterminate destroy. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static MemoryRecordMutationOutcome Delete(MemoryRecordId recordId) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var identity = LuaRuntime.CurrentStateIdentity; - var top = state.Top; - var mutationStarted = false; - try - { - var preflight = TryGetCurrentList(state, out var list); - if (preflight.Problem != MemoryRecordMutationProblem.None) return preflight; - - preflight = TryResolveRecord(state, list, recordId, isParent: false, out var record); - if (preflight.Problem != MemoryRecordMutationProblem.None) return preflight; - - if (LuaRuntime.CurrentStateIdentity != identity) - return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); - - var status = record.Handle.TryPushMethodLeavingObject(state, "destroy"u8); - if (!status.IsOk) return FromPreflightStatus(status); - - mutationStarted = true; - status = state.TryCall(0, 0); - return status.IsOk ? Completed() : Indeterminate(status); - } - catch (LuaException exception) - { - return mutationStarted ? Indeterminate(exception.Status) : NotAttempted(MemoryRecordMutationProblem.LuaFailure, - exception.Status); - } - finally - { - state.SetTop(top); - } - } - - /// Assigns a record's parent after validating the requested hierarchy with the default traversal bound. - /// The child record's CE ID in the current list. - /// The new parent's CE ID, or to make the child a root record. - /// A command result that preserves whether assignment was started. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, MemoryRecordId? parentId) - { - return SetParent(recordId, parentId, MemoryRecordParentTraversalLimit.Default); - } - - /// Assigns a record's parent after validating the requested hierarchy with . - /// The child record's CE ID in the current list. - /// The new parent's CE ID, or to make the child a root record. - /// A positive bound for the proposed parent's existing parent chain. - /// A command result that preserves whether assignment was started. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, MemoryRecordId? parentId, - MemoryRecordParentTraversalLimit traversalLimit) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(traversalLimit.MaximumHops, nameof(traversalLimit)); - if (parentId.HasValue && parentId.Value == recordId) return NotAttempted(MemoryRecordMutationProblem.SelfParent); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var identity = LuaRuntime.CurrentStateIdentity; - var top = state.Top; - var mutationStarted = false; - try - { - var preflight = TryGetCurrentList(state, out var list); - if (preflight.Problem != MemoryRecordMutationProblem.None) return preflight; - - preflight = TryResolveRecord(state, list, recordId, isParent: false, out var child); - if (preflight.Problem != MemoryRecordMutationProblem.None) return preflight; - - MemoryRecord parent = default; - if (parentId.HasValue) - { - preflight = TryResolveRecord(state, list, parentId.Value, isParent: true, out parent); - if (preflight.Problem != MemoryRecordMutationProblem.None) return preflight; - - preflight = ValidateParentChain(state, recordId, parent, traversalLimit); - if (preflight.Problem != MemoryRecordMutationProblem.None) return preflight; - } - - if (LuaRuntime.CurrentStateIdentity != identity) - return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); - - if (parentId.HasValue) - parent.Handle.Push(state); - else - state.PushNil(); - - mutationStarted = true; - var status = child.Handle.TrySetProperty(state, "Parent"u8); - return status.IsOk ? Completed() : Indeterminate(status); - } - catch (LuaException exception) - { - return mutationStarted ? Indeterminate(exception.Status) : NotAttempted(MemoryRecordMutationProblem.LuaFailure, - exception.Status); - } - finally - { - state.SetTop(top); - } - } - - private static MemoryRecordMutationOutcome TryResolveRecord(LuaState state, AddressList list, MemoryRecordId id, - bool isParent, out MemoryRecord record) - { - var status = list.Handle.TryPushMethodLeavingObject(state, "getMemoryRecordByID"u8); - if (!status.IsOk) - { - record = default; - return FromPreflightStatus(status); - } - - MemoryRecordId.Push(state, id); - status = state.TryCall(1, 1); - if (!status.IsOk) - { - record = default; - return NotAttempted(MemoryRecordMutationProblem.LuaFailure, status); - } - - if (state.IsNil(-1)) - { - record = default; - return NotAttempted(isParent ? MemoryRecordMutationProblem.ParentNotFound : MemoryRecordMutationProblem.RecordNotFound); - } - - if (!MemoryRecord.TryRead(state, -1, out record)) - return NotAttempted(MemoryRecordMutationProblem.InvalidResult); - - return Completed(); - } - - private static MemoryRecordMutationOutcome TryGetCurrentList(LuaState state, out AddressList list) - { - var global = LuaGlobalFunctions.TryPushWithOutcome(state, SGetAddressList, "getAddressList"u8); - if (!global.IsSuccess) - { - list = default; - return global.Status == LuaGlobalPushStatus.LuaFailure - ? NotAttempted(MemoryRecordMutationProblem.LuaFailure, global.LuaStatus) - : NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); - } - - var status = state.TryCall(0, 1); - if (!status.IsOk) - { - list = default; - return NotAttempted(MemoryRecordMutationProblem.LuaFailure, status); - } - - if (state.IsNil(-1)) - { - list = default; - return NotAttempted(MemoryRecordMutationProblem.AddressListUnavailable); - } - - if (!AddressList.TryRead(state, -1, out list)) - return NotAttempted(MemoryRecordMutationProblem.InvalidResult); - - return Completed(); - } - - private static MemoryRecordMutationOutcome ValidateParentChain(LuaState state, MemoryRecordId childId, - MemoryRecord proposedParent, MemoryRecordParentTraversalLimit traversalLimit) - { - var seen = new HashSet(); - var current = proposedParent; - for (var hops = 0;; hops++) - { - var status = current.Handle.TryGetProperty(state, "ID"u8); - if (!status.IsOk) return FromPreflightStatus(status); - if (!MemoryRecordId.TryRead(state, -1, out var currentId)) - return NotAttempted(MemoryRecordMutationProblem.InvalidResult); - - if (currentId == childId || !seen.Add(currentId)) - return NotAttempted(MemoryRecordMutationProblem.CycleDetected); - - status = current.Handle.TryGetProperty(state, "Parent"u8); - if (!status.IsOk) return FromPreflightStatus(status); - if (state.IsNil(-1)) return Completed(); - if (!MemoryRecord.TryRead(state, -1, out current)) - return NotAttempted(MemoryRecordMutationProblem.InvalidResult); - - if (hops + 1 >= traversalLimit.MaximumHops) - return NotAttempted(MemoryRecordMutationProblem.TraversalLimitReached); - } - } - - private static MemoryRecordMutationOutcome FromPreflightStatus(LuaStatus status) - { - return NotAttempted(MemoryRecordMutationProblem.LuaFailure, status); - } - - private static MemoryRecordMutationOutcome Completed() - { - return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.Completed, MemoryRecordMutationProblem.None, - LuaStatus.Ok); - } - - private static MemoryRecordMutationOutcome NotAttempted(MemoryRecordMutationProblem problem) - { - return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.NotAttempted, problem, LuaStatus.Ok); - } - - private static MemoryRecordMutationOutcome NotAttempted(MemoryRecordMutationProblem problem, LuaStatus status) - { - return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.NotAttempted, problem, status); - } - - private static MemoryRecordMutationOutcome Indeterminate(LuaStatus status) - { - return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.Indeterminate, - MemoryRecordMutationProblem.LuaFailure, status); - } + private static readonly LuaRef SGetAddressList = new(); + + /// Deletes the current address-list record with . + /// The CE MemoryRecord.ID to resolve in the current list. + /// A command result that distinguishes preflight rejection from an invoked but indeterminate destroy. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static MemoryRecordMutationOutcome Delete(MemoryRecordId recordId) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + int top = state.Top; + bool mutationStarted = false; + try + { + MemoryRecordMutationOutcome preflight = TryGetCurrentList(state, out AddressList list); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + preflight = TryResolveRecord(state, list, recordId, false, out MemoryRecord record); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + if (LuaRuntime.CurrentStateIdentity != identity) + { + return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); + } + + LuaStatus status = record.Handle.TryPushMethodLeavingObject(state, "destroy"u8); + if (!status.IsOk) + { + return FromPreflightStatus(status); + } + + mutationStarted = true; + status = state.TryCall(0, 0); + return status.IsOk ? Completed() : Indeterminate(status); + } + catch (LuaException exception) + { + return mutationStarted + ? Indeterminate(exception.Status) + : NotAttempted(MemoryRecordMutationProblem.LuaFailure, + exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Assigns a record's parent after validating the requested hierarchy with the default traversal bound. + /// The child record's CE ID in the current list. + /// The new parent's CE ID, or to make the child a root record. + /// A command result that preserves whether assignment was started. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, MemoryRecordId? parentId) + { + return SetParent(recordId, parentId, MemoryRecordParentTraversalLimit.Default); + } + + /// Assigns a record's parent after validating the requested hierarchy with . + /// The child record's CE ID in the current list. + /// The new parent's CE ID, or to make the child a root record. + /// A positive bound for the proposed parent's existing parent chain. + /// A command result that preserves whether assignment was started. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "The parent-assignment transaction is intentionally kept atomic around the Lua operation.")] + public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, MemoryRecordId? parentId, + MemoryRecordParentTraversalLimit traversalLimit) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(traversalLimit.MaximumHops, nameof(traversalLimit)); + if (parentId.HasValue && parentId.Value == recordId) + { + return NotAttempted(MemoryRecordMutationProblem.SelfParent); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + int top = state.Top; + bool mutationStarted = false; + try + { + MemoryRecordMutationOutcome preflight = TryGetCurrentList(state, out AddressList list); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + preflight = TryResolveRecord(state, list, recordId, false, out MemoryRecord child); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + MemoryRecord parent = default; + if (parentId.HasValue) + { + preflight = TryResolveRecord(state, list, parentId.Value, true, out parent); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + preflight = ValidateParentChain(state, recordId, parent, traversalLimit); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + } + + if (LuaRuntime.CurrentStateIdentity != identity) + { + return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); + } + + if (parentId.HasValue) + { + parent.Handle.Push(state); + } + else + { + state.PushNil(); + } + + mutationStarted = true; + LuaStatus status = child.Handle.TrySetProperty(state, "Parent"u8); + return status.IsOk ? Completed() : Indeterminate(status); + } + catch (LuaException exception) + { + return mutationStarted + ? Indeterminate(exception.Status) + : NotAttempted(MemoryRecordMutationProblem.LuaFailure, + exception.Status); + } + finally + { + state.SetTop(top); + } + } + + private static MemoryRecordMutationOutcome TryResolveRecord(LuaState state, AddressList list, MemoryRecordId id, + bool isParent, out MemoryRecord record) + { + LuaStatus status = list.Handle.TryPushMethodLeavingObject(state, "getMemoryRecordByID"u8); + if (!status.IsOk) + { + record = default; + return FromPreflightStatus(status); + } + + MemoryRecordId.Push(state, id); + status = state.TryCall(1, 1); + if (!status.IsOk) + { + record = default; + return NotAttempted(MemoryRecordMutationProblem.LuaFailure, status); + } + + if (state.IsNil(-1)) + { + record = default; + return NotAttempted(isParent + ? MemoryRecordMutationProblem.ParentNotFound + : MemoryRecordMutationProblem.RecordNotFound); + } + + if (!MemoryRecord.TryRead(state, -1, out record)) + { + return NotAttempted(MemoryRecordMutationProblem.InvalidResult); + } + + return Completed(); + } + + private static MemoryRecordMutationOutcome TryGetCurrentList(LuaState state, out AddressList list) + { + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, SGetAddressList, "getAddressList"u8); + if (!global.IsSuccess) + { + list = default; + return global.Status == LuaGlobalPushStatus.LuaFailure + ? NotAttempted(MemoryRecordMutationProblem.LuaFailure, global.LuaStatus) + : NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); + } + + LuaStatus status = state.TryCall(0, 1); + if (!status.IsOk) + { + list = default; + return NotAttempted(MemoryRecordMutationProblem.LuaFailure, status); + } + + if (state.IsNil(-1)) + { + list = default; + return NotAttempted(MemoryRecordMutationProblem.AddressListUnavailable); + } + + if (!AddressList.TryRead(state, -1, out list)) + { + return NotAttempted(MemoryRecordMutationProblem.InvalidResult); + } + + return Completed(); + } + + private static MemoryRecordMutationOutcome ValidateParentChain(LuaState state, MemoryRecordId childId, + MemoryRecord proposedParent, MemoryRecordParentTraversalLimit traversalLimit) + { + HashSet seen = new(); + MemoryRecord current = proposedParent; + for (int hops = 0;; hops++) + { + LuaStatus status = current.Handle.TryGetProperty(state, "ID"u8); + if (!status.IsOk) + { + return FromPreflightStatus(status); + } + + if (!MemoryRecordId.TryRead(state, -1, out MemoryRecordId currentId)) + { + return NotAttempted(MemoryRecordMutationProblem.InvalidResult); + } + + if (currentId == childId || !seen.Add(currentId)) + { + return NotAttempted(MemoryRecordMutationProblem.CycleDetected); + } + + status = current.Handle.TryGetProperty(state, "Parent"u8); + if (!status.IsOk) + { + return FromPreflightStatus(status); + } + + if (state.IsNil(-1)) + { + return Completed(); + } + + if (!MemoryRecord.TryRead(state, -1, out current)) + { + return NotAttempted(MemoryRecordMutationProblem.InvalidResult); + } + + if (hops + 1 >= traversalLimit.MaximumHops) + { + return NotAttempted(MemoryRecordMutationProblem.TraversalLimitReached); + } + } + } + + private static MemoryRecordMutationOutcome FromPreflightStatus(LuaStatus status) + { + return NotAttempted(MemoryRecordMutationProblem.LuaFailure, status); + } + + private static MemoryRecordMutationOutcome Completed() + { + return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.Completed, MemoryRecordMutationProblem.None, + LuaStatus.Ok); + } + + private static MemoryRecordMutationOutcome NotAttempted(MemoryRecordMutationProblem problem) + { + return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.NotAttempted, problem, LuaStatus.Ok); + } + + private static MemoryRecordMutationOutcome NotAttempted(MemoryRecordMutationProblem problem, LuaStatus status) + { + return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.NotAttempted, problem, status); + } + + private static MemoryRecordMutationOutcome Indeterminate(LuaStatus status) + { + return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.Indeterminate, + MemoryRecordMutationProblem.LuaFailure, status); + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs index f4773fd8..7ca8247f 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Enums; @@ -38,210 +39,213 @@ namespace CheatEngine.SDK.Engine.AddressList; /// Wraps an untyped Cheat Engine object handle without validating its runtime class. /// The handle; gives . public readonly struct MemoryRecord(CEObject handle) - : IEquatable, ICEObject, ILuaMarshaller + : IEquatable, ICEObject, ILuaMarshaller { - /// Gets the handle that names no memory record. - public static MemoryRecord Null => default; - - /// - public CEObject Handle { get; } = handle; - - /// Gets a value indicating whether this value names no memory record. - public bool IsNull => Handle.IsNull; - - /// - public static MemoryRecord FromHandle(CEObject handle) - { - return new MemoryRecord(handle); - } - - /// Tests two memory-record handles for native-object identity. - /// The first handle. - /// The second handle. - /// when both handles name the same object. - public static bool operator ==(MemoryRecord left, MemoryRecord right) - { - return left.Handle == right.Handle; - } - - /// Tests two memory-record handles for native-object inequality. - /// The first handle. - /// The second handle. - /// when the handles name different objects. - public static bool operator !=(MemoryRecord left, MemoryRecord right) - { - return !(left == right); - } - - /// - public bool Equals(MemoryRecord other) - { - return this == other; - } - - /// - public override bool Equals(object? obj) - { - return obj is MemoryRecord other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Handle.GetHashCode(); - } - - /// Formats the underlying native-object identity for diagnostics. - /// MemoryRecord(CEObject@0x...), or MemoryRecord(null). - public override string ToString() - { - return IsNull ? "MemoryRecord(null)" : "MemoryRecord(" + Handle + ")"; - } - - /// - [LuaStackEffect(1)] - public static void Push(LuaState state, MemoryRecord value) - { - value.Handle.Push(state); - } - - /// - [LuaStackEffect(0)] - public static bool TryRead(LuaState state, int index, out MemoryRecord value) - { - if (CEObject.TryRead(state, index, out var handle)) - { - value = new MemoryRecord(handle); - return true; - } - - value = default; - return false; - } - - /// Gets CE's unique identifier for this record. - /// The identifier; default on failure. - /// when the ID property was a 32-bit integer. - [RequiresPluginEnabled] - public bool TryGetId(out MemoryRecordId id) - { - return Handle.TryGetProperty("ID"u8, out id); - } - - /// Gets this record's zero-based position in its current address list. - /// The position; default on failure. - /// when the Index property was a 32-bit integer. - [RequiresPluginEnabled] - public bool TryGetIndex(out int zeroBasedIndex) - { - return Handle.TryGetProperty("Index"u8, out zeroBasedIndex); - } - - /// Gets the record's display description. - /// A newly allocated managed string; default on failure. - /// when CE returned a Lua string. - [RequiresPluginEnabled] - public bool TryGetDescription([MaybeNullWhen(false)] out string description) - { - return Handle.TryGetProperty("Description"u8, out description); - } - - /// Sets the record's display description. - /// The non-null display text to pass to CE. - /// when the assignment completed without a protected Lua error. - /// is . - [RequiresPluginEnabled] - public bool TrySetDescription(string description) - { - ArgumentNullException.ThrowIfNull(description); - return Handle.TrySetProperty("Description"u8, description); - } - - /// Gets CE's interpretable address expression, not the resolved target address. - /// A newly allocated expression string; default on failure. - /// when CE returned a Lua string. - [RequiresPluginEnabled] - public bool TryGetAddressExpression([MaybeNullWhen(false)] out string addressExpression) - { - return Handle.TryGetProperty("Address"u8, out addressExpression); - } - - /// Sets CE's interpretable address expression. - /// The non-null CE expression, for example a symbol or hexadecimal address. - /// when the assignment completed without a protected Lua error. - /// is . - [RequiresPluginEnabled] - public bool TrySetAddressExpression(string addressExpression) - { - ArgumentNullException.ThrowIfNull(addressExpression); - return Handle.TrySetProperty("Address"u8, addressExpression); - } - - /// Gets the record's string-form value. - /// A newly allocated managed string; default on failure. - /// when CE returned a Lua string. - [RequiresPluginEnabled] - public bool TryGetValue([MaybeNullWhen(false)] out string value) - { - return Handle.TryGetProperty("Value"u8, out value); - } - - /// Sets the record's string-form value. - /// The non-null value text to pass to CE. - /// when the assignment completed without a protected Lua error. - /// is . - [RequiresPluginEnabled] - public bool TrySetValue(string value) - { - ArgumentNullException.ThrowIfNull(value); - return Handle.TrySetProperty("Value"u8, value); - } - - /// Gets the numeric variable type from CE's Type property. - /// The value type; default on failure. - /// when CE returned an integer that fits . - [RequiresPluginEnabled] - public bool TryGetVariableType(out VariableType variableType) - { - return Handle.TryGetProperty, VariableType>("Type"u8, out variableType); - } - - /// Sets the numeric variable type through CE's Type property. - /// The CE variable type. - /// when the assignment completed without a protected Lua error. - [RequiresPluginEnabled] - public bool TrySetVariableType(VariableType variableType) - { - return Handle.TrySetProperty, VariableType>("Type"u8, variableType); - } - - /// Gets the current resolved target address through CE's getCurrentAddress() method. - /// The target-process address; default on failure. - /// when CE returned a numeric address. - [RequiresPluginEnabled] - public bool TryGetCurrentAddress(out Address address) - { - return Handle.TryCallMethod("getCurrentAddress"u8, out address); - } - - /// Gets a direct child by its zero-based child position. - /// The position in CE's Child[index] accessor; 0 is the first child. - /// A borrowed, Cheat-Engine-owned child; default when there is no child at the index. - /// when CE returned a record rather than nil. - /// is negative. - [RequiresPluginEnabled] - public bool TryGetChild(int zeroBasedIndex, [CEOwned] out MemoryRecord child) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - return AddressListCalls.TryGetIndex(Handle, zeroBasedIndex, out child); - } - - /// Gets this record's parent record. - /// A borrowed, Cheat-Engine-owned parent; default for a root record. - /// when CE returned a parent rather than nil. - [RequiresPluginEnabled] - public bool TryGetParent([CEOwned] out MemoryRecord parent) - { - return Handle.TryGetProperty("Parent"u8, out parent); - } + /// Gets the handle that names no memory record. + public static MemoryRecord Null => default; + + /// + public CEObject Handle + { + get; + } = handle; + + /// Gets a value indicating whether this value names no memory record. + public bool IsNull => Handle.IsNull; + + /// + public static MemoryRecord FromHandle(CEObject handle) + { + return new MemoryRecord(handle); + } + + /// Tests two memory-record handles for native-object identity. + /// The first handle. + /// The second handle. + /// when both handles name the same object. + public static bool operator ==(MemoryRecord left, MemoryRecord right) + { + return left.Handle == right.Handle; + } + + /// Tests two memory-record handles for native-object inequality. + /// The first handle. + /// The second handle. + /// when the handles name different objects. + public static bool operator !=(MemoryRecord left, MemoryRecord right) + { + return !(left == right); + } + + /// + public bool Equals(MemoryRecord other) + { + return this == other; + } + + /// + public override bool Equals(object? obj) + { + return obj is MemoryRecord other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Handle.GetHashCode(); + } + + /// Formats the underlying native-object identity for diagnostics. + /// MemoryRecord(CEObject@0x...), or MemoryRecord(null). + public override string ToString() + { + return IsNull ? "MemoryRecord(null)" : "MemoryRecord(" + Handle + ")"; + } + + /// + [LuaStackEffect(1)] + public static void Push(LuaState state, MemoryRecord value) + { + value.Handle.Push(state); + } + + /// + [LuaStackEffect(0)] + public static bool TryRead(LuaState state, int index, out MemoryRecord value) + { + if (CEObject.TryRead(state, index, out CEObject handle)) + { + value = new MemoryRecord(handle); + return true; + } + + value = default; + return false; + } + + /// Gets CE's unique identifier for this record. + /// The identifier; default on failure. + /// when the ID property was a 32-bit integer. + [RequiresPluginEnabled] + public bool TryGetId(out MemoryRecordId id) + { + return Handle.TryGetProperty("ID"u8, out id); + } + + /// Gets this record's zero-based position in its current address list. + /// The position; default on failure. + /// when the Index property was a 32-bit integer. + [RequiresPluginEnabled] + public bool TryGetIndex(out int zeroBasedIndex) + { + return Handle.TryGetProperty("Index"u8, out zeroBasedIndex); + } + + /// Gets the record's display description. + /// A newly allocated managed string; default on failure. + /// when CE returned a Lua string. + [RequiresPluginEnabled] + public bool TryGetDescription([MaybeNullWhen(false)] out string description) + { + return Handle.TryGetProperty("Description"u8, out description); + } + + /// Sets the record's display description. + /// The non-null display text to pass to CE. + /// when the assignment completed without a protected Lua error. + /// is . + [RequiresPluginEnabled] + public bool TrySetDescription(string description) + { + ArgumentNullException.ThrowIfNull(description); + return Handle.TrySetProperty("Description"u8, description); + } + + /// Gets CE's interpretable address expression, not the resolved target address. + /// A newly allocated expression string; default on failure. + /// when CE returned a Lua string. + [RequiresPluginEnabled] + public bool TryGetAddressExpression([MaybeNullWhen(false)] out string addressExpression) + { + return Handle.TryGetProperty("Address"u8, out addressExpression); + } + + /// Sets CE's interpretable address expression. + /// The non-null CE expression, for example a symbol or hexadecimal address. + /// when the assignment completed without a protected Lua error. + /// is . + [RequiresPluginEnabled] + public bool TrySetAddressExpression(string addressExpression) + { + ArgumentNullException.ThrowIfNull(addressExpression); + return Handle.TrySetProperty("Address"u8, addressExpression); + } + + /// Gets the record's string-form value. + /// A newly allocated managed string; default on failure. + /// when CE returned a Lua string. + [RequiresPluginEnabled] + public bool TryGetValue([MaybeNullWhen(false)] out string value) + { + return Handle.TryGetProperty("Value"u8, out value); + } + + /// Sets the record's string-form value. + /// The non-null value text to pass to CE. + /// when the assignment completed without a protected Lua error. + /// is . + [RequiresPluginEnabled] + public bool TrySetValue(string value) + { + ArgumentNullException.ThrowIfNull(value); + return Handle.TrySetProperty("Value"u8, value); + } + + /// Gets the numeric variable type from CE's Type property. + /// The value type; default on failure. + /// when CE returned an integer that fits . + [RequiresPluginEnabled] + public bool TryGetVariableType(out VariableType variableType) + { + return Handle.TryGetProperty, VariableType>("Type"u8, out variableType); + } + + /// Sets the numeric variable type through CE's Type property. + /// The CE variable type. + /// when the assignment completed without a protected Lua error. + [RequiresPluginEnabled] + public bool TrySetVariableType(VariableType variableType) + { + return Handle.TrySetProperty, VariableType>("Type"u8, variableType); + } + + /// Gets the current resolved target address through CE's getCurrentAddress() method. + /// The target-process address; default on failure. + /// when CE returned a numeric address. + [RequiresPluginEnabled] + public bool TryGetCurrentAddress(out Address address) + { + return Handle.TryCallMethod("getCurrentAddress"u8, out address); + } + + /// Gets a direct child by its zero-based child position. + /// The position in CE's Child[index] accessor; 0 is the first child. + /// A borrowed, Cheat-Engine-owned child; default when there is no child at the index. + /// when CE returned a record rather than nil. + /// is negative. + [RequiresPluginEnabled] + public bool TryGetChild(int zeroBasedIndex, [CEOwned] out MemoryRecord child) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + return AddressListCalls.TryGetIndex(Handle, zeroBasedIndex, out child); + } + + /// Gets this record's parent record. + /// A borrowed, Cheat-Engine-owned parent; default for a root record. + /// when CE returned a parent rather than nil. + [RequiresPluginEnabled] + public bool TryGetParent([CEOwned] out MemoryRecord parent) + { + return Handle.TryGetProperty("Parent"u8, out parent); + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordId.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordId.cs index 497ce30a..11c5ef48 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordId.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordId.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; @@ -24,133 +25,144 @@ namespace CheatEngine.SDK.Engine.AddressList; /// Creates an identifier from the integer CE exposes. /// The raw identifier; CE documents no invalid sentinel. public readonly struct MemoryRecordId(int value) : IEquatable, IComparable, IComparable, - ILuaMarshaller + ILuaMarshaller { - /// Gets the integer carried by Cheat Engine's ID property. - public int Value { get; } = value; - - /// Compares two identifiers by their numeric value. - /// The first identifier. - /// The second identifier. - /// A negative value, zero, or a positive value. - public static int Compare(MemoryRecordId left, MemoryRecordId right) - { - return left.Value.CompareTo(right.Value); - } - - /// Tests two identifiers for numeric equality. - /// The first identifier. - /// The second identifier. - /// when both values are equal. - public static bool operator ==(MemoryRecordId left, MemoryRecordId right) - { - return left.Value == right.Value; - } - - /// Tests two identifiers for numeric inequality. - /// The first identifier. - /// The second identifier. - /// when the values differ. - public static bool operator !=(MemoryRecordId left, MemoryRecordId right) - { - return left.Value != right.Value; - } - - /// Tests whether the first identifier precedes the second one. - /// The first identifier. - /// The second identifier. - /// when is lower than . - public static bool operator <(MemoryRecordId left, MemoryRecordId right) - { - return Compare(left, right) < 0; - } - - /// Tests whether the first identifier does not follow the second one. - /// The first identifier. - /// The second identifier. - /// when is lower than or equal to . - public static bool operator <=(MemoryRecordId left, MemoryRecordId right) - { - return Compare(left, right) <= 0; - } - - /// Tests whether the first identifier follows the second one. - /// The first identifier. - /// The second identifier. - /// when is greater than . - public static bool operator >(MemoryRecordId left, MemoryRecordId right) - { - return Compare(left, right) > 0; - } - - /// Tests whether the first identifier does not precede the second one. - /// The first identifier. - /// The second identifier. - /// when is greater than or equal to . - public static bool operator >=(MemoryRecordId left, MemoryRecordId right) - { - return Compare(left, right) >= 0; - } - - /// - public int CompareTo(MemoryRecordId other) - { - return Compare(this, other); - } - - /// - public int CompareTo(object? obj) - { - if (obj is null) return 1; - if (obj is MemoryRecordId other) return CompareTo(other); - throw new ArgumentException("The value must be a MemoryRecordId.", nameof(obj)); - } - - /// - public bool Equals(MemoryRecordId other) - { - return this == other; - } - - /// - public override bool Equals(object? obj) - { - return obj is MemoryRecordId other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Value; - } - - /// Formats the identifier with invariant decimal digits. - /// The raw integer in decimal. - public override string ToString() - { - return Value.ToString(CultureInfo.InvariantCulture); - } - - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, MemoryRecordId value) - { - Int32Marshaller.Push(state, value.Value); - } - - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out MemoryRecordId value) - { - if (Int32Marshaller.TryRead(state, index, out var raw)) - { - value = new MemoryRecordId(raw); - return true; - } - - value = default; - return false; - } + /// Gets the integer carried by Cheat Engine's ID property. + public int Value + { + get; + } = value; + + /// Compares two identifiers by their numeric value. + /// The first identifier. + /// The second identifier. + /// A negative value, zero, or a positive value. + public static int Compare(MemoryRecordId left, MemoryRecordId right) + { + return left.Value.CompareTo(right.Value); + } + + /// Tests two identifiers for numeric equality. + /// The first identifier. + /// The second identifier. + /// when both values are equal. + public static bool operator ==(MemoryRecordId left, MemoryRecordId right) + { + return left.Value == right.Value; + } + + /// Tests two identifiers for numeric inequality. + /// The first identifier. + /// The second identifier. + /// when the values differ. + public static bool operator !=(MemoryRecordId left, MemoryRecordId right) + { + return left.Value != right.Value; + } + + /// Tests whether the first identifier precedes the second one. + /// The first identifier. + /// The second identifier. + /// when is lower than . + public static bool operator <(MemoryRecordId left, MemoryRecordId right) + { + return Compare(left, right) < 0; + } + + /// Tests whether the first identifier does not follow the second one. + /// The first identifier. + /// The second identifier. + /// when is lower than or equal to . + public static bool operator <=(MemoryRecordId left, MemoryRecordId right) + { + return Compare(left, right) <= 0; + } + + /// Tests whether the first identifier follows the second one. + /// The first identifier. + /// The second identifier. + /// when is greater than . + public static bool operator >(MemoryRecordId left, MemoryRecordId right) + { + return Compare(left, right) > 0; + } + + /// Tests whether the first identifier does not precede the second one. + /// The first identifier. + /// The second identifier. + /// when is greater than or equal to . + public static bool operator >=(MemoryRecordId left, MemoryRecordId right) + { + return Compare(left, right) >= 0; + } + + /// + public int CompareTo(MemoryRecordId other) + { + return Compare(this, other); + } + + /// + public int CompareTo(object? obj) + { + if (obj is null) + { + return 1; + } + + if (obj is MemoryRecordId other) + { + return CompareTo(other); + } + + throw new ArgumentException("The value must be a MemoryRecordId.", nameof(obj)); + } + + /// + public bool Equals(MemoryRecordId other) + { + return this == other; + } + + /// + public override bool Equals(object? obj) + { + return obj is MemoryRecordId other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value; + } + + /// Formats the identifier with invariant decimal digits. + /// The raw integer in decimal. + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } + + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, MemoryRecordId value) + { + Int32Marshaller.Push(state, value.Value); + } + + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out MemoryRecordId value) + { + if (Int32Marshaller.TryRead(state, index, out int raw)) + { + value = new MemoryRecordId(raw); + return true; + } + + value = default; + return false; + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationEffect.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationEffect.cs index 4611499f..87b93818 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationEffect.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationEffect.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Engine.AddressList; /// States how far a table-record mutation progressed at the Cheat Engine boundary. public enum MemoryRecordMutationEffect { - /// No CE mutation was started. - NotAttempted, + /// No CE mutation was started. + NotAttempted, - /// The CE mutation completed successfully. - Completed, + /// The CE mutation completed successfully. + Completed, - /// The CE mutation started but returned a protected failure, so its final host state is unknown. - Indeterminate, + /// The CE mutation started but returned a protected failure, so its final host state is unknown. + Indeterminate } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationOutcome.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationOutcome.cs index 90b9a74b..031b1b69 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationOutcome.cs @@ -1,13 +1,15 @@ -using CheatEngine.SDK.Lua.Calls; using System.Runtime.InteropServices; +using CheatEngine.SDK.Lua.Calls; + namespace CheatEngine.SDK.Engine.AddressList; /// A structured result from a typed address-list record mutation. /// /// /// The result describes the command only. It intentionally does not capture a record snapshot: callers that -/// need a post-command view must obtain a fresh snapshot after a +/// need a post-command view must obtain a fresh snapshot after a +/// /// result, and must not merge a snapshot-read failure with the mutation result. /// /// @@ -18,23 +20,32 @@ namespace CheatEngine.SDK.Engine.AddressList; [StructLayout(LayoutKind.Sequential)] public readonly struct MemoryRecordMutationOutcome { - internal MemoryRecordMutationOutcome(MemoryRecordMutationEffect effect, MemoryRecordMutationProblem problem, - LuaStatus luaStatus) - { - Effect = effect; - Problem = problem; - LuaStatus = luaStatus; - } + internal MemoryRecordMutationOutcome(MemoryRecordMutationEffect effect, MemoryRecordMutationProblem problem, + LuaStatus luaStatus) + { + Effect = effect; + Problem = problem; + LuaStatus = luaStatus; + } - /// Gets how far the mutation progressed. - public MemoryRecordMutationEffect Effect { get; } + /// Gets how far the mutation progressed. + public MemoryRecordMutationEffect Effect + { + get; + } - /// Gets the stable problem classification, or on success. - public MemoryRecordMutationProblem Problem { get; } + /// Gets the stable problem classification, or on success. + public MemoryRecordMutationProblem Problem + { + get; + } - /// Gets the protected Lua status for only. - public LuaStatus LuaStatus { get; } + /// Gets the protected Lua status for only. + public LuaStatus LuaStatus + { + get; + } - /// Gets whether CE reported that the mutation completed. - public bool IsCompleted => Effect == MemoryRecordMutationEffect.Completed; + /// Gets whether CE reported that the mutation completed. + public bool IsCompleted => Effect == MemoryRecordMutationEffect.Completed; } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs index e110cc16..c99b95f6 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs @@ -3,36 +3,36 @@ namespace CheatEngine.SDK.Engine.AddressList; /// Classifies a record-mutation outcome without exposing transient Lua error text. public enum MemoryRecordMutationProblem { - /// No mutation outcome has been produced. - Uninitialized, + /// No mutation outcome has been produced. + Uninitialized, - /// The operation completed. - None, + /// The operation completed. + None, - /// The current CE address list was not available. - AddressListUnavailable, + /// The current CE address list was not available. + AddressListUnavailable, - /// The requested child record was not found in the current address list. - RecordNotFound, + /// The requested child record was not found in the current address list. + RecordNotFound, - /// The requested parent record was not found in the current address list. - ParentNotFound, + /// The requested parent record was not found in the current address list. + ParentNotFound, - /// A record was requested as its own parent. - SelfParent, + /// A record was requested as its own parent. + SelfParent, - /// The requested hierarchy would contain a cycle. - CycleDetected, + /// The requested hierarchy would contain a cycle. + CycleDetected, - /// The parent walk reached its explicit safety limit. - TraversalLimitReached, + /// The parent walk reached its explicit safety limit. + TraversalLimitReached, - /// A required CE global or member was absent or not callable. - GlobalUnavailable, + /// A required CE global or member was absent or not callable. + GlobalUnavailable, - /// A protected CE access or call raised. - LuaFailure, + /// A protected CE access or call raised. + LuaFailure, - /// CE returned a value outside the typed contract. - InvalidResult, + /// CE returned a value outside the typed contract. + InvalidResult } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordParentTraversalLimit.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordParentTraversalLimit.cs index 236a09ba..d6a780ac 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordParentTraversalLimit.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordParentTraversalLimit.cs @@ -5,19 +5,22 @@ namespace CheatEngine.SDK.Engine.AddressList; /// An explicit bound for the parent-chain validation performed before a record reparenting command. public readonly struct MemoryRecordParentTraversalLimit { - /// Initializes a positive parent-chain traversal bound. - /// Maximum parent links inspected before the requested assignment. - /// is not positive. - public MemoryRecordParentTraversalLimit(int maximumHops) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumHops); + /// Initializes a positive parent-chain traversal bound. + /// Maximum parent links inspected before the requested assignment. + /// is not positive. + public MemoryRecordParentTraversalLimit(int maximumHops) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumHops); - MaximumHops = maximumHops; - } + MaximumHops = maximumHops; + } - /// Gets the maximum parent links inspected. - public int MaximumHops { get; } + /// Gets the maximum parent links inspected. + public int MaximumHops + { + get; + } - /// Gets the conservative default validation bound. - public static MemoryRecordParentTraversalLimit Default => new(4096); + /// Gets the conservative default validation bound. + public static MemoryRecordParentTraversalLimit Default => new(4096); } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs index ec9c8b3c..c2eeeeb9 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs @@ -1,5 +1,6 @@ using System; using System.Threading; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Targets; @@ -14,7 +15,8 @@ namespace CheatEngine.SDK.Engine.Allocation; /// The region is not a managed allocation and has no finalizer: deAlloc must execute while the plugin remains /// enabled. CE 7.7's catalog establishes no GUI-thread affinity for this global, so this type intentionally carries /// no MainThreadOnly assertion until a live probe provides that evidence. Call in a -/// block for best-effort, no-throw cleanup, or when the caller must observe a +/// block for best-effort, no-throw cleanup, or when the caller must +/// observe a /// failure. Both paths consume ownership before invoking CE; an expected failure, a binding/marshalling failure, or a /// Lua exception never causes a retry. This makes concurrent and repeated cleanup deterministic and prevents a stale /// address from being freed twice. Before invoking CE, the owner reads the current selection and refuses when it is @@ -24,213 +26,252 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public sealed class AllocatedRegion : IDisposable { - private readonly Address _address; - private readonly ITargetBoundMemoryAllocationOperations _targetBoundOperations; - private readonly TargetProcessIncarnation _targetIncarnation; - private readonly TargetAllocationSize _size; - private TargetReleaseOutcome _lastReleaseOutcome; - private int _released; - - internal AllocatedRegion(ITargetBoundMemoryAllocationOperations targetBoundOperations, Address address, - TargetAllocationSize size, - TargetProcessIncarnation targetIncarnation) - { - ArgumentNullException.ThrowIfNull(targetBoundOperations); - if (address.IsZero) - throw new ArgumentException("An allocated region needs a nonzero target address.", nameof(address)); - if (size.Value <= 0) - throw new ArgumentOutOfRangeException(nameof(size), size.Value, - "An allocated region needs a positive allocation size."); - - _targetBoundOperations = targetBoundOperations; - _targetIncarnation = targetIncarnation; - _address = address; - _size = size; - } - - /// - /// Gets the owned address in the attached target process. - /// - /// Ownership was released or disposed. - public Address Address - { - get - { - ThrowIfReleased(); - return _address; - } - } - - /// - /// Gets the positive byte count originally passed to allocateMemory. - /// - /// Ownership was released or disposed. - public TargetAllocationSize Size - { - get - { - ThrowIfReleased(); - return _size; - } - } - - /// - /// Gets a value indicating whether ownership has been consumed by , - /// , , or . - /// - public bool IsDisposed => Volatile.Read(ref _released) != 0; - - /// Gets the copied process incarnation that was qualified when this allocation was created. - public TargetProcessIncarnation TargetIncarnation => _targetIncarnation; - - /// Gets the factual outcome of the one release attempt, including a safe target refusal. - public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; - - /// - /// Best-effort no-throw release of the target allocation. Idempotent, including concurrent calls. - /// - /// - /// This method is intended for / cleanup. It intentionally discards expected CE, - /// Lua, binding, and marshalling failures, but still consumes ownership so a later call never retries a possibly - /// partial deallocation. Call when the outcome must be observed. - /// - [RequiresPluginEnabled] - public void Dispose() - { - if (!TryTakeOwnership()) return; - - try - { - _ = ReleaseTakenWithOutcome(); - } - catch (Exception) - { - if (_lastReleaseOutcome.Status == TargetReleaseStatus.Unspecified) - _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(failureKind: null); - // IDisposable cleanup must not hide another failure or retry a possibly partial CE deallocation. - } - } - - /// - /// Releases the target allocation and reports every failure to the caller. - /// - /// Ownership was already released or disposed. - /// Cheat Engine reported that deallocation did not complete. - /// The required CE global is absent or non-callable. - /// The CE binding cannot uphold its documented contract. - /// The binding returned an invalid success/failure shape. - /// The protected CE Lua call failed. - /// - /// Ownership is consumed before the CE call. If the call throws, the region remains disposed and cannot be retried; - /// this is safer than attempting to free an allocation whose native state is unknown. - /// - [RequiresPluginEnabled] - public void Release() - { - if (!TryTakeOwnership()) ThrowDisposed(); - - var outcome = ReleaseTakenWithOutcome(); - if (outcome.IsSuccess) return; - - ThrowForReleaseOutcome(outcome); - } - - /// - /// Releases the target allocation and returns a structured factual outcome instead of translating an expected - /// or Engine-boundary result into an exception. - /// - /// The outcome of the one permitted deallocation attempt. - /// - /// Ownership is consumed before the CE call just as it is for . This method does not - /// retry an expected failure or a boundary failure. It exposes the allocation binding outcome and a separate - /// for the target-incarnation check without inspecting exception text. - /// - /// Ownership was already released or disposed. - [RequiresPluginEnabled] - public TargetMemoryOperationOutcome ReleaseWithOutcome() - { - if (!TryTakeOwnership()) ThrowDisposed(); - - return ReleaseTakenWithOutcome(); - } - - /// Releases this owner and returns the target-bound outcome, including safe target refusal. - /// Ownership was already released or disposed. - [RequiresPluginEnabled] - public TargetReleaseOutcome ReleaseWithTargetOutcome() - { - if (!TryTakeOwnership()) ThrowDisposed(); - - _ = ReleaseTakenWithOutcome(); - return LastReleaseOutcome; - } - - private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() - { - try - { - var outcome = _targetBoundOperations.DeallocateBoundWithOutcome(_targetIncarnation, _address, _size, - out var targetCheck); - _lastReleaseOutcome = targetCheck.IsCurrent - ? outcome.IsSuccess - ? TargetReleaseOutcome.Released() - : TargetReleaseOutcome.Unconfirmed(outcome.FailureKind) - : TargetReleaseOutcome.Refused(targetCheck); - return outcome; - } - catch (EngineException exception) - { - _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(exception.Kind); - throw; - } - catch (Exception) - { - _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(failureKind: null); - throw; - } - } - - private void ThrowForReleaseOutcome(TargetMemoryOperationOutcome outcome) - { - if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) - { - throw new EngineTargetIdentityException("TargetMemoryDeallocate", - LastReleaseOutcome.TargetCheck.GetValueOrDefault()); - } - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure) - throw new EngineOperationFailedException("TargetMemoryDeallocate"); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.GlobalUnavailable) - throw new EngineGlobalUnavailableException("TargetMemoryDeallocate"); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.CapabilityUnavailable) - throw new EngineCapabilityUnavailableException("TargetMemoryAllocation"); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.ProtectedLuaFailure) - throw new EngineLuaException("TargetMemoryDeallocate", outcome.LuaStatus); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.MarshallingFailure) - throw new EngineMarshallingException("TargetMemoryDeallocate", EngineMarshallingDirection.Result, - "a Boolean deallocation result", "a non-Boolean result"); - - throw new EngineBindingException("TargetMemoryDeallocate"); - } - - private bool TryTakeOwnership() - { - return Interlocked.Exchange(ref _released, 1) == 0; - } - - private void ThrowIfReleased() - { - if (IsDisposed) ThrowDisposed(); - } - - private static void ThrowDisposed() - { - throw new ObjectDisposedException(nameof(AllocatedRegion), - "The target allocation is no longer owned: it was released or disposed."); - } + private readonly Address _address; + private readonly TargetAllocationSize _size; + private readonly ITargetBoundMemoryAllocationOperations _targetBoundOperations; + private TargetReleaseOutcome _lastReleaseOutcome; + private int _released; + + internal AllocatedRegion(ITargetBoundMemoryAllocationOperations targetBoundOperations, Address address, + TargetAllocationSize size, + TargetProcessIncarnation targetIncarnation) + { + ArgumentNullException.ThrowIfNull(targetBoundOperations); + if (address.IsZero) + { + throw new ArgumentException("An allocated region needs a nonzero target address.", nameof(address)); + } + + if (size.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(size), size.Value, + "An allocated region needs a positive allocation size."); + } + + _targetBoundOperations = targetBoundOperations; + TargetIncarnation = targetIncarnation; + _address = address; + _size = size; + } + + /// + /// Gets the owned address in the attached target process. + /// + /// Ownership was released or disposed. + public Address Address + { + get + { + ThrowIfReleased(); + return _address; + } + } + + /// + /// Gets the positive byte count originally passed to allocateMemory. + /// + /// Ownership was released or disposed. + public TargetAllocationSize Size + { + get + { + ThrowIfReleased(); + return _size; + } + } + + /// + /// Gets a value indicating whether ownership has been consumed by , + /// , , or . + /// + public bool IsDisposed => Volatile.Read(ref _released) != 0; + + /// Gets the copied process incarnation that was qualified when this allocation was created. + public TargetProcessIncarnation TargetIncarnation + { + get; + } + + /// Gets the factual outcome of the one release attempt, including a safe target refusal. + public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; + + /// + /// Best-effort no-throw release of the target allocation. Idempotent, including concurrent calls. + /// + /// + /// This method is intended for / cleanup. It intentionally discards + /// expected CE, + /// Lua, binding, and marshalling failures, but still consumes ownership so a later call never retries a possibly + /// partial deallocation. Call when the outcome must be observed. + /// + [RequiresPluginEnabled] + public void Dispose() + { + if (!TryTakeOwnership()) + { + return; + } + + try + { + _ = ReleaseTakenWithOutcome(); + } + catch (Exception) + { + if (_lastReleaseOutcome.Status == TargetReleaseStatus.Unspecified) + { + _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(null); + } + // IDisposable cleanup must not hide another failure or retry a possibly partial CE deallocation. + } + } + + /// + /// Releases the target allocation and reports every failure to the caller. + /// + /// Ownership was already released or disposed. + /// Cheat Engine reported that deallocation did not complete. + /// The required CE global is absent or non-callable. + /// The CE binding cannot uphold its documented contract. + /// The binding returned an invalid success/failure shape. + /// The protected CE Lua call failed. + /// + /// Ownership is consumed before the CE call. If the call throws, the region remains disposed and cannot be retried; + /// this is safer than attempting to free an allocation whose native state is unknown. + /// + [RequiresPluginEnabled] + public void Release() + { + if (!TryTakeOwnership()) + { + ThrowDisposed(); + } + + TargetMemoryOperationOutcome outcome = ReleaseTakenWithOutcome(); + if (outcome.IsSuccess) + { + return; + } + + ThrowForReleaseOutcome(outcome); + } + + /// + /// Releases the target allocation and returns a structured factual outcome instead of translating an expected + /// or Engine-boundary result into an exception. + /// + /// The outcome of the one permitted deallocation attempt. + /// + /// Ownership is consumed before the CE call just as it is for . This method does not + /// retry an expected failure or a boundary failure. It exposes the allocation binding outcome and a separate + /// for the target-incarnation check without inspecting exception text. + /// + /// Ownership was already released or disposed. + [RequiresPluginEnabled] + public TargetMemoryOperationOutcome ReleaseWithOutcome() + { + if (!TryTakeOwnership()) + { + ThrowDisposed(); + } + + return ReleaseTakenWithOutcome(); + } + + /// Releases this owner and returns the target-bound outcome, including safe target refusal. + /// Ownership was already released or disposed. + [RequiresPluginEnabled] + public TargetReleaseOutcome ReleaseWithTargetOutcome() + { + if (!TryTakeOwnership()) + { + ThrowDisposed(); + } + + _ = ReleaseTakenWithOutcome(); + return LastReleaseOutcome; + } + + private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() + { + try + { + TargetMemoryOperationOutcome outcome = _targetBoundOperations.DeallocateBoundWithOutcome(TargetIncarnation, + _address, _size, + out TargetIdentityCheck targetCheck); + _lastReleaseOutcome = targetCheck.IsCurrent + ? outcome.IsSuccess + ? TargetReleaseOutcome.Released() + : TargetReleaseOutcome.Unconfirmed(outcome.FailureKind) + : TargetReleaseOutcome.Refused(targetCheck); + return outcome; + } + catch (EngineException exception) + { + _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(exception.Kind); + throw; + } + catch (Exception) + { + _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(null); + throw; + } + } + + private void ThrowForReleaseOutcome(TargetMemoryOperationOutcome outcome) + { + if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) + { + throw new EngineTargetIdentityException("TargetMemoryDeallocate", + LastReleaseOutcome.TargetCheck.GetValueOrDefault()); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure) + { + throw new EngineOperationFailedException("TargetMemoryDeallocate"); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.GlobalUnavailable) + { + throw new EngineGlobalUnavailableException("TargetMemoryDeallocate"); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.CapabilityUnavailable) + { + throw new EngineCapabilityUnavailableException("TargetMemoryAllocation"); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.ProtectedLuaFailure) + { + throw new EngineLuaException("TargetMemoryDeallocate", outcome.LuaStatus); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.MarshallingFailure) + { + throw new EngineMarshallingException("TargetMemoryDeallocate", EngineMarshallingDirection.Result, + "a Boolean deallocation result", "a non-Boolean result"); + } + + throw new EngineBindingException("TargetMemoryDeallocate"); + } + + private bool TryTakeOwnership() + { + return Interlocked.Exchange(ref _released, 1) == 0; + } + + private void ThrowIfReleased() + { + if (IsDisposed) + { + ThrowDisposed(); + } + } + + private static void ThrowDisposed() + { + throw new ObjectDisposedException(nameof(AllocatedRegion), + "The target allocation is no longer owned: it was released or disposed."); + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs index 5bb0c700..8bd31392 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs @@ -5,4 +5,4 @@ namespace CheatEngine.SDK.Engine.Allocation; /// Internal owner-publication seam used to qualify post-allocation compensation. internal delegate AllocatedRegion AllocatedRegionFactory(ITargetBoundMemoryAllocationOperations operations, - Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation); + Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation); diff --git a/libs/CheatEngine.SDK.Engine/Allocation/ITargetBoundMemoryAllocationOperations.cs b/libs/CheatEngine.SDK.Engine/Allocation/ITargetBoundMemoryAllocationOperations.cs index c832a9ce..4767957a 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/ITargetBoundMemoryAllocationOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/ITargetBoundMemoryAllocationOperations.cs @@ -13,35 +13,35 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public interface ITargetBoundMemoryAllocationOperations { - /// - /// Allocates only after capturing the current qualified target incarnation in the same operation scope. - /// - /// The allocation request to submit to the current qualified target. - /// The captured incarnation when is qualified. - /// The factual current-target observation made before the operation. - /// The allocation boundary outcome. - TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, - out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation); + /// + /// Allocates only after capturing the current qualified target incarnation in the same operation scope. + /// + /// The allocation request to submit to the current qualified target. + /// The captured incarnation when is qualified. + /// The factual current-target observation made before the operation. + /// The allocation boundary outcome. + public TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, + out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation); - /// - /// Attempts deallocation only when the current qualified target matches . - /// - /// The incarnation captured when ownership was acquired. - /// The owned nonzero allocation address. - /// The original allocation size. - /// The factual current-target comparison made before any deallocation attempt. - /// only after a matching target and a confirmed deallocation. - bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, TargetAllocationSize size, - out TargetIdentityCheck targetCheck); + /// + /// Attempts deallocation only when the current qualified target matches . + /// + /// The incarnation captured when ownership was acquired. + /// The owned nonzero allocation address. + /// The original allocation size. + /// The factual current-target comparison made before any deallocation attempt. + /// only after a matching target and a confirmed deallocation. + public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, TargetAllocationSize size, + out TargetIdentityCheck targetCheck); - /// - /// Attempts deallocation only when the current qualified target matches . - /// - /// The incarnation captured when ownership was acquired. - /// The owned nonzero allocation address. - /// The original allocation size. - /// The factual current-target comparison made before any deallocation attempt. - /// The deallocation boundary outcome or a target-identity refusal. - TargetMemoryOperationOutcome DeallocateBoundWithOutcome(TargetProcessIncarnation expected, Address address, - TargetAllocationSize size, out TargetIdentityCheck targetCheck); + /// + /// Attempts deallocation only when the current qualified target matches . + /// + /// The incarnation captured when ownership was acquired. + /// The owned nonzero allocation address. + /// The original allocation size. + /// The factual current-target comparison made before any deallocation attempt. + /// The deallocation boundary outcome or a target-identity refusal. + public TargetMemoryOperationOutcome DeallocateBoundWithOutcome(TargetProcessIncarnation expected, Address address, + TargetAllocationSize size, out TargetIdentityCheck targetCheck); } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOperations.cs b/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOperations.cs index a81a247c..c9f9474d 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOperations.cs @@ -25,29 +25,29 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public interface ITargetMemoryAllocationOperations { - /// - /// Attempts to allocate memory in the attached target process. - /// - /// The validated target allocation request. - /// The nonzero target address on success; on expected failure. - /// on success; for an expected CE allocation failure. - /// The required CE global is absent or non-callable. - /// The binding cannot uphold its documented contract. - /// The CE result cannot be represented by this contract. - /// The protected CE Lua call failed. - [RequiresPluginEnabled] - public bool TryAllocate(TargetAllocationRequest request, out Address address); + /// + /// Attempts to allocate memory in the attached target process. + /// + /// The validated target allocation request. + /// The nonzero target address on success; on expected failure. + /// on success; for an expected CE allocation failure. + /// The required CE global is absent or non-callable. + /// The binding cannot uphold its documented contract. + /// The CE result cannot be represented by this contract. + /// The protected CE Lua call failed. + [RequiresPluginEnabled] + public bool TryAllocate(TargetAllocationRequest request, out Address address); - /// - /// Attempts to free a region previously created through . - /// - /// The owned nonzero address in the target process. - /// The original allocation request size passed to deAlloc. - /// on success; for an expected CE deallocation failure. - /// The required CE global is absent or non-callable. - /// The binding cannot uphold its documented contract. - /// The CE result cannot be represented by this contract. - /// The protected CE Lua call failed. - [RequiresPluginEnabled] - public bool TryDeallocate(Address address, TargetAllocationSize size); + /// + /// Attempts to free a region previously created through . + /// + /// The owned nonzero address in the target process. + /// The original allocation request size passed to deAlloc. + /// on success; for an expected CE deallocation failure. + /// The required CE global is absent or non-callable. + /// The binding cannot uphold its documented contract. + /// The CE result cannot be represented by this contract. + /// The protected CE Lua call failed. + [RequiresPluginEnabled] + public bool TryDeallocate(Address address, TargetAllocationSize size); } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs b/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs index 85c14e1d..218f057a 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs @@ -14,18 +14,18 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public interface ITargetMemoryAllocationOutcomeOperations { - /// Runs the allocation operation and returns its structured outcome. - /// The target allocation request. - /// The result category and nonzero address on success. - /// The plugin is not enabled or its Lua operation scope is unavailable. - [RequiresPluginEnabled] - public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request); + /// Runs the allocation operation and returns its structured outcome. + /// The target allocation request. + /// The result category and nonzero address on success. + /// The plugin is not enabled or its Lua operation scope is unavailable. + [RequiresPluginEnabled] + public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request); - /// Runs the deallocation operation and returns its structured outcome. - /// The owned nonzero address in the target process. - /// The original allocation request size. - /// The result category for the deallocation operation. - /// The plugin is not enabled or its Lua operation scope is unavailable. - [RequiresPluginEnabled] - public TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size); + /// Runs the deallocation operation and returns its structured outcome. + /// The owned nonzero address in the target process. + /// The original allocation request size. + /// The result category for the deallocation operation. + /// The plugin is not enabled or its Lua operation scope is unavailable. + [RequiresPluginEnabled] + public TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size); } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs b/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs index ccc1164b..58f8a354 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs @@ -1,8 +1,10 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -22,283 +24,336 @@ namespace CheatEngine.SDK.Engine.Allocation; /// failure. The class is stateless and may be shared by multiple instances. /// public sealed class LuaTargetMemoryAllocationOperations : ITargetMemoryAllocationOperations, - ITargetMemoryAllocationOutcomeOperations, ITargetBoundMemoryAllocationOperations + ITargetMemoryAllocationOutcomeOperations, ITargetBoundMemoryAllocationOperations { - private const string AllocateOperation = "TargetMemoryAllocate"; - private const string DeallocateOperation = "TargetMemoryDeallocate"; - private static readonly LuaRef SAllocateMemory = new(); - private static readonly LuaRef SDeallocate = new(); - - /// Gets the shared production implementation. - public static LuaTargetMemoryAllocationOperations Instance { get; } = new(); - - private LuaTargetMemoryAllocationOperations() - { - } - - /// - [RequiresPluginEnabled] - public bool TryAllocate(TargetAllocationRequest request, out Address address) - { - if (request.Size.Value <= 0) ThrowInvalidAllocationSize(); - - var outcome = AllocateWithOutcome(request); - address = outcome.Address; - return GetAllocationResultOrThrow(outcome); - } - - /// - [RequiresPluginEnabled] - public bool TryDeallocate(Address address, TargetAllocationSize size) - { - if (address.IsZero) ThrowInvalidDeallocationAddress(); - if (size.Value <= 0) ThrowInvalidAllocationSize(DeallocateOperation); - - return GetDeallocationResultOrThrow(DeallocateWithOutcome(address, size)); - } - - /// - [RequiresPluginEnabled] - public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) - { - if (request.Size.Value <= 0) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.MarshallingFailure)); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - return AllocateCore(state, request); - } - finally - { - state.SetTop(top); - } - } - - /// - [RequiresPluginEnabled] - public TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size) - { - if (address.IsZero || size.Value <= 0) - return TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - return DeallocateCore(state, address, size); - } - finally - { - state.SetTop(top); - } - } - - TargetMemoryAllocationOutcome ITargetBoundMemoryAllocationOperations.AllocateBoundWithOutcome( - TargetAllocationRequest request, - out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) - { - incarnation = default; - observation = default; - if (request.Size.Value <= 0) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.MarshallingFailure)); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - observation = TargetSelection.ObserveCurrent(state); - if (!observation.IsQualified) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); - - var outcome = AllocateCore(state, request); - incarnation = observation.Incarnation.GetValueOrDefault(); - return outcome; - } - finally - { - state.SetTop(top); - } - } - - bool ITargetBoundMemoryAllocationOperations.TryDeallocateBound(TargetProcessIncarnation expected, Address address, - TargetAllocationSize size, out TargetIdentityCheck targetCheck) - { - var outcome = DeallocateBoundWithOutcomeCore(expected, address, size, out targetCheck); - return targetCheck.IsCurrent && outcome.IsSuccess; - } - - TargetMemoryOperationOutcome ITargetBoundMemoryAllocationOperations.DeallocateBoundWithOutcome( - TargetProcessIncarnation expected, Address address, TargetAllocationSize size, out TargetIdentityCheck targetCheck) - { - return DeallocateBoundWithOutcomeCore(expected, address, size, out targetCheck); - } - - private static TargetMemoryAllocationOutcome AllocateCore(LuaState state, TargetAllocationRequest request) - { - var globalOutcome = TryPushGlobal(state, SAllocateMemory, "allocateMemory"u8); - if (!globalOutcome.IsSuccess) return TargetMemoryAllocationOutcome.Failed(globalOutcome); - state.PushInteger(request.Size.Value); - var argumentCount = 1; - if (request.PreferredBaseAddress.HasValue) - { - Address.Push(state, request.PreferredBaseAddress.Value); - argumentCount++; - } - - if (request.Protection.HasValue) - { - if (!request.PreferredBaseAddress.HasValue) - { - state.PushNil(); - argumentCount++; - } - - state.PushInteger((long)(uint)request.Protection.Value); - argumentCount++; - } - - var status = state.TryCall(argumentCount, 1); - if (!status.IsOk) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.ProtectedLuaFailure, status)); - if (state.IsNil(-1)) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.ExpectedOperationFailure)); - - if (!Address.TryRead(state, -1, out var address)) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.MarshallingFailure)); - - return address.IsZero - ? TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.ExpectedOperationFailure)) - : TargetMemoryAllocationOutcome.Succeeded(address); - } - - private static TargetMemoryOperationOutcome DeallocateCore(LuaState state, Address address, TargetAllocationSize size) - { - var globalOutcome = TryPushGlobal(state, SDeallocate, "deAlloc"u8); - if (!globalOutcome.IsSuccess) return globalOutcome; - Address.Push(state, address); - state.PushInteger(size.Value); - var status = state.TryCall(2, 1); - if (!status.IsOk) - return TargetMemoryOperationOutcome.Failed(EngineFailureKind.ProtectedLuaFailure, status); - if (state.TypeOf(-1) != LuaType.Boolean) - return TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure); - - return state.ToBoolean(-1) - ? TargetMemoryOperationOutcome.Succeeded() - : TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); - } - - private static TargetMemoryOperationOutcome DeallocateBoundWithOutcomeCore(TargetProcessIncarnation expected, - Address address, TargetAllocationSize size, out TargetIdentityCheck targetCheck) - { - targetCheck = default; - if (address.IsZero || size.Value <= 0) - return TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - targetCheck = TargetSelection.ValidateCurrent(state, expected); - return targetCheck.IsCurrent - ? DeallocateCore(state, address, size) - : TargetMemoryOperationOutcome.Failed(GetFailureKind(targetCheck)); - } - finally - { - state.SetTop(top); - } - } - - private static EngineFailureKind GetFailureKind(TargetIdentityCheck check) - { - return check.Kind is TargetIdentityCheckKind.TargetChanged or TargetIdentityCheckKind.ProcessReused - ? EngineFailureKind.TargetIdentityMismatch - : EngineFailureKind.TargetIdentityUnavailable; - } - - private static bool GetAllocationResultOrThrow(TargetMemoryAllocationOutcome outcome) - { - if (outcome.Operation.IsSuccess) return true; - - ThrowForOutcome(outcome.Operation, AllocateOperation, isAllocation: true); - return false; - } - - private static bool GetDeallocationResultOrThrow(TargetMemoryOperationOutcome outcome) - { - if (outcome.IsSuccess) return true; - - ThrowForOutcome(outcome, DeallocateOperation, isAllocation: false); - return false; - } - - private static void ThrowForOutcome(TargetMemoryOperationOutcome outcome, string operation, bool isAllocation) - { - switch (outcome.Kind) - { - case TargetMemoryOperationOutcomeKind.ExpectedFailure: - return; - case TargetMemoryOperationOutcomeKind.GlobalUnavailable: - throw new EngineGlobalUnavailableException(operation); - case TargetMemoryOperationOutcomeKind.CapabilityUnavailable: - throw new EngineCapabilityUnavailableException("TargetMemoryAllocation"); - case TargetMemoryOperationOutcomeKind.ProtectedLuaFailure: - throw new EngineLuaException(operation, outcome.LuaStatus); - case TargetMemoryOperationOutcomeKind.BindingFailure: - throw new EngineBindingException(operation); - case TargetMemoryOperationOutcomeKind.MarshallingFailure: - if (isAllocation) - throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, - "a target address or nil", "a result that is neither an address nor nil"); - - throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, - "a Boolean deallocation result", "a non-Boolean result"); - default: - throw new EngineBindingException(operation); - } - } - - private static TargetMemoryOperationOutcome TryPushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) - { - var resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, name); - return resolution.Status switch - { - LuaGlobalPushStatus.Success => TargetMemoryOperationOutcome.Succeeded(), - LuaGlobalPushStatus.Unavailable => TargetMemoryOperationOutcome.Failed( - EngineFailureKind.GlobalUnavailable), - _ => TargetMemoryOperationOutcome.Failed(EngineFailureKind.ProtectedLuaFailure, - resolution.LuaStatus), - }; - } - - private static void ThrowInvalidAllocationSize() - { - ThrowInvalidAllocationSize(AllocateOperation); - } - - private static void ThrowInvalidAllocationSize(string operation) - { - throw new EngineMarshallingException(operation, EngineMarshallingDirection.Argument, - "a positive allocation size", "a zero or negative allocation size"); - } - - private static void ThrowInvalidDeallocationAddress() - { - throw new EngineMarshallingException(DeallocateOperation, EngineMarshallingDirection.Argument, - "a nonzero target address", "the null target address"); - } + private const string AllocateOperation = "TargetMemoryAllocate"; + private const string DeallocateOperation = "TargetMemoryDeallocate"; + private static readonly LuaRef SAllocateMemory = new(); + private static readonly LuaRef SDeallocate = new(); + + private LuaTargetMemoryAllocationOperations() + { + } + + /// Gets the shared production implementation. + public static LuaTargetMemoryAllocationOperations Instance + { + get; + } = new(); + + TargetMemoryAllocationOutcome ITargetBoundMemoryAllocationOperations.AllocateBoundWithOutcome( + TargetAllocationRequest request, + out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) + { + incarnation = default; + observation = default; + if (request.Size.Value <= 0) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.MarshallingFailure)); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + observation = TargetSelection.ObserveCurrent(state); + if (!observation.IsQualified) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable)); + } + + TargetMemoryAllocationOutcome outcome = AllocateCore(state, request); + incarnation = observation.Incarnation.GetValueOrDefault(); + return outcome; + } + finally + { + state.SetTop(top); + } + } + + bool ITargetBoundMemoryAllocationOperations.TryDeallocateBound(TargetProcessIncarnation expected, Address address, + TargetAllocationSize size, out TargetIdentityCheck targetCheck) + { + TargetMemoryOperationOutcome outcome = DeallocateBoundWithOutcomeCore(expected, address, size, out targetCheck); + return targetCheck.IsCurrent && outcome.IsSuccess; + } + + TargetMemoryOperationOutcome ITargetBoundMemoryAllocationOperations.DeallocateBoundWithOutcome( + TargetProcessIncarnation expected, Address address, TargetAllocationSize size, + out TargetIdentityCheck targetCheck) + { + return DeallocateBoundWithOutcomeCore(expected, address, size, out targetCheck); + } + + /// + [RequiresPluginEnabled] + public bool TryAllocate(TargetAllocationRequest request, out Address address) + { + if (request.Size.Value <= 0) + { + ThrowInvalidAllocationSize(); + } + + TargetMemoryAllocationOutcome outcome = AllocateWithOutcome(request); + address = outcome.Address; + return GetAllocationResultOrThrow(outcome); + } + + /// + [RequiresPluginEnabled] + public bool TryDeallocate(Address address, TargetAllocationSize size) + { + if (address.IsZero) + { + ThrowInvalidDeallocationAddress(); + } + + if (size.Value <= 0) + { + ThrowInvalidAllocationSize(DeallocateOperation); + } + + return GetDeallocationResultOrThrow(DeallocateWithOutcome(address, size)); + } + + /// + [RequiresPluginEnabled] + public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) + { + if (request.Size.Value <= 0) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.MarshallingFailure)); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + return AllocateCore(state, request); + } + finally + { + state.SetTop(top); + } + } + + /// + [RequiresPluginEnabled] + public TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size) + { + if (address.IsZero || size.Value <= 0) + { + return TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + return DeallocateCore(state, address, size); + } + finally + { + state.SetTop(top); + } + } + + private static TargetMemoryAllocationOutcome AllocateCore(LuaState state, TargetAllocationRequest request) + { + TargetMemoryOperationOutcome globalOutcome = TryPushGlobal(state, SAllocateMemory, "allocateMemory"u8); + if (!globalOutcome.IsSuccess) + { + return TargetMemoryAllocationOutcome.Failed(globalOutcome); + } + + state.PushInteger(request.Size.Value); + int argumentCount = 1; + if (request.PreferredBaseAddress.HasValue) + { + Address.Push(state, request.PreferredBaseAddress.Value); + argumentCount++; + } + + if (request.Protection.HasValue) + { + if (!request.PreferredBaseAddress.HasValue) + { + state.PushNil(); + argumentCount++; + } + + state.PushInteger((uint) request.Protection.Value); + argumentCount++; + } + + LuaStatus status = state.TryCall(argumentCount, 1); + if (!status.IsOk) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ProtectedLuaFailure, status)); + } + + if (state.IsNil(-1)) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ExpectedOperationFailure)); + } + + if (!Address.TryRead(state, -1, out Address address)) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.MarshallingFailure)); + } + + return address.IsZero + ? TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ExpectedOperationFailure)) + : TargetMemoryAllocationOutcome.Succeeded(address); + } + + private static TargetMemoryOperationOutcome DeallocateCore(LuaState state, Address address, + TargetAllocationSize size) + { + TargetMemoryOperationOutcome globalOutcome = TryPushGlobal(state, SDeallocate, "deAlloc"u8); + if (!globalOutcome.IsSuccess) + { + return globalOutcome; + } + + Address.Push(state, address); + state.PushInteger(size.Value); + LuaStatus status = state.TryCall(2, 1); + if (!status.IsOk) + { + return TargetMemoryOperationOutcome.Failed(EngineFailureKind.ProtectedLuaFailure, status); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure); + } + + return state.ToBoolean(-1) + ? TargetMemoryOperationOutcome.Succeeded() + : TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); + } + + private static TargetMemoryOperationOutcome DeallocateBoundWithOutcomeCore(TargetProcessIncarnation expected, + Address address, TargetAllocationSize size, out TargetIdentityCheck targetCheck) + { + targetCheck = default; + if (address.IsZero || size.Value <= 0) + { + return TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + targetCheck = TargetSelection.ValidateCurrent(state, expected); + return targetCheck.IsCurrent + ? DeallocateCore(state, address, size) + : TargetMemoryOperationOutcome.Failed(GetFailureKind(targetCheck)); + } + finally + { + state.SetTop(top); + } + } + + private static EngineFailureKind GetFailureKind(TargetIdentityCheck check) + { + return check.Kind is TargetIdentityCheckKind.TargetChanged or TargetIdentityCheckKind.ProcessReused + ? EngineFailureKind.TargetIdentityMismatch + : EngineFailureKind.TargetIdentityUnavailable; + } + + private static bool GetAllocationResultOrThrow(TargetMemoryAllocationOutcome outcome) + { + if (outcome.Operation.IsSuccess) + { + return true; + } + + ThrowForOutcome(outcome.Operation, AllocateOperation, true); + return false; + } + + private static bool GetDeallocationResultOrThrow(TargetMemoryOperationOutcome outcome) + { + if (outcome.IsSuccess) + { + return true; + } + + ThrowForOutcome(outcome, DeallocateOperation, false); + return false; + } + + private static void ThrowForOutcome(TargetMemoryOperationOutcome outcome, string operation, bool isAllocation) + { + switch (outcome.Kind) + { + case TargetMemoryOperationOutcomeKind.ExpectedFailure: + return; + case TargetMemoryOperationOutcomeKind.GlobalUnavailable: + throw new EngineGlobalUnavailableException(operation); + case TargetMemoryOperationOutcomeKind.CapabilityUnavailable: + throw new EngineCapabilityUnavailableException("TargetMemoryAllocation"); + case TargetMemoryOperationOutcomeKind.ProtectedLuaFailure: + throw new EngineLuaException(operation, outcome.LuaStatus); + case TargetMemoryOperationOutcomeKind.BindingFailure: + throw new EngineBindingException(operation); + case TargetMemoryOperationOutcomeKind.MarshallingFailure: + if (isAllocation) + { + throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, + "a target address or nil", "a result that is neither an address nor nil"); + } + + throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, + "a Boolean deallocation result", "a non-Boolean result"); + default: + throw new EngineBindingException(operation); + } + } + + private static TargetMemoryOperationOutcome TryPushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) + { + LuaGlobalPushOutcome resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, name); + return resolution.Status switch + { + LuaGlobalPushStatus.Success => TargetMemoryOperationOutcome.Succeeded(), + LuaGlobalPushStatus.Unavailable => TargetMemoryOperationOutcome.Failed( + EngineFailureKind.GlobalUnavailable), + _ => TargetMemoryOperationOutcome.Failed(EngineFailureKind.ProtectedLuaFailure, + resolution.LuaStatus) + }; + } + + private static void ThrowInvalidAllocationSize() + { + ThrowInvalidAllocationSize(AllocateOperation); + } + + private static void ThrowInvalidAllocationSize(string operation) + { + throw new EngineMarshallingException(operation, EngineMarshallingDirection.Argument, + "a positive allocation size", "a zero or negative allocation size"); + } + + private static void ThrowInvalidDeallocationAddress() + { + throw new EngineMarshallingException(DeallocateOperation, EngineMarshallingDirection.Argument, + "a nonzero target address", "the null target address"); + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationRequest.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationRequest.cs index 1936150c..9e0283ca 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationRequest.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationRequest.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Values; @@ -20,35 +21,47 @@ namespace CheatEngine.SDK.Engine.Allocation; [StructLayout(LayoutKind.Auto)] public readonly record struct TargetAllocationRequest { - /// - /// Initializes a request for target-process memory. - /// - /// The positive number of requested bytes. - /// An optional target address near which Cheat Engine should allocate. - /// The optional CE PAGE_* protection supplied at allocation time. - /// is the default, zero, or negative value. - public TargetAllocationRequest(TargetAllocationSize size, Address? preferredBaseAddress = null, - MemoryProtection? protection = null) - { - if (size.Value <= 0) - throw new ArgumentOutOfRangeException(nameof(size), size.Value, "An allocation size must be positive."); - Size = size; - PreferredBaseAddress = preferredBaseAddress; - Protection = protection; - } + /// + /// Initializes a request for target-process memory. + /// + /// The positive number of requested bytes. + /// An optional target address near which Cheat Engine should allocate. + /// The optional CE PAGE_* protection supplied at allocation time. + /// is the default, zero, or negative value. + public TargetAllocationRequest(TargetAllocationSize size, Address? preferredBaseAddress = null, + MemoryProtection? protection = null) + { + if (size.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(size), size.Value, "An allocation size must be positive."); + } + + Size = size; + PreferredBaseAddress = preferredBaseAddress; + Protection = protection; + } - /// - /// Gets the requested byte count. - /// - public TargetAllocationSize Size { get; } + /// + /// Gets the requested byte count. + /// + public TargetAllocationSize Size + { + get; + } - /// - /// Gets the optional target-process base-address preference. - /// - public Address? PreferredBaseAddress { get; } + /// + /// Gets the optional target-process base-address preference. + /// + public Address? PreferredBaseAddress + { + get; + } - /// - /// Gets the optional initial page protection. - /// - public MemoryProtection? Protection { get; } + /// + /// Gets the optional initial page protection. + /// + public MemoryProtection? Protection + { + get; + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationSize.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationSize.cs index fcb6c46f..96808c5c 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationSize.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationSize.cs @@ -14,70 +14,75 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public readonly struct TargetAllocationSize : IEquatable { - /// - /// Initializes a positive target allocation size. - /// - /// The number of requested bytes, greater than zero. - /// is zero or negative. - public TargetAllocationSize(long value) - { - if (value <= 0) - throw new ArgumentOutOfRangeException(nameof(value), value, "An allocation size must be positive."); + /// + /// Initializes a positive target allocation size. + /// + /// The number of requested bytes, greater than zero. + /// is zero or negative. + public TargetAllocationSize(long value) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "An allocation size must be positive."); + } - Value = value; - } + Value = value; + } - /// - /// Gets the positive byte count supplied to Cheat Engine. - /// - public long Value { get; } + /// + /// Gets the positive byte count supplied to Cheat Engine. + /// + public long Value + { + get; + } - /// - public bool Equals(TargetAllocationSize other) - { - return Value == other.Value; - } + /// + public bool Equals(TargetAllocationSize other) + { + return Value == other.Value; + } - /// - public override bool Equals(object? obj) - { - return obj is TargetAllocationSize other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is TargetAllocationSize other && Equals(other); + } - /// - public override int GetHashCode() - { - return Value.GetHashCode(); - } + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } - /// - /// Determines whether two allocation sizes have the same byte count. - /// - /// The first allocation size. - /// The second allocation size. - /// when the byte counts are equal. - public static bool operator ==(TargetAllocationSize left, TargetAllocationSize right) - { - return left.Equals(right); - } + /// + /// Determines whether two allocation sizes have the same byte count. + /// + /// The first allocation size. + /// The second allocation size. + /// when the byte counts are equal. + public static bool operator ==(TargetAllocationSize left, TargetAllocationSize right) + { + return left.Equals(right); + } - /// - /// Determines whether two allocation sizes have different byte counts. - /// - /// The first allocation size. - /// The second allocation size. - /// when the byte counts differ. - public static bool operator !=(TargetAllocationSize left, TargetAllocationSize right) - { - return !left.Equals(right); - } + /// + /// Determines whether two allocation sizes have different byte counts. + /// + /// The first allocation size. + /// The second allocation size. + /// when the byte counts differ. + public static bool operator !=(TargetAllocationSize left, TargetAllocationSize right) + { + return !left.Equals(right); + } - /// - /// Formats the byte count with the invariant integer format. - /// - /// The byte count. - public override string ToString() - { - return Value.ToString(CultureInfo.InvariantCulture); - } + /// + /// Formats the byte count with the invariant integer format. + /// + /// The byte count. + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocationOutcome.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocationOutcome.cs index ca765b54..73c3488e 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocationOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocationOutcome.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Values; namespace CheatEngine.SDK.Engine.Allocation; @@ -11,45 +12,57 @@ namespace CheatEngine.SDK.Engine.Allocation; [StructLayout(LayoutKind.Auto)] public readonly record struct TargetMemoryAllocationOutcome { - internal TargetMemoryAllocationOutcome(TargetMemoryOperationOutcome operation, Address address) - { - Operation = operation; - Address = address; - } - - /// Gets the allocation binding outcome. - public TargetMemoryOperationOutcome Operation { get; } - - /// - /// Gets the allocated target address when succeeded; otherwise - /// . - /// - public Address Address { get; } - - /// Gets whether an allocation address is available. - public bool IsSuccess => Operation.IsSuccess; - - /// Creates a successful allocation outcome for a nonzero target address. - /// The nonzero target address returned by the allocation operation. - /// A successful allocation outcome. - /// is . - public static TargetMemoryAllocationOutcome Succeeded(Address address) - { - if (address.IsZero) - throw new ArgumentException("A successful allocation outcome requires a nonzero target address.", nameof(address)); - - return new TargetMemoryAllocationOutcome(TargetMemoryOperationOutcome.Succeeded(), address); - } - - /// Creates a specified unsuccessful allocation outcome without an address. - /// The specified non-success allocation operation outcome. - /// An unsuccessful allocation outcome whose is . - /// is successful or unspecified. - public static TargetMemoryAllocationOutcome Failed(TargetMemoryOperationOutcome operation) - { - if (operation.IsSuccess || operation.Kind == TargetMemoryOperationOutcomeKind.Unspecified) - throw new ArgumentException("An unsuccessful allocation outcome requires a specified failure.", nameof(operation)); - - return new TargetMemoryAllocationOutcome(operation, Address.Zero); - } + internal TargetMemoryAllocationOutcome(TargetMemoryOperationOutcome operation, Address address) + { + Operation = operation; + Address = address; + } + + /// Gets the allocation binding outcome. + public TargetMemoryOperationOutcome Operation + { + get; + } + + /// + /// Gets the allocated target address when succeeded; otherwise + /// . + /// + public Address Address + { + get; + } + + /// Gets whether an allocation address is available. + public bool IsSuccess => Operation.IsSuccess; + + /// Creates a successful allocation outcome for a nonzero target address. + /// The nonzero target address returned by the allocation operation. + /// A successful allocation outcome. + /// is . + public static TargetMemoryAllocationOutcome Succeeded(Address address) + { + if (address.IsZero) + { + throw new ArgumentException("A successful allocation outcome requires a nonzero target address.", + nameof(address)); + } + + return new TargetMemoryAllocationOutcome(TargetMemoryOperationOutcome.Succeeded(), address); + } + + /// Creates a specified unsuccessful allocation outcome without an address. + /// The specified non-success allocation operation outcome. + /// An unsuccessful allocation outcome whose is . + /// is successful or unspecified. + public static TargetMemoryAllocationOutcome Failed(TargetMemoryOperationOutcome operation) + { + if (operation.IsSuccess || operation.Kind == TargetMemoryOperationOutcomeKind.Unspecified) + { + throw new ArgumentException("An unsuccessful allocation outcome requires a specified failure.", + nameof(operation)); + } + + return new TargetMemoryAllocationOutcome(operation, Address.Zero); + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs index 6f54d1d2..a4453288 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Targets; @@ -20,217 +21,255 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public sealed class TargetMemoryAllocator { - private readonly ITargetMemoryAllocationOperations _operations; - - /// - /// Initializes an allocator backed by the production CE 7.7 allocateMemory/deAlloc binding. - /// - /// - /// The binding resolves its globals only while an enabled plugin has a Lua state. Constructing this facade does - /// not contact Cheat Engine and is safe before plugin enable; remains lifecycle-gated. - /// - public TargetMemoryAllocator() - : this(LuaTargetMemoryAllocationOperations.Instance) - { - } - - /// - /// Initializes the target-memory allocation facade. - /// - /// The CE 7.7 generated-binding-facing operations. - /// is . - public TargetMemoryAllocator(ITargetMemoryAllocationOperations operations) - { - ArgumentNullException.ThrowIfNull(operations); - _operations = operations; - } - - /// - /// Allocates a region in the current target process and transfers sole ownership to the returned wrapper. - /// - /// The allocation size, optional target base preference, and optional initial protection. - /// The explicitly owned allocation. - /// has the default or an invalid size. - /// Cheat Engine reported that allocation did not complete. - /// The required CE global is absent or non-callable. - /// The CE binding cannot uphold its documented contract. - /// The binding returned an invalid success/failure shape. - /// The protected CE Lua call failed. - /// The operation cannot qualify a target incarnation. - /// - /// Cheat Engine accepted an allocation but an owner could not be published; - /// records the one target-qualified compensation attempt or an unconfirmed effect when no address was available. - /// - [RequiresPluginEnabled] - public AllocatedRegion Allocate(TargetAllocationRequest request) - { - return AllocateCore(request, CreateRegion); - } - - // The factory is internal so tests can fail publication after the effect without allowing consumers to choose a - // different ownership policy. Keep the target-bound tuple local until the owner has been published. - internal AllocatedRegion AllocateCore(TargetAllocationRequest request, AllocatedRegionFactory factory) - { - ArgumentNullException.ThrowIfNull(factory); - if (request.Size.Value <= 0) - throw new ArgumentOutOfRangeException(nameof(request), request.Size.Value, - "An allocation request must have a positive size."); - - if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) - throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); - - var allocationOutcome = targetBound.AllocateBoundWithOutcome(request, out var incarnation, out var observation); - var allocated = allocationOutcome.IsSuccess; - var address = allocationOutcome.Address; - if (!allocated) - { - if (!observation.IsQualified) - throw new EngineTargetIdentityException("TargetMemoryAllocate", - TargetSelection.CreateUnavailableCheck(observation)); - if (!address.IsZero) - throw new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, - "a null target address on failure", "a nonzero target address on failure"); - - ThrowForAllocationOutcome(allocationOutcome.Operation); - } - - if (address.IsZero) - ThrowUnknownSuccessfulAllocation(); - - if (!observation.IsQualified) - ThrowUnqualifiedSuccessfulAllocation(observation); - - try - { - return factory(targetBound, address, request.Size, incarnation); - } - catch (Exception exception) - { - var cleanupOutcome = CompensateFailedPublication(targetBound, address, request.Size, incarnation); - throw new EngineResourceHandoffException("TargetMemoryAllocate", cleanupOutcome, exception); - } - } - - /// - /// Attempts an allocation while retaining a compact factual category for each expected or Engine-boundary - /// result. - /// - /// The allocation size, optional target base preference, and optional initial protection. - /// The structured allocation outcome and a nonzero target address on success. - /// - /// This additive API does not change the direct contract. - /// It reports when an implementation has not opted - /// into , because creating an owner without a qualified - /// cleanup target would be unsafe. Lifecycle failures outside the Engine failure hierarchy still throw. - /// - [RequiresPluginEnabled] - public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) - { - if (request.Size.Value <= 0) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.MarshallingFailure)); - - if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); - - try - { - var outcome = targetBound.AllocateBoundWithOutcome(request, out _, out var observation); - if (!observation.IsQualified) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); - return outcome; - } - catch (EngineException exception) - { - return TargetMemoryAllocationOutcome.Failed(CreateOutcome(exception)); - } - } - - internal static TargetMemoryOperationOutcome CreateOutcome(EngineException exception) - { - return exception is EngineLuaException lua - ? TargetMemoryOperationOutcome.Failed(exception.Kind, lua.Status) - : TargetMemoryOperationOutcome.Failed(exception.Kind); - } - - private static AllocatedRegion CreateRegion(ITargetBoundMemoryAllocationOperations operations, Address address, - TargetAllocationSize size, TargetProcessIncarnation targetIncarnation) - { - return new AllocatedRegion(operations, address, size, targetIncarnation); - } - - private static TargetReleaseOutcome CompensateFailedPublication(ITargetBoundMemoryAllocationOperations operations, - Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation) - { - try - { - var outcome = operations.DeallocateBoundWithOutcome(targetIncarnation, address, size, out var targetCheck); - if (!targetCheck.IsCurrent) return TargetReleaseOutcome.Refused(targetCheck); - - return outcome.IsSuccess - ? TargetReleaseOutcome.Released() - : TargetReleaseOutcome.Unconfirmed(outcome.FailureKind); - } - catch (EngineException exception) - { - return TargetReleaseOutcome.Unconfirmed(exception.Kind); - } - catch (Exception) - { - return TargetReleaseOutcome.Unconfirmed(failureKind: null); - } - } - - [DoesNotReturn] - private static void ThrowUnknownSuccessfulAllocation() - { - var cause = new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, - "a nonzero target address on success", "a null target address on success"); - throw new EngineResourceHandoffException("TargetMemoryAllocate", - TargetReleaseOutcome.Unconfirmed(EngineFailureKind.MarshallingFailure), cause); - } - - [DoesNotReturn] - private static void ThrowUnqualifiedSuccessfulAllocation(TargetSelectionObservation observation) - { - var check = TargetSelection.CreateUnavailableCheck(observation); - var cause = new EngineTargetIdentityException("TargetMemoryAllocate", check); - throw new EngineResourceHandoffException("TargetMemoryAllocate", TargetReleaseOutcome.Refused(check), cause); - } - - private static void ThrowForAllocationOutcome(TargetMemoryOperationOutcome outcome) - { - if (outcome.Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure) - throw new EngineOperationFailedException("TargetMemoryAllocate"); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.GlobalUnavailable) - throw new EngineGlobalUnavailableException("TargetMemoryAllocate"); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.CapabilityUnavailable) - throw new EngineCapabilityUnavailableException("TargetMemoryAllocation"); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.ProtectedLuaFailure) - throw new EngineLuaException("TargetMemoryAllocate", outcome.LuaStatus); - - if (outcome.Kind == TargetMemoryOperationOutcomeKind.MarshallingFailure) - throw new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, - "a target address or nil", "a result that is neither an address nor nil"); - - if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) - { - throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); - } - - throw new EngineBindingException("TargetMemoryAllocate"); - } - - private static TargetIdentityCheck GetUnavailableTargetCheck() - { - var observation = TargetSelectionObservation.FromStatus( - TargetSelectionObservationStatus.CurrentTargetUnqualified); - return TargetSelection.CreateUnavailableCheck(observation); - } + private readonly ITargetMemoryAllocationOperations _operations; + + /// + /// Initializes an allocator backed by the production CE 7.7 allocateMemory/deAlloc binding. + /// + /// + /// The binding resolves its globals only while an enabled plugin has a Lua state. Constructing this facade does + /// not contact Cheat Engine and is safe before plugin enable; remains lifecycle-gated. + /// + public TargetMemoryAllocator() + : this(LuaTargetMemoryAllocationOperations.Instance) + { + } + + /// + /// Initializes the target-memory allocation facade. + /// + /// The CE 7.7 generated-binding-facing operations. + /// is . + public TargetMemoryAllocator(ITargetMemoryAllocationOperations operations) + { + ArgumentNullException.ThrowIfNull(operations); + _operations = operations; + } + + /// + /// Allocates a region in the current target process and transfers sole ownership to the returned wrapper. + /// + /// The allocation size, optional target base preference, and optional initial protection. + /// The explicitly owned allocation. + /// has the default or an invalid size. + /// Cheat Engine reported that allocation did not complete. + /// The required CE global is absent or non-callable. + /// The CE binding cannot uphold its documented contract. + /// The binding returned an invalid success/failure shape. + /// The protected CE Lua call failed. + /// The operation cannot qualify a target incarnation. + /// + /// Cheat Engine accepted an allocation but an owner could not be published; + /// + /// records the one target-qualified compensation attempt or an unconfirmed effect when no address was available. + /// + [RequiresPluginEnabled] + public AllocatedRegion Allocate(TargetAllocationRequest request) + { + return AllocateCore(request, CreateRegion); + } + + // The factory is internal so tests can fail publication after the effect without allowing consumers to choose a + // different ownership policy. Keep the target-bound tuple local until the owner has been published. + internal AllocatedRegion AllocateCore(TargetAllocationRequest request, AllocatedRegionFactory factory) + { + ArgumentNullException.ThrowIfNull(factory); + if (request.Size.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.Size.Value, + "An allocation request must have a positive size."); + } + + if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) + { + throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); + } + + TargetMemoryAllocationOutcome allocationOutcome = targetBound.AllocateBoundWithOutcome(request, + out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation); + bool allocated = allocationOutcome.IsSuccess; + Address address = allocationOutcome.Address; + if (!allocated) + { + if (!observation.IsQualified) + { + throw new EngineTargetIdentityException("TargetMemoryAllocate", + TargetSelection.CreateUnavailableCheck(observation)); + } + + if (!address.IsZero) + { + throw new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, + "a null target address on failure", "a nonzero target address on failure"); + } + + ThrowForAllocationOutcome(allocationOutcome.Operation); + } + + if (address.IsZero) + { + ThrowUnknownSuccessfulAllocation(); + } + + if (!observation.IsQualified) + { + ThrowUnqualifiedSuccessfulAllocation(observation); + } + + try + { + return factory(targetBound, address, request.Size, incarnation); + } + catch (Exception exception) + { + TargetReleaseOutcome cleanupOutcome = + CompensateFailedPublication(targetBound, address, request.Size, incarnation); + throw new EngineResourceHandoffException("TargetMemoryAllocate", cleanupOutcome, exception); + } + } + + /// + /// Attempts an allocation while retaining a compact factual category for each expected or Engine-boundary + /// result. + /// + /// The allocation size, optional target base preference, and optional initial protection. + /// The structured allocation outcome and a nonzero target address on success. + /// + /// This additive API does not change the direct contract. + /// It reports when an implementation has not opted + /// into , because creating an owner without a qualified + /// cleanup target would be unsafe. Lifecycle failures outside the Engine failure hierarchy still throw. + /// + [RequiresPluginEnabled] + public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) + { + if (request.Size.Value <= 0) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.MarshallingFailure)); + } + + if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable)); + } + + try + { + TargetMemoryAllocationOutcome outcome = + targetBound.AllocateBoundWithOutcome(request, out _, out TargetSelectionObservation observation); + if (!observation.IsQualified) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable)); + } + + return outcome; + } + catch (EngineException exception) + { + return TargetMemoryAllocationOutcome.Failed(CreateOutcome(exception)); + } + } + + internal static TargetMemoryOperationOutcome CreateOutcome(EngineException exception) + { + return exception is EngineLuaException lua + ? TargetMemoryOperationOutcome.Failed(exception.Kind, lua.Status) + : TargetMemoryOperationOutcome.Failed(exception.Kind); + } + + private static AllocatedRegion CreateRegion(ITargetBoundMemoryAllocationOperations operations, Address address, + TargetAllocationSize size, TargetProcessIncarnation targetIncarnation) + { + return new AllocatedRegion(operations, address, size, targetIncarnation); + } + + private static TargetReleaseOutcome CompensateFailedPublication(ITargetBoundMemoryAllocationOperations operations, + Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation) + { + try + { + TargetMemoryOperationOutcome outcome = operations.DeallocateBoundWithOutcome(targetIncarnation, address, + size, out TargetIdentityCheck targetCheck); + if (!targetCheck.IsCurrent) + { + return TargetReleaseOutcome.Refused(targetCheck); + } + + return outcome.IsSuccess + ? TargetReleaseOutcome.Released() + : TargetReleaseOutcome.Unconfirmed(outcome.FailureKind); + } + catch (EngineException exception) + { + return TargetReleaseOutcome.Unconfirmed(exception.Kind); + } + catch (Exception) + { + return TargetReleaseOutcome.Unconfirmed(null); + } + } + + [DoesNotReturn] + private static void ThrowUnknownSuccessfulAllocation() + { + EngineMarshallingException cause = new("TargetMemoryAllocate", EngineMarshallingDirection.Result, + "a nonzero target address on success", "a null target address on success"); + throw new EngineResourceHandoffException("TargetMemoryAllocate", + TargetReleaseOutcome.Unconfirmed(EngineFailureKind.MarshallingFailure), cause); + } + + [DoesNotReturn] + private static void ThrowUnqualifiedSuccessfulAllocation(TargetSelectionObservation observation) + { + TargetIdentityCheck check = TargetSelection.CreateUnavailableCheck(observation); + EngineTargetIdentityException cause = new("TargetMemoryAllocate", check); + throw new EngineResourceHandoffException("TargetMemoryAllocate", TargetReleaseOutcome.Refused(check), cause); + } + + private static void ThrowForAllocationOutcome(TargetMemoryOperationOutcome outcome) + { + if (outcome.Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure) + { + throw new EngineOperationFailedException("TargetMemoryAllocate"); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.GlobalUnavailable) + { + throw new EngineGlobalUnavailableException("TargetMemoryAllocate"); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.CapabilityUnavailable) + { + throw new EngineCapabilityUnavailableException("TargetMemoryAllocation"); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.ProtectedLuaFailure) + { + throw new EngineLuaException("TargetMemoryAllocate", outcome.LuaStatus); + } + + if (outcome.Kind == TargetMemoryOperationOutcomeKind.MarshallingFailure) + { + throw new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, + "a target address or nil", "a result that is neither an address nor nil"); + } + + if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) + { + throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); + } + + throw new EngineBindingException("TargetMemoryAllocate"); + } + + private static TargetIdentityCheck GetUnavailableTargetCheck() + { + TargetSelectionObservation observation = TargetSelectionObservation.FromStatus( + TargetSelectionObservationStatus.CurrentTargetUnqualified); + return TargetSelection.CreateUnavailableCheck(observation); + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcome.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcome.cs index ca654277..1b6fbbb1 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcome.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Lua.Calls; @@ -10,95 +11,103 @@ namespace CheatEngine.SDK.Engine.Allocation; /// inspecting a raw Lua state or localized exception message. /// /// -/// is meaningful only for . -/// It is for every other kind. An absent allocation or rejected deallocation is an +/// is meaningful only for +/// . +/// It is for every other kind. An absent allocation or rejected deallocation is +/// an /// , not a protected Lua failure. /// [StructLayout(LayoutKind.Auto)] public readonly record struct TargetMemoryOperationOutcome { - internal TargetMemoryOperationOutcome(TargetMemoryOperationOutcomeKind kind, LuaStatus luaStatus) - { - Kind = kind; - LuaStatus = luaStatus; - } + internal TargetMemoryOperationOutcome(TargetMemoryOperationOutcomeKind kind, LuaStatus luaStatus) + { + Kind = kind; + LuaStatus = luaStatus; + } - /// Gets the stable category of the operation result. - public TargetMemoryOperationOutcomeKind Kind { get; } + /// Gets the stable category of the operation result. + public TargetMemoryOperationOutcomeKind Kind + { + get; + } - /// - /// Gets the protected-call status for a Lua failure, or when - /// is not . - /// - public LuaStatus LuaStatus { get; } + /// + /// Gets the protected-call status for a Lua failure, or when + /// is not . + /// + public LuaStatus LuaStatus + { + get; + } - /// Gets whether the operation completed successfully. - public bool IsSuccess => Kind == TargetMemoryOperationOutcomeKind.Succeeded; + /// Gets whether the operation completed successfully. + public bool IsSuccess => Kind == TargetMemoryOperationOutcomeKind.Succeeded; - /// Gets whether Cheat Engine returned its documented negative result. - public bool IsExpectedFailure => Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure; + /// Gets whether Cheat Engine returned its documented negative result. + public bool IsExpectedFailure => Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure; - /// - /// Gets the corresponding stable Engine failure category, or when no Engine failure is - /// represented, including a successful or unspecified value. - /// - public EngineFailureKind? FailureKind => Kind switch - { - TargetMemoryOperationOutcomeKind.ExpectedFailure => EngineFailureKind.ExpectedOperationFailure, - TargetMemoryOperationOutcomeKind.GlobalUnavailable => EngineFailureKind.GlobalUnavailable, - TargetMemoryOperationOutcomeKind.CapabilityUnavailable => EngineFailureKind.CapabilityUnavailable, - TargetMemoryOperationOutcomeKind.ProtectedLuaFailure => EngineFailureKind.ProtectedLuaFailure, - TargetMemoryOperationOutcomeKind.BindingFailure => EngineFailureKind.BindingFailure, - TargetMemoryOperationOutcomeKind.MarshallingFailure => EngineFailureKind.MarshallingFailure, - TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable => EngineFailureKind.TargetIdentityUnavailable, - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch => EngineFailureKind.TargetIdentityMismatch, - _ => null, - }; + /// + /// Gets the corresponding stable Engine failure category, or when no Engine failure is + /// represented, including a successful or unspecified value. + /// + public EngineFailureKind? FailureKind => Kind switch + { + TargetMemoryOperationOutcomeKind.ExpectedFailure => EngineFailureKind.ExpectedOperationFailure, + TargetMemoryOperationOutcomeKind.GlobalUnavailable => EngineFailureKind.GlobalUnavailable, + TargetMemoryOperationOutcomeKind.CapabilityUnavailable => EngineFailureKind.CapabilityUnavailable, + TargetMemoryOperationOutcomeKind.ProtectedLuaFailure => EngineFailureKind.ProtectedLuaFailure, + TargetMemoryOperationOutcomeKind.BindingFailure => EngineFailureKind.BindingFailure, + TargetMemoryOperationOutcomeKind.MarshallingFailure => EngineFailureKind.MarshallingFailure, + TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable => EngineFailureKind.TargetIdentityUnavailable, + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch => EngineFailureKind.TargetIdentityMismatch, + _ => null + }; - /// Creates a successful allocation operation outcome. - public static TargetMemoryOperationOutcome Succeeded() - { - return new TargetMemoryOperationOutcome(TargetMemoryOperationOutcomeKind.Succeeded, LuaStatus.Ok); - } + /// Creates a successful allocation operation outcome. + public static TargetMemoryOperationOutcome Succeeded() + { + return new TargetMemoryOperationOutcome(TargetMemoryOperationOutcomeKind.Succeeded, LuaStatus.Ok); + } - /// Creates a specified allocation operation failure outcome. - /// The stable Engine failure category to report. - /// - /// The non-success protected-call status when is - /// ; ignored for every other failure category. - /// - /// A specified non-success outcome with a valid Lua status. - /// - /// is and - /// is successful. - /// - /// - /// is not an Engine failure category supported by memory allocation. - /// - public static TargetMemoryOperationOutcome Failed(EngineFailureKind failureKind, LuaStatus luaStatus = default) - { - return failureKind switch - { - EngineFailureKind.ExpectedOperationFailure => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.ExpectedFailure, LuaStatus.Ok), - EngineFailureKind.GlobalUnavailable => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.GlobalUnavailable, LuaStatus.Ok), - EngineFailureKind.CapabilityUnavailable => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.CapabilityUnavailable, LuaStatus.Ok), - EngineFailureKind.ProtectedLuaFailure when luaStatus.IsOk => throw new ArgumentException( - "A protected Lua failure requires a non-success Lua status.", nameof(luaStatus)), - EngineFailureKind.ProtectedLuaFailure => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, luaStatus), - EngineFailureKind.BindingFailure => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.BindingFailure, LuaStatus.Ok), - EngineFailureKind.MarshallingFailure => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.MarshallingFailure, LuaStatus.Ok), - EngineFailureKind.TargetIdentityUnavailable => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, LuaStatus.Ok), - EngineFailureKind.TargetIdentityMismatch => new TargetMemoryOperationOutcome( - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch, LuaStatus.Ok), - _ => throw new ArgumentOutOfRangeException(nameof(failureKind), failureKind, - "The failure category is not supported by memory allocation."), - }; - } + /// Creates a specified allocation operation failure outcome. + /// The stable Engine failure category to report. + /// + /// The non-success protected-call status when is + /// ; ignored for every other failure category. + /// + /// A specified non-success outcome with a valid Lua status. + /// + /// is and + /// is successful. + /// + /// + /// is not an Engine failure category supported by memory allocation. + /// + public static TargetMemoryOperationOutcome Failed(EngineFailureKind failureKind, LuaStatus luaStatus = default) + { + return failureKind switch + { + EngineFailureKind.ExpectedOperationFailure => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.ExpectedFailure, LuaStatus.Ok), + EngineFailureKind.GlobalUnavailable => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.GlobalUnavailable, LuaStatus.Ok), + EngineFailureKind.CapabilityUnavailable => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.CapabilityUnavailable, LuaStatus.Ok), + EngineFailureKind.ProtectedLuaFailure when luaStatus.IsOk => throw new ArgumentException( + "A protected Lua failure requires a non-success Lua status.", nameof(luaStatus)), + EngineFailureKind.ProtectedLuaFailure => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, luaStatus), + EngineFailureKind.BindingFailure => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.BindingFailure, LuaStatus.Ok), + EngineFailureKind.MarshallingFailure => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.MarshallingFailure, LuaStatus.Ok), + EngineFailureKind.TargetIdentityUnavailable => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, LuaStatus.Ok), + EngineFailureKind.TargetIdentityMismatch => new TargetMemoryOperationOutcome( + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch, LuaStatus.Ok), + _ => throw new ArgumentOutOfRangeException(nameof(failureKind), failureKind, + "The failure category is not supported by memory allocation.") + }; + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcomeKind.cs index cbfb5c14..008f2aca 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcomeKind.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryOperationOutcomeKind.cs @@ -10,36 +10,36 @@ namespace CheatEngine.SDK.Engine.Allocation; /// public enum TargetMemoryOperationOutcomeKind { - /// The value was not initialized by an operation. - Unspecified = 0, + /// The value was not initialized by an operation. + Unspecified = 0, - /// The operation completed with its documented successful result. - Succeeded = 1, + /// The operation completed with its documented successful result. + Succeeded = 1, - /// - /// Cheat Engine completed the call and returned its documented negative result, such as - /// from allocateMemory or from deAlloc. - /// - ExpectedFailure = 2, + /// + /// Cheat Engine completed the call and returned its documented negative result, such as + /// from allocateMemory or from deAlloc. + /// + ExpectedFailure = 2, - /// The required Cheat Engine binding global was unavailable or non-callable. - GlobalUnavailable = 3, + /// The required Cheat Engine binding global was unavailable or non-callable. + GlobalUnavailable = 3, - /// A selected optional Engine capability was unavailable. - CapabilityUnavailable = 4, + /// A selected optional Engine capability was unavailable. + CapabilityUnavailable = 4, - /// The protected Lua call failed before it produced its declared result. - ProtectedLuaFailure = 5, + /// The protected Lua call failed before it produced its declared result. + ProtectedLuaFailure = 5, - /// The binding could not uphold its declared contract. - BindingFailure = 6, + /// The binding could not uphold its declared contract. + BindingFailure = 6, - /// The call result or an input could not be represented by the declared contract. - MarshallingFailure = 7, + /// The call result or an input could not be represented by the declared contract. + MarshallingFailure = 7, - /// The target identity required to begin the operation could not be established. - TargetIdentityUnavailable = 8, + /// The target identity required to begin the operation could not be established. + TargetIdentityUnavailable = 8, - /// The current target no longer matches the target-bound owner. - TargetIdentityMismatch = 9, + /// The current target no longer matches the target-bound owner. + TargetIdentityMismatch = 9 } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs index f8004156..19a82d0c 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs @@ -1,5 +1,6 @@ using System; using System.Threading; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Targets; @@ -28,176 +29,190 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public sealed class AutoAssemblerPatch : IDisposable { - private readonly string _script; - private readonly TargetProcessIncarnation _targetIncarnation; - private LuaRef? _disableInfo; - private TargetReleaseOutcome _lastReleaseOutcome; - private int _requiresManualRecovery; - - internal AutoAssemblerPatch(string script, LuaRef disableInfo, TargetProcessIncarnation targetIncarnation) - { - ArgumentException.ThrowIfNullOrWhiteSpace(script); - ArgumentNullException.ThrowIfNull(disableInfo); - _script = script; - _disableInfo = disableInfo; - _targetIncarnation = targetIncarnation; - } - - /// - /// Gets whether the patch still owns a current CE disable-info table and can attempt target validation before a - /// normal disable. - /// - public bool IsEnabled - { - get - { - var disableInfo = Volatile.Read(ref _disableInfo); - return LuaRuntime.IsAttached && disableInfo is not null && disableInfo.IsCurrent; - } - } - - /// - /// Gets whether CE may retain a partially-applied patch that this owner could not safely disable. - /// - /// - /// This remains observable after cleanup consumes ownership. A value of means a CE - /// failure, protected Lua failure, or stale state prevented a confirmed disable; it does not authorize a retry - /// with the old disable-info table. - /// - public bool RequiresManualRecovery => Volatile.Read(ref _requiresManualRecovery) != 0; - - /// - /// Gets whether this owner was consumed by or . - /// - public bool IsDisposed => Volatile.Read(ref _disableInfo) is null; - - /// Gets the copied process incarnation that was qualified when this patch was applied. - public TargetProcessIncarnation TargetIncarnation => _targetIncarnation; - - /// Gets the factual result of the one disable attempt, including a safe target refusal. - public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; - - /// - /// Disables the patch and observes failure. - /// - /// The owner was already released or disposed. - /// Cheat Engine did not confirm that the disable completed. - /// The patch target is no longer the current qualified target. - /// The required CE global is absent or not a function. - /// The protected CE Lua call failed. - /// CE returned a non-boolean disable result. - /// - /// Ownership is consumed before the CE call. If the call returns false, raises, or cannot use an invalidated - /// state, is set and a future call cannot re-run [DISABLE]. A - /// mismatched or unavailable target is refused without selecting another target or invoking the disable script. - /// - [RequiresPluginEnabled] - public void Release() - { - var disableInfo = TakeOwnership(); - var disableInvocationStarted = false; - try - { - _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, _targetIncarnation, - out disableInvocationStarted); - if (_lastReleaseOutcome.Status == TargetReleaseStatus.Released) return; - - Volatile.Write(ref _requiresManualRecovery, 1); - if (_lastReleaseOutcome.TargetCheck.HasValue) - throw new EngineTargetIdentityException("AutoAssemblerDisable", _lastReleaseOutcome.TargetCheck.Value); - - throw new EngineOperationFailedException("AutoAssemblerDisable"); - } - catch (EngineTargetIdentityException) - { - Volatile.Write(ref _requiresManualRecovery, 1); - throw; - } - catch (EngineException exception) - { - _lastReleaseOutcome = disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(exception.Kind) - : TargetReleaseOutcome.NotInvoked(exception.Kind); - Volatile.Write(ref _requiresManualRecovery, 1); - throw; - } - catch - { - _lastReleaseOutcome = disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(failureKind: null) - : TargetReleaseOutcome.NotInvoked(failureKind: null); - Volatile.Write(ref _requiresManualRecovery, 1); - throw; - } - } - - /// - /// Best-effort, no-throw cleanup of this patch. It never retries after a CE, Lua, or lifecycle failure. - /// - [RequiresPluginEnabled] - public void Dispose() - { - var disableInfo = Interlocked.Exchange(ref _disableInfo, null); - if (disableInfo is null) return; - - _ = ReleaseTakenWithTargetOutcome(disableInfo); - } - - /// - /// Disables the patch and returns the factual result of its one permitted target-bound cleanup attempt. - /// - /// - /// when Cheat Engine confirmed disable, a safe refusal when the - /// captured target is no longer current, when cleanup could not - /// begin, or an unconfirmed outcome when an attempted disable could have had partial effects. - /// - /// The owner was already released or disposed. - /// - /// This is the structured counterpart to . It consumes ownership before contacting Lua - /// and distinguishes cleanup that could not begin from an Engine or unexpected failure after invocation. It - /// still throws for an already-consumed owner. It never retries cleanup, never reselects a target, and does - /// not make arbitrary Auto Assembler source safe to run. - /// - [RequiresPluginEnabled] - public TargetReleaseOutcome ReleaseWithTargetOutcome() - { - return ReleaseTakenWithTargetOutcome(TakeOwnership()); - } - - private LuaRef TakeOwnership() - { - var disableInfo = Interlocked.Exchange(ref _disableInfo, null); - if (disableInfo is null) - throw new ObjectDisposedException(nameof(AutoAssemblerPatch), - "The Auto Assembler patch no longer owns CE's disable information."); - - return disableInfo; - } - - private TargetReleaseOutcome ReleaseTakenWithTargetOutcome(LuaRef disableInfo) - { - var disableInvocationStarted = false; - try - { - _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, _targetIncarnation, - out disableInvocationStarted); - } - catch (EngineException exception) - { - _lastReleaseOutcome = disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(exception.Kind) - : TargetReleaseOutcome.NotInvoked(exception.Kind); - } - catch (Exception) - { - _lastReleaseOutcome = disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(failureKind: null) - : TargetReleaseOutcome.NotInvoked(failureKind: null); - } - - if (_lastReleaseOutcome.Status != TargetReleaseStatus.Released) - Volatile.Write(ref _requiresManualRecovery, 1); - - return _lastReleaseOutcome; - } + private readonly string _script; + private LuaRef? _disableInfo; + private TargetReleaseOutcome _lastReleaseOutcome; + private int _requiresManualRecovery; + + internal AutoAssemblerPatch(string script, LuaRef disableInfo, TargetProcessIncarnation targetIncarnation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(script); + ArgumentNullException.ThrowIfNull(disableInfo); + _script = script; + _disableInfo = disableInfo; + TargetIncarnation = targetIncarnation; + } + + /// + /// Gets whether the patch still owns a current CE disable-info table and can attempt target validation before a + /// normal disable. + /// + public bool IsEnabled + { + get + { + LuaRef? disableInfo = Volatile.Read(ref _disableInfo); + return LuaRuntime.IsAttached && disableInfo is not null && disableInfo.IsCurrent; + } + } + + /// + /// Gets whether CE may retain a partially-applied patch that this owner could not safely disable. + /// + /// + /// This remains observable after cleanup consumes ownership. A value of means a CE + /// failure, protected Lua failure, or stale state prevented a confirmed disable; it does not authorize a retry + /// with the old disable-info table. + /// + public bool RequiresManualRecovery => Volatile.Read(ref _requiresManualRecovery) != 0; + + /// + /// Gets whether this owner was consumed by or . + /// + public bool IsDisposed => Volatile.Read(ref _disableInfo) is null; + + /// Gets the copied process incarnation that was qualified when this patch was applied. + public TargetProcessIncarnation TargetIncarnation + { + get; + } + + /// Gets the factual result of the one disable attempt, including a safe target refusal. + public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; + + /// + /// Best-effort, no-throw cleanup of this patch. It never retries after a CE, Lua, or lifecycle failure. + /// + [RequiresPluginEnabled] + public void Dispose() + { + LuaRef? disableInfo = Interlocked.Exchange(ref _disableInfo, null); + if (disableInfo is null) + { + return; + } + + _ = ReleaseTakenWithTargetOutcome(disableInfo); + } + + /// + /// Disables the patch and observes failure. + /// + /// The owner was already released or disposed. + /// Cheat Engine did not confirm that the disable completed. + /// The patch target is no longer the current qualified target. + /// The required CE global is absent or not a function. + /// The protected CE Lua call failed. + /// CE returned a non-boolean disable result. + /// + /// Ownership is consumed before the CE call. If the call returns false, raises, or cannot use an invalidated + /// state, is set and a future call cannot re-run [DISABLE]. A + /// mismatched or unavailable target is refused without selecting another target or invoking the disable script. + /// + [RequiresPluginEnabled] + public void Release() + { + LuaRef disableInfo = TakeOwnership(); + bool disableInvocationStarted = false; + try + { + _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, TargetIncarnation, + out disableInvocationStarted); + if (_lastReleaseOutcome.Status == TargetReleaseStatus.Released) + { + return; + } + + Volatile.Write(ref _requiresManualRecovery, 1); + if (_lastReleaseOutcome.TargetCheck.HasValue) + { + throw new EngineTargetIdentityException("AutoAssemblerDisable", _lastReleaseOutcome.TargetCheck.Value); + } + + throw new EngineOperationFailedException("AutoAssemblerDisable"); + } + catch (EngineTargetIdentityException) + { + Volatile.Write(ref _requiresManualRecovery, 1); + throw; + } + catch (EngineException exception) + { + _lastReleaseOutcome = disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(exception.Kind) + : TargetReleaseOutcome.NotInvoked(exception.Kind); + Volatile.Write(ref _requiresManualRecovery, 1); + throw; + } + catch + { + _lastReleaseOutcome = disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(null) + : TargetReleaseOutcome.NotInvoked(); + Volatile.Write(ref _requiresManualRecovery, 1); + throw; + } + } + + /// + /// Disables the patch and returns the factual result of its one permitted target-bound cleanup attempt. + /// + /// + /// when Cheat Engine confirmed disable, a safe refusal when the + /// captured target is no longer current, when cleanup could not + /// begin, or an unconfirmed outcome when an attempted disable could have had partial effects. + /// + /// The owner was already released or disposed. + /// + /// This is the structured counterpart to . It consumes ownership before contacting Lua + /// and distinguishes cleanup that could not begin from an Engine or unexpected failure after invocation. It + /// still throws for an already-consumed owner. It never retries cleanup, never reselects a target, and does + /// not make arbitrary Auto Assembler source safe to run. + /// + [RequiresPluginEnabled] + public TargetReleaseOutcome ReleaseWithTargetOutcome() + { + return ReleaseTakenWithTargetOutcome(TakeOwnership()); + } + + private LuaRef TakeOwnership() + { + LuaRef? disableInfo = Interlocked.Exchange(ref _disableInfo, null); + if (disableInfo is null) + { + throw new ObjectDisposedException(nameof(AutoAssemblerPatch), + "The Auto Assembler patch no longer owns CE's disable information."); + } + + return disableInfo; + } + + private TargetReleaseOutcome ReleaseTakenWithTargetOutcome(LuaRef disableInfo) + { + bool disableInvocationStarted = false; + try + { + _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, TargetIncarnation, + out disableInvocationStarted); + } + catch (EngineException exception) + { + _lastReleaseOutcome = disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(exception.Kind) + : TargetReleaseOutcome.NotInvoked(exception.Kind); + } + catch (Exception) + { + _lastReleaseOutcome = disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(null) + : TargetReleaseOutcome.NotInvoked(); + } + + if (_lastReleaseOutcome.Status != TargetReleaseStatus.Released) + { + Volatile.Write(ref _requiresManualRecovery, 1); + } + + return _lastReleaseOutcome; + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs index a14a4997..0addc373 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs @@ -5,4 +5,4 @@ namespace CheatEngine.SDK.Engine.Assembly; /// Internal patch-publication seam used only for deterministic post-effect failure tests. internal delegate AutoAssemblerPatch AutoAssemblerPatchFactory(string script, LuaRef disableInfo, - TargetProcessIncarnation targetIncarnation); + TargetProcessIncarnation targetIncarnation); diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs index eff964be..e1e90c39 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Targets; @@ -26,268 +27,308 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public static class AutoAssemblerPatcher { - private const string ApplyOperation = "AutoAssemblerApply"; - private const string DisableOperation = "AutoAssemblerDisable"; - - /// - /// Applies and returns the sole owner of the resulting CE disable information. - /// - /// The complete Auto Assembler script, including its [ENABLE] and [DISABLE] sections. - /// An active patch which must be released or disposed before the plugin is disabled. - /// is empty or white-space only. - /// Cheat Engine rejected the Auto Assembler script. - /// The required CE global is absent or not a function. - /// The protected CE Lua call failed. - /// CE returned a success result without a disable-info table. - /// The selected target cannot be qualified as an incarnation. - /// - /// CE accepted the script but disable-info tracking or patch publication failed; the exception reports its one - /// target-qualified disable attempt. - /// - [RequiresPluginEnabled] - public static AutoAssemblerPatch Apply(string script) - { - if (TryApply(script, out var patch)) return patch; - - throw new EngineOperationFailedException(ApplyOperation); - } - - /// - /// Attempts to apply and transfers CE's disable-info table to - /// on success. - /// - /// The complete Auto Assembler script, including its [ENABLE] and [DISABLE] sections. - /// The active patch owner on success; otherwise . - /// when Cheat Engine accepted the script. - /// is empty or white-space only. - /// The required CE global is absent or not a function. - /// The protected CE Lua call failed. - /// CE returned a success result without a disable-info table. - /// The selected target cannot be qualified as an incarnation. - /// - /// CE accepted the script but disable-info tracking or patch publication failed; the exception reports its one - /// target-qualified disable attempt. - /// - [RequiresPluginEnabled] - public static bool TryApply(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch) - { - return TryApplyCore(script, out patch, CreateDisableInfo, CreatePatch); - } - - // The seams are internal test infrastructure. A caller cannot select tracking or ownership behavior; they let the - // SDK prove that every exception between a successful apply and publication retains one compensation authority. - internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch, - AutoAssemblerDisableInfoTracker disableInfoTracker, AutoAssemblerPatchFactory patchFactory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(script); - ArgumentNullException.ThrowIfNull(disableInfoTracker); - ArgumentNullException.ThrowIfNull(patchFactory); - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - - var targetObservation = TargetSelection.ObserveCurrent(state); - if (!targetObservation.IsQualified) - throw new EngineTargetIdentityException(ApplyOperation, - TargetSelection.CreateUnavailableCheck(targetObservation)); - - PushAutoAssemble(state, ApplyOperation); - StringMarshaller.Push(state, script); - var status = state.TryCall(1, 2); - if (!status.IsOk) ThrowLua(state, status, ApplyOperation); - - if (state.TypeOf(-2) != LuaType.Boolean) - ThrowUnexpectedResult(ApplyOperation, "a boolean success result", state.TypeOf(-2)); - - if (!state.ToBoolean(-2)) - { - patch = null; - return false; - } - - if (!state.IsTable(-1)) - ThrowUnexpectedResult(ApplyOperation, "a disable-info table on success", state.TypeOf(-1)); - - // Retain the original table on the stack while the copy is rooted. A protected ref failure consumes only the - // copy and leaves the original table as the one remaining authority for a direct, target-checked disable. - var disableInfoIndex = state.AbsoluteIndex(-1); - state.PushValue(disableInfoIndex); - LuaRef disableInfo; - try - { - disableInfo = disableInfoTracker(state); - if (disableInfo is null) - throw new InvalidOperationException("The disable-info tracker returned no reference."); - } - catch (Exception exception) - { - state.SetTop(disableInfoIndex); - var cleanupOutcome = TryDisableFromStack(script, state, disableInfoIndex, - targetObservation.Incarnation.GetValueOrDefault()); - throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); - } - - try - { - patch = patchFactory(script, disableInfo, targetObservation.Incarnation.GetValueOrDefault()); - return true; - } - catch (Exception exception) - { - var cleanupOutcome = CompensateFailedPublication(script, disableInfo, - targetObservation.Incarnation.GetValueOrDefault()); - throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); - } - } - - // The owner always routes cleanup through this method. Keeping the LuaRef release in its finally block prevents a - // failed protected call from pinning CE's disable-info table and makes retrying a possibly partial disable impossible. - internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInfo, TargetProcessIncarnation target, - out bool disableInvocationStarted) - { - ArgumentNullException.ThrowIfNull(disableInfo); - disableInvocationStarted = false; - - if (!LuaRuntime.IsAttached || !disableInfo.IsCurrent) - { - disableInfo.Dispose(); - return TargetReleaseOutcome.NotInvoked(); - } - - try - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - try - { - using LuaFrame frame = new(state); - var targetCheck = TargetSelection.ValidateCurrent(state, target); - if (!targetCheck.IsCurrent) return TargetReleaseOutcome.Refused(targetCheck); - - PushAutoAssemble(state, DisableOperation); - StringMarshaller.Push(state, script); - if (!state.TryPushRef(disableInfo)) return TargetReleaseOutcome.NotInvoked(); - - disableInvocationStarted = true; - var status = state.TryCall(2, 1); - if (!status.IsOk) ThrowLua(state, status, DisableOperation); - - if (state.TypeOf(-1) != LuaType.Boolean) - ThrowUnexpectedResult(DisableOperation, "a boolean disable result", state.TypeOf(-1)); - - return state.ToBoolean(-1) - ? TargetReleaseOutcome.Released() - : TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ExpectedOperationFailure); - } - finally - { - // A disable can be partially applied even when CE returns false or raises. Ownership is therefore - // consumed before the invocation and the registry table is unrooted on every result path. - disableInfo.Release(state); - } - } - catch - { - // Dispose still marks the reference released, without attempting a second operation against a detached - // state. The caller uses disableInvocationStarted to preserve whether the CE call began. - disableInfo.Dispose(); - throw; - } - } - - private static LuaRef CreateDisableInfo(LuaState state) - { - return state.CreateRef(); - } - - private static AutoAssemblerPatch CreatePatch(string script, LuaRef disableInfo, - TargetProcessIncarnation targetIncarnation) - { - return new AutoAssemblerPatch(script, disableInfo, targetIncarnation); - } - - private static TargetReleaseOutcome CompensateFailedPublication(string script, LuaRef disableInfo, - TargetProcessIncarnation targetIncarnation) - { - var disableInvocationStarted = false; - try - { - return TryDisable(script, disableInfo, targetIncarnation, out disableInvocationStarted); - } - catch (EngineException exception) - { - return disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(exception.Kind) - : TargetReleaseOutcome.NotInvoked(exception.Kind); - } - catch (Exception) - { - return disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(failureKind: null) - : TargetReleaseOutcome.NotInvoked(failureKind: null); - } - } - - // The original disable-info table remains at disableInfoIndex and this helper deliberately does not root it. The - // surrounding LuaFrame restores the stack after the one compensation attempt, including a failed protected call. - private static TargetReleaseOutcome TryDisableFromStack(string script, LuaState state, int disableInfoIndex, - TargetProcessIncarnation targetIncarnation) - { - var disableInvocationStarted = false; - try - { - var targetCheck = TargetSelection.ValidateCurrent(state, targetIncarnation); - if (!targetCheck.IsCurrent) return TargetReleaseOutcome.Refused(targetCheck); - - PushAutoAssemble(state, DisableOperation); - StringMarshaller.Push(state, script); - state.PushValue(disableInfoIndex); - disableInvocationStarted = true; - var status = state.TryCall(2, 1); - if (!status.IsOk) return TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); - - if (state.TypeOf(-1) != LuaType.Boolean) - return TargetReleaseOutcome.Unconfirmed(EngineFailureKind.MarshallingFailure); - - return state.ToBoolean(-1) - ? TargetReleaseOutcome.Released() - : TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ExpectedOperationFailure); - } - catch (EngineException exception) - { - return disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(exception.Kind) - : TargetReleaseOutcome.NotInvoked(exception.Kind); - } - catch (Exception) - { - return disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(failureKind: null) - : TargetReleaseOutcome.NotInvoked(failureKind: null); - } - } - - private static void PushAutoAssemble(LuaState state, string operation) - { - var status = state.TryGetGlobal("autoAssemble"u8); - if (!status.IsOk) ThrowLua(state, status, operation); - - if (!state.IsFunction(-1)) - throw new EngineGlobalUnavailableException(operation); - } - - [DoesNotReturn] - private static void ThrowLua(LuaState state, LuaStatus status, string operation) - { - var error = LuaError.FromStack(state, status); - throw new EngineLuaException(operation, status, - "The protected Lua call for Engine operation '" + operation + "' failed.", new LuaException(error)); - } - - [DoesNotReturn] - private static void ThrowUnexpectedResult(string operation, string expected, LuaType actual) - { - throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, expected, - "a Lua " + actual.ToString().ToLowerInvariant() + " value"); - } + private const string ApplyOperation = "AutoAssemblerApply"; + private const string DisableOperation = "AutoAssemblerDisable"; + + /// + /// Applies and returns the sole owner of the resulting CE disable information. + /// + /// The complete Auto Assembler script, including its [ENABLE] and [DISABLE] sections. + /// An active patch which must be released or disposed before the plugin is disabled. + /// is empty or white-space only. + /// Cheat Engine rejected the Auto Assembler script. + /// The required CE global is absent or not a function. + /// The protected CE Lua call failed. + /// CE returned a success result without a disable-info table. + /// The selected target cannot be qualified as an incarnation. + /// + /// CE accepted the script but disable-info tracking or patch publication failed; the exception reports its one + /// target-qualified disable attempt. + /// + [RequiresPluginEnabled] + public static AutoAssemblerPatch Apply(string script) + { + if (TryApply(script, out AutoAssemblerPatch? patch)) + { + return patch; + } + + throw new EngineOperationFailedException(ApplyOperation); + } + + /// + /// Attempts to apply and transfers CE's disable-info table to + /// on success. + /// + /// The complete Auto Assembler script, including its [ENABLE] and [DISABLE] sections. + /// The active patch owner on success; otherwise . + /// when Cheat Engine accepted the script. + /// is empty or white-space only. + /// The required CE global is absent or not a function. + /// The protected CE Lua call failed. + /// CE returned a success result without a disable-info table. + /// The selected target cannot be qualified as an incarnation. + /// + /// CE accepted the script but disable-info tracking or patch publication failed; the exception reports its one + /// target-qualified disable attempt. + /// + [RequiresPluginEnabled] + public static bool TryApply(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch) + { + return TryApplyCore(script, out patch, CreateDisableInfo, CreatePatch); + } + + // The seams are internal test infrastructure. A caller cannot select tracking or ownership behavior; they let the + // SDK prove that every exception between a successful apply and publication retains one compensation authority. + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "This internal seam must keep the apply, compensation, and publication transaction together.")] + internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch, + AutoAssemblerDisableInfoTracker disableInfoTracker, AutoAssemblerPatchFactory patchFactory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(script); + ArgumentNullException.ThrowIfNull(disableInfoTracker); + ArgumentNullException.ThrowIfNull(patchFactory); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + + TargetSelectionObservation targetObservation = TargetSelection.ObserveCurrent(state); + if (!targetObservation.IsQualified) + { + throw new EngineTargetIdentityException(ApplyOperation, + TargetSelection.CreateUnavailableCheck(targetObservation)); + } + + PushAutoAssemble(state, ApplyOperation); + StringMarshaller.Push(state, script); + LuaStatus status = state.TryCall(1, 2); + if (!status.IsOk) + { + ThrowLua(state, status, ApplyOperation); + } + + if (state.TypeOf(-2) != LuaType.Boolean) + { + ThrowUnexpectedResult(ApplyOperation, "a boolean success result", state.TypeOf(-2)); + } + + if (!state.ToBoolean(-2)) + { + patch = null; + return false; + } + + if (!state.IsTable(-1)) + { + ThrowUnexpectedResult(ApplyOperation, "a disable-info table on success", state.TypeOf(-1)); + } + + // Retain the original table on the stack while the copy is rooted. A protected ref failure consumes only the + // copy and leaves the original table as the one remaining authority for a direct, target-checked disable. + int disableInfoIndex = state.AbsoluteIndex(-1); + state.PushValue(disableInfoIndex); + LuaRef disableInfo; + try + { + disableInfo = disableInfoTracker(state); + if (disableInfo is null) + { + throw new InvalidOperationException("The disable-info tracker returned no reference."); + } + } + catch (Exception exception) + { + state.SetTop(disableInfoIndex); + TargetReleaseOutcome cleanupOutcome = TryDisableFromStack(script, state, disableInfoIndex, + targetObservation.Incarnation.GetValueOrDefault()); + throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); + } + + try + { + patch = patchFactory(script, disableInfo, targetObservation.Incarnation.GetValueOrDefault()); + return true; + } + catch (Exception exception) + { + TargetReleaseOutcome cleanupOutcome = CompensateFailedPublication(script, disableInfo, + targetObservation.Incarnation.GetValueOrDefault()); + throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); + } + } + + // The owner always routes cleanup through this method. Keeping the LuaRef release in its finally block prevents a + // failed protected call from pinning CE's disable-info table and makes retrying a possibly partial disable impossible. + internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInfo, TargetProcessIncarnation target, + out bool disableInvocationStarted) + { + ArgumentNullException.ThrowIfNull(disableInfo); + disableInvocationStarted = false; + + if (!LuaRuntime.IsAttached || !disableInfo.IsCurrent) + { + disableInfo.Dispose(); + return TargetReleaseOutcome.NotInvoked(); + } + + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + try + { + using LuaFrame frame = new(state); + TargetIdentityCheck targetCheck = TargetSelection.ValidateCurrent(state, target); + if (!targetCheck.IsCurrent) + { + return TargetReleaseOutcome.Refused(targetCheck); + } + + PushAutoAssemble(state, DisableOperation); + StringMarshaller.Push(state, script); + if (!state.TryPushRef(disableInfo)) + { + return TargetReleaseOutcome.NotInvoked(); + } + + disableInvocationStarted = true; + LuaStatus status = state.TryCall(2, 1); + if (!status.IsOk) + { + ThrowLua(state, status, DisableOperation); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + ThrowUnexpectedResult(DisableOperation, "a boolean disable result", state.TypeOf(-1)); + } + + return state.ToBoolean(-1) + ? TargetReleaseOutcome.Released() + : TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ExpectedOperationFailure); + } + finally + { + // A disable can be partially applied even when CE returns false or raises. Ownership is therefore + // consumed before the invocation and the registry table is unrooted on every result path. + disableInfo.Release(state); + } + } + catch + { + // Dispose still marks the reference released, without attempting a second operation against a detached + // state. The caller uses disableInvocationStarted to preserve whether the CE call began. + disableInfo.Dispose(); + throw; + } + } + + private static LuaRef CreateDisableInfo(LuaState state) + { + return state.CreateRef(); + } + + private static AutoAssemblerPatch CreatePatch(string script, LuaRef disableInfo, + TargetProcessIncarnation targetIncarnation) + { + return new AutoAssemblerPatch(script, disableInfo, targetIncarnation); + } + + private static TargetReleaseOutcome CompensateFailedPublication(string script, LuaRef disableInfo, + TargetProcessIncarnation targetIncarnation) + { + bool disableInvocationStarted = false; + try + { + return TryDisable(script, disableInfo, targetIncarnation, out disableInvocationStarted); + } + catch (EngineException exception) + { + return disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(exception.Kind) + : TargetReleaseOutcome.NotInvoked(exception.Kind); + } + catch (Exception) + { + return disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(null) + : TargetReleaseOutcome.NotInvoked(); + } + } + + // The original disable-info table remains at disableInfoIndex and this helper deliberately does not root it. The + // surrounding LuaFrame restores the stack after the one compensation attempt, including a failed protected call. + private static TargetReleaseOutcome TryDisableFromStack(string script, LuaState state, int disableInfoIndex, + TargetProcessIncarnation targetIncarnation) + { + bool disableInvocationStarted = false; + try + { + TargetIdentityCheck targetCheck = TargetSelection.ValidateCurrent(state, targetIncarnation); + if (!targetCheck.IsCurrent) + { + return TargetReleaseOutcome.Refused(targetCheck); + } + + PushAutoAssemble(state, DisableOperation); + StringMarshaller.Push(state, script); + state.PushValue(disableInfoIndex); + disableInvocationStarted = true; + LuaStatus status = state.TryCall(2, 1); + if (!status.IsOk) + { + return TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return TargetReleaseOutcome.Unconfirmed(EngineFailureKind.MarshallingFailure); + } + + return state.ToBoolean(-1) + ? TargetReleaseOutcome.Released() + : TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ExpectedOperationFailure); + } + catch (EngineException exception) + { + return disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(exception.Kind) + : TargetReleaseOutcome.NotInvoked(exception.Kind); + } + catch (Exception) + { + return disableInvocationStarted + ? TargetReleaseOutcome.Unconfirmed(null) + : TargetReleaseOutcome.NotInvoked(); + } + } + + private static void PushAutoAssemble(LuaState state, string operation) + { + LuaStatus status = state.TryGetGlobal("autoAssemble"u8); + if (!status.IsOk) + { + ThrowLua(state, status, operation); + } + + if (!state.IsFunction(-1)) + { + throw new EngineGlobalUnavailableException(operation); + } + } + + [DoesNotReturn] + private static void ThrowLua(LuaState state, LuaStatus status, string operation) + { + LuaError error = LuaError.FromStack(state, status); + throw new EngineLuaException(operation, status, + "The protected Lua call for Engine operation '" + operation + "' failed.", new LuaException(error)); + } + + [DoesNotReturn] + private static void ThrowUnexpectedResult(string operation, string expected, LuaType actual) + { + throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, expected, + "a Lua " + actual.ToString().ToLowerInvariant() + " value"); + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs index c37a423e..87b811a6 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; @@ -28,108 +29,155 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public static class InstructionAssembler { - private static readonly LuaRef SAssemble = new(); - - /// Assembles one instruction into caller-owned storage using an explicit target address as its origin. - /// The CE-observed selected PID and instruction profile used to validate . - /// The instruction source sent to CE as UTF-8 without normalization. - /// The target origin supplied to CE; relative operands are interpreted by CE relative to this address. - /// Caller-owned storage for the complete assembled byte sequence. - /// The number of copied bytes on success; zero for every other outcome. - /// The exact byte-table length when CE returned a valid table; zero otherwise. - /// A profile, capacity, instruction, availability, protected-call, or result-shape outcome. - /// is . - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "The protected call, full table validation, target recheck, and copy share one stack frame.")] - public static InstructionOperationStatus TryAssemble(InstructionTargetProfile targetProfile, string instruction, Address address, - Span destination, out int written, out int requiredLength) - { - ArgumentNullException.ThrowIfNull(instruction); - written = 0; - requiredLength = 0; - - var profileStatus = targetProfile.Validate(address); - if (profileStatus != InstructionOperationStatus.Success) return profileStatus; - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - var targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) return targetStatus; - - var global = LuaGlobalFunctions.TryPushWithStatus(state, SAssemble, "assemble"u8); - if (global == LuaGlobalPushStatus.Unavailable) return InstructionOperationStatus.GlobalUnavailable; - if (global != LuaGlobalPushStatus.Success) return InstructionOperationStatus.LuaFailure; - - var resultStart = state.Top - 1; - StringMarshaller.Push(state, instruction); - Address.Push(state, address); - if (!state.TryCall(2, LuaState.MultipleResults).IsOk) return InstructionOperationStatus.LuaFailure; - - targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) return targetStatus; - - var resultIndex = resultStart + 1; - if (state.Top < resultIndex) return InstructionOperationStatus.InvalidResult; - if (state.IsNil(resultIndex)) return InstructionOperationStatus.InstructionRejected; - if (!state.IsTable(resultIndex)) return InstructionOperationStatus.InvalidResult; - - var tableIndex = state.AbsoluteIndex(resultIndex); - var rawLength = state.RawLength(tableIndex); - if (rawLength > (nuint)int.MaxValue) return InstructionOperationStatus.InvalidResult; - requiredLength = (int)rawLength; - if (requiredLength > destination.Length) return InstructionOperationStatus.DestinationTooSmall; - - if (!ValidateByteTable(state, tableIndex, requiredLength)) - { - requiredLength = 0; - return InstructionOperationStatus.InvalidResult; - } - - targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) - { - requiredLength = 0; - return targetStatus; - } - - for (var index = 0; index < requiredLength; index++) - { - _ = state.RawGetSequenceItem(tableIndex, index); - _ = state.TryReadInteger(-1, out var value); - destination[index] = (byte)value; - state.Pop(1); - } - - written = requiredLength; - return InstructionOperationStatus.Success; - } - catch (LuaException) - { - written = 0; - requiredLength = 0; - return InstructionOperationStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - private static bool ValidateByteTable(LuaState state, int tableIndex, int length) - { - for (var index = 0; index < length; index++) - { - _ = state.RawGetSequenceItem(tableIndex, index); - var valid = state.TryReadInteger(-1, out var value) && value is >= byte.MinValue and <= byte.MaxValue; - state.Pop(1); - if (!valid) return false; - } - - return true; - } + private static readonly LuaRef SAssemble = new(); + + /// Assembles one instruction into caller-owned storage using an explicit target address as its origin. + /// + /// The CE-observed selected PID and instruction profile used to validate + /// . + /// + /// The instruction source sent to CE as UTF-8 without normalization. + /// + /// The target origin supplied to CE; relative operands are interpreted by CE relative to this + /// address. + /// + /// Caller-owned storage for the complete assembled byte sequence. + /// The number of copied bytes on success; zero for every other outcome. + /// The exact byte-table length when CE returned a valid table; zero otherwise. + /// A profile, capacity, instruction, availability, protected-call, or result-shape outcome. + /// is . + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "The protected call, full table validation, target recheck, and copy share one stack frame.")] + public static InstructionOperationStatus TryAssemble(InstructionTargetProfile targetProfile, string instruction, + Address address, + Span destination, out int written, out int requiredLength) + { + ArgumentNullException.ThrowIfNull(instruction); + written = 0; + requiredLength = 0; + + InstructionOperationStatus profileStatus = targetProfile.Validate(address); + if (profileStatus != InstructionOperationStatus.Success) + { + return profileStatus; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + InstructionOperationStatus targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + return targetStatus; + } + + LuaGlobalPushStatus global = LuaGlobalFunctions.TryPushWithStatus(state, SAssemble, "assemble"u8); + if (global == LuaGlobalPushStatus.Unavailable) + { + return InstructionOperationStatus.GlobalUnavailable; + } + + if (global != LuaGlobalPushStatus.Success) + { + return InstructionOperationStatus.LuaFailure; + } + + int resultStart = state.Top - 1; + StringMarshaller.Push(state, instruction); + Address.Push(state, address); + if (!state.TryCall(2, LuaState.MultipleResults).IsOk) + { + return InstructionOperationStatus.LuaFailure; + } + + targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + return targetStatus; + } + + int resultIndex = resultStart + 1; + if (state.Top < resultIndex) + { + return InstructionOperationStatus.InvalidResult; + } + + if (state.IsNil(resultIndex)) + { + return InstructionOperationStatus.InstructionRejected; + } + + if (!state.IsTable(resultIndex)) + { + return InstructionOperationStatus.InvalidResult; + } + + int tableIndex = state.AbsoluteIndex(resultIndex); + UIntPtr rawLength = state.RawLength(tableIndex); + if (rawLength > int.MaxValue) + { + return InstructionOperationStatus.InvalidResult; + } + + requiredLength = (int) rawLength; + if (requiredLength > destination.Length) + { + return InstructionOperationStatus.DestinationTooSmall; + } + + if (!ValidateByteTable(state, tableIndex, requiredLength)) + { + requiredLength = 0; + return InstructionOperationStatus.InvalidResult; + } + + targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + requiredLength = 0; + return targetStatus; + } + + for (int index = 0; index < requiredLength; index++) + { + _ = state.RawGetSequenceItem(tableIndex, index); + _ = state.TryReadInteger(-1, out long value); + destination[index] = (byte) value; + state.Pop(1); + } + + written = requiredLength; + return InstructionOperationStatus.Success; + } + catch (LuaException) + { + written = 0; + requiredLength = 0; + return InstructionOperationStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + private static bool ValidateByteTable(LuaState state, int tableIndex, int length) + { + for (int index = 0; index < length; index++) + { + _ = state.RawGetSequenceItem(tableIndex, index); + bool valid = state.TryReadInteger(-1, out long value) && value is >= byte.MinValue and <= byte.MaxValue; + state.Pop(1); + if (!valid) + { + return false; + } + } + + return true; + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs index d9974b7c..5ea03595 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Text; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; @@ -31,112 +32,145 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public static class InstructionDisassembler { - private static readonly LuaRef SDisassemble = new(); - private static readonly LuaRef SSplitDisassembledString = new(); - - /// Disassembles and parses one target instruction with an explicit bound for CE's raw UTF-8 line. - /// The CE-observed selected PID and instruction profile used to validate the address. - /// The target address supplied to CE's disassemble global. - /// The largest raw display-line byte length accepted before any managed text is decoded. - /// The copied parsed result only when the returned status is . - /// The raw display-line UTF-8 byte length when CE supplied a string; otherwise zero. - /// A profile, capacity, availability, protected-call, target, or result-shape outcome. - /// is negative. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "Both protected Lua calls and all stack-borrowed text remain in one lifetime frame.")] - public static InstructionOperationStatus TryDisassemble(InstructionTargetProfile targetProfile, Address address, - int maximumUtf8Bytes, out InstructionDisassembly instruction, out int requiredUtf8Bytes) - { - ArgumentOutOfRangeException.ThrowIfNegative(maximumUtf8Bytes); - instruction = default; - requiredUtf8Bytes = 0; - - var profileStatus = targetProfile.Validate(address); - if (profileStatus != InstructionOperationStatus.Success) return profileStatus; - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - var targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) return targetStatus; - - var status = PushGlobal(state, SDisassemble, "disassemble"u8); - if (status != InstructionOperationStatus.Success) return status; - - Address.Push(state, address); - if (!state.TryCall(1, 1).IsOk) return InstructionOperationStatus.LuaFailure; - if (!state.TryReadUtf8(-1, out var line)) return InstructionOperationStatus.InvalidResult; - - requiredUtf8Bytes = line.Length; - if (line.Length > maximumUtf8Bytes) return InstructionOperationStatus.OutputTooLong; - - byte[] lineCopy = new byte[line.Length]; - line.CopyTo(lineCopy); - - targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) - { - requiredUtf8Bytes = 0; - return targetStatus; - } - - status = PushGlobal(state, SSplitDisassembledString, "splitDisassembledString"u8); - if (status != InstructionOperationStatus.Success) return status; - - state.PushString(lineCopy); - if (!state.TryCall(1, 4).IsOk) return InstructionOperationStatus.LuaFailure; - - if (!state.TryReadUtf8(-4, out var addressUtf8) || - !state.TryReadUtf8(-3, out var bytesUtf8) || - !state.TryReadUtf8(-2, out var opcodeUtf8) || - !state.TryReadUtf8(-1, out var extraUtf8)) - return InstructionOperationStatus.InvalidResult; - - var copiedLength = checked(addressUtf8.Length + bytesUtf8.Length + opcodeUtf8.Length + extraUtf8.Length); - if (copiedLength > maximumUtf8Bytes) return InstructionOperationStatus.OutputTooLong; - - targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) - { - requiredUtf8Bytes = 0; - return targetStatus; - } - - instruction = new InstructionDisassembly(address, Encoding.UTF8.GetString(addressUtf8), - Encoding.UTF8.GetString(bytesUtf8), Encoding.UTF8.GetString(opcodeUtf8), Encoding.UTF8.GetString(extraUtf8), - copiedLength); - return InstructionOperationStatus.Success; - } - catch (LuaException) - { - instruction = default; - requiredUtf8Bytes = 0; - return InstructionOperationStatus.LuaFailure; - } - catch (OverflowException) - { - instruction = default; - requiredUtf8Bytes = 0; - return InstructionOperationStatus.InvalidResult; - } - finally - { - state.SetTop(top); - } - } - - private static InstructionOperationStatus PushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) - { - return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch - { - LuaGlobalPushStatus.Success => InstructionOperationStatus.Success, - LuaGlobalPushStatus.Unavailable => InstructionOperationStatus.GlobalUnavailable, - _ => InstructionOperationStatus.LuaFailure, - }; - } - + private static readonly LuaRef SDisassemble = new(); + private static readonly LuaRef SSplitDisassembledString = new(); + + /// Disassembles and parses one target instruction with an explicit bound for CE's raw UTF-8 line. + /// The CE-observed selected PID and instruction profile used to validate the address. + /// The target address supplied to CE's disassemble global. + /// The largest raw display-line byte length accepted before any managed text is decoded. + /// + /// The copied parsed result only when the returned status is + /// . + /// + /// The raw display-line UTF-8 byte length when CE supplied a string; otherwise zero. + /// A profile, capacity, availability, protected-call, target, or result-shape outcome. + /// is negative. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "Both protected Lua calls and all stack-borrowed text remain in one lifetime frame.")] + public static InstructionOperationStatus TryDisassemble(InstructionTargetProfile targetProfile, Address address, + int maximumUtf8Bytes, out InstructionDisassembly instruction, out int requiredUtf8Bytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(maximumUtf8Bytes); + instruction = default; + requiredUtf8Bytes = 0; + + InstructionOperationStatus profileStatus = targetProfile.Validate(address); + if (profileStatus != InstructionOperationStatus.Success) + { + return profileStatus; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + InstructionOperationStatus targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + return targetStatus; + } + + InstructionOperationStatus status = PushGlobal(state, SDisassemble, "disassemble"u8); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + Address.Push(state, address); + if (!state.TryCall(1, 1).IsOk) + { + return InstructionOperationStatus.LuaFailure; + } + + if (!state.TryReadUtf8(-1, out ReadOnlySpan line)) + { + return InstructionOperationStatus.InvalidResult; + } + + requiredUtf8Bytes = line.Length; + if (line.Length > maximumUtf8Bytes) + { + return InstructionOperationStatus.OutputTooLong; + } + + byte[] lineCopy = new byte[line.Length]; + line.CopyTo(lineCopy); + + targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + requiredUtf8Bytes = 0; + return targetStatus; + } + + status = PushGlobal(state, SSplitDisassembledString, "splitDisassembledString"u8); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + state.PushString(lineCopy); + if (!state.TryCall(1, 4).IsOk) + { + return InstructionOperationStatus.LuaFailure; + } + + if (!state.TryReadUtf8(-4, out ReadOnlySpan addressUtf8) || + !state.TryReadUtf8(-3, out ReadOnlySpan bytesUtf8) || + !state.TryReadUtf8(-2, out ReadOnlySpan opcodeUtf8) || + !state.TryReadUtf8(-1, out ReadOnlySpan extraUtf8)) + { + return InstructionOperationStatus.InvalidResult; + } + + int copiedLength = checked(addressUtf8.Length + bytesUtf8.Length + opcodeUtf8.Length + extraUtf8.Length); + if (copiedLength > maximumUtf8Bytes) + { + return InstructionOperationStatus.OutputTooLong; + } + + targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + requiredUtf8Bytes = 0; + return targetStatus; + } + + instruction = new InstructionDisassembly(address, Encoding.UTF8.GetString(addressUtf8), + Encoding.UTF8.GetString(bytesUtf8), Encoding.UTF8.GetString(opcodeUtf8), + Encoding.UTF8.GetString(extraUtf8), + copiedLength); + return InstructionOperationStatus.Success; + } + catch (LuaException) + { + instruction = default; + requiredUtf8Bytes = 0; + return InstructionOperationStatus.LuaFailure; + } + catch (OverflowException) + { + instruction = default; + requiredUtf8Bytes = 0; + return InstructionOperationStatus.InvalidResult; + } + finally + { + state.SetTop(top); + } + } + + private static InstructionOperationStatus PushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) + { + return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch + { + LuaGlobalPushStatus.Success => InstructionOperationStatus.Success, + LuaGlobalPushStatus.Unavailable => InstructionOperationStatus.GlobalUnavailable, + _ => InstructionOperationStatus.LuaFailure + }; + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembly.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembly.cs index 9b173377..981053af 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembly.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembly.cs @@ -16,9 +16,9 @@ namespace CheatEngine.SDK.Engine.Assembly; /// The additional annotation column returned by CE's helper. /// The total copied UTF-8 byte length of the four text fields. public readonly record struct InstructionDisassembly( - Address Address, - string AddressText, - string Bytes, - string Opcode, - string Extra, - int Utf8ByteLength); + Address Address, + string AddressText, + string Bytes, + string Opcode, + string Extra, + int Utf8ByteLength); diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs index fe31126b..bd87fe70 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; @@ -19,93 +20,140 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public static class InstructionNavigator { - private static readonly LuaRef SGetInstructionSize = new(); - private static readonly LuaRef SGetPreviousOpcode = new(); - - /// Gets CE's positive byte length for the instruction that starts at . - /// The CE-observed selected PID and instruction profile used to validate the address. - /// The target address supplied to CE's getInstructionSize global. - /// The positive byte length only when the status is . - /// A profile, availability, protected-call, or result-shape outcome. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InstructionOperationStatus TryGetLength(InstructionTargetProfile targetProfile, Address address, out int length) - { - length = 0; - var profileStatus = targetProfile.Validate(address); - if (profileStatus != InstructionOperationStatus.Success) return profileStatus; - - var status = TryCallAddressGlobal(targetProfile, SGetInstructionSize, "getInstructionSize"u8, address, out var value); - if (status != InstructionOperationStatus.Success) return status; - if (value is <= 0 or > int.MaxValue) return InstructionOperationStatus.InvalidResult; - - length = (int)value; - return InstructionOperationStatus.Success; - } - - /// Gets CE's estimated previous opcode address for a profile-qualified target address. - /// The CE-observed selected PID and instruction profile used to validate input and output addresses. - /// The target address supplied to CE's getPreviousOpcode global. - /// The estimated target address only when the status is . - /// A profile, availability, protected-call, or result-shape outcome. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InstructionOperationStatus TryGetPrevious(InstructionTargetProfile targetProfile, Address address, out Address previous) - { - previous = Address.Zero; - var profileStatus = targetProfile.Validate(address); - if (profileStatus != InstructionOperationStatus.Success) return profileStatus; - - var status = TryCallAddressGlobal(targetProfile, SGetPreviousOpcode, "getPreviousOpcode"u8, address, out var value); - if (status != InstructionOperationStatus.Success) return status; - - var candidate = Address.FromInt64(value); - if (targetProfile.Validate(candidate) != InstructionOperationStatus.Success) - return InstructionOperationStatus.AddressExceedsProfileWidth; - - previous = candidate; - return InstructionOperationStatus.Success; - } - - private static InstructionOperationStatus TryCallAddressGlobal(InstructionTargetProfile targetProfile, LuaRef cache, - ReadOnlySpan name, Address address, - out long value) - { - value = 0; - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - var targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) return targetStatus; - - var global = LuaGlobalFunctions.TryPushWithStatus(state, cache, name); - if (global == LuaGlobalPushStatus.Unavailable) return InstructionOperationStatus.GlobalUnavailable; - if (global != LuaGlobalPushStatus.Success) return InstructionOperationStatus.LuaFailure; - - Address.Push(state, address); - if (!state.TryCall(1, 1).IsOk) return InstructionOperationStatus.LuaFailure; - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out value)) - return InstructionOperationStatus.InvalidResult; - - targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); - if (targetStatus != InstructionOperationStatus.Success) - { - value = 0; - return targetStatus; - } - - return InstructionOperationStatus.Success; - } - catch (LuaException) - { - value = 0; - return InstructionOperationStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } + private static readonly LuaRef SGetInstructionSize = new(); + private static readonly LuaRef SGetPreviousOpcode = new(); + + /// Gets CE's positive byte length for the instruction that starts at . + /// The CE-observed selected PID and instruction profile used to validate the address. + /// The target address supplied to CE's getInstructionSize global. + /// + /// The positive byte length only when the status is + /// . + /// + /// A profile, availability, protected-call, or result-shape outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InstructionOperationStatus TryGetLength(InstructionTargetProfile targetProfile, Address address, + out int length) + { + length = 0; + InstructionOperationStatus profileStatus = targetProfile.Validate(address); + if (profileStatus != InstructionOperationStatus.Success) + { + return profileStatus; + } + + InstructionOperationStatus status = TryCallAddressGlobal(targetProfile, SGetInstructionSize, + "getInstructionSize"u8, address, out long value); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + if (value is <= 0 or > int.MaxValue) + { + return InstructionOperationStatus.InvalidResult; + } + + length = (int) value; + return InstructionOperationStatus.Success; + } + + /// Gets CE's estimated previous opcode address for a profile-qualified target address. + /// + /// The CE-observed selected PID and instruction profile used to validate input and output + /// addresses. + /// + /// The target address supplied to CE's getPreviousOpcode global. + /// + /// The estimated target address only when the status is + /// . + /// + /// A profile, availability, protected-call, or result-shape outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InstructionOperationStatus TryGetPrevious(InstructionTargetProfile targetProfile, Address address, + out Address previous) + { + previous = Address.Zero; + InstructionOperationStatus profileStatus = targetProfile.Validate(address); + if (profileStatus != InstructionOperationStatus.Success) + { + return profileStatus; + } + + InstructionOperationStatus status = TryCallAddressGlobal(targetProfile, SGetPreviousOpcode, + "getPreviousOpcode"u8, address, out long value); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + Address candidate = Address.FromInt64(value); + if (targetProfile.Validate(candidate) != InstructionOperationStatus.Success) + { + return InstructionOperationStatus.AddressExceedsProfileWidth; + } + + previous = candidate; + return InstructionOperationStatus.Success; + } + + private static InstructionOperationStatus TryCallAddressGlobal(InstructionTargetProfile targetProfile, LuaRef cache, + ReadOnlySpan name, Address address, + out long value) + { + value = 0; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + InstructionOperationStatus targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + return targetStatus; + } + + LuaGlobalPushStatus global = LuaGlobalFunctions.TryPushWithStatus(state, cache, name); + if (global == LuaGlobalPushStatus.Unavailable) + { + return InstructionOperationStatus.GlobalUnavailable; + } + + if (global != LuaGlobalPushStatus.Success) + { + return InstructionOperationStatus.LuaFailure; + } + + Address.Push(state, address); + if (!state.TryCall(1, 1).IsOk) + { + return InstructionOperationStatus.LuaFailure; + } + + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out value)) + { + return InstructionOperationStatus.InvalidResult; + } + + targetStatus = InstructionProfiles.TryVerifyCurrent(state, targetProfile.Target); + if (targetStatus != InstructionOperationStatus.Success) + { + value = 0; + return targetStatus; + } + + return InstructionOperationStatus.Success; + } + catch (LuaException) + { + value = 0; + return InstructionOperationStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs index 9a1c2757..3e564957 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs @@ -9,39 +9,42 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public enum InstructionOperationStatus { - /// The operation completed and copied its complete result. - Success, + /// The operation completed and copied its complete result. + Success, - /// The instruction profile does not name a supported x86, x64, ARM32, or ARM64 architecture with its matching width. - InvalidProfile, + /// + /// The instruction profile does not name a supported x86, x64, ARM32, or ARM64 architecture with its matching + /// width. + /// + InvalidProfile, - /// The input or returned address has bits outside the caller-declared profile width. - AddressExceedsProfileWidth, + /// The input or returned address has bits outside the caller-declared profile width. + AddressExceedsProfileWidth, - /// Cheat Engine did not report a positive selected target process identifier. - TargetNotSelected, + /// Cheat Engine did not report a positive selected target process identifier. + TargetNotSelected, - /// - /// Cheat Engine reported a different selected target before and after an instruction profile observation or - /// operation. - /// - TargetChanged, + /// + /// Cheat Engine reported a different selected target before and after an instruction profile observation or + /// operation. + /// + TargetChanged, - /// The caller-owned destination cannot hold the complete byte result; no byte was written. - DestinationTooSmall, + /// The caller-owned destination cannot hold the complete byte result; no byte was written. + DestinationTooSmall, - /// The raw UTF-8 line exceeds the explicit maximum before the SDK decodes or publishes text. - OutputTooLong, + /// The raw UTF-8 line exceeds the explicit maximum before the SDK decodes or publishes text. + OutputTooLong, - /// Cheat Engine rejected an otherwise well-formed assembly instruction. - InstructionRejected, + /// Cheat Engine rejected an otherwise well-formed assembly instruction. + InstructionRejected, - /// The required Cheat Engine Lua global was missing or was not callable. - GlobalUnavailable, + /// The required Cheat Engine Lua global was missing or was not callable. + GlobalUnavailable, - /// A protected Lua lookup, argument push, or call failed. - LuaFailure, + /// A protected Lua lookup, argument push, or call failed. + LuaFailure, - /// Cheat Engine returned a value whose type, table elements, or instruction length violated the contract. - InvalidResult, + /// Cheat Engine returned a value whose type, table elements, or instruction length violated the contract. + InvalidResult } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs index a5946bde..1328dc0d 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; @@ -22,43 +23,65 @@ namespace CheatEngine.SDK.Engine.Assembly; [StructLayout(LayoutKind.Sequential)] public readonly record struct InstructionProfile { - internal InstructionProfile(CheatEngineArchitecture architecture, PointerSize addressWidth) - { - Architecture = architecture; - AddressWidth = addressWidth; - } + internal InstructionProfile(CheatEngineArchitecture architecture, PointerSize addressWidth) + { + Architecture = architecture; + AddressWidth = addressWidth; + } + + /// Gets the observed target instruction architecture. + public CheatEngineArchitecture Architecture + { + get; + } - /// Gets the observed target instruction architecture. - public CheatEngineArchitecture Architecture { get; } + /// Gets the observed target pointer and instruction-address width. + public PointerSize AddressWidth + { + get; + } - /// Gets the observed target pointer and instruction-address width. - public PointerSize AddressWidth { get; } + /// Gets the conventional x86 instruction profile. + public static InstructionProfile X86 + { + get; + } = new(CheatEngineArchitecture.X86, PointerSize.Bit32); - /// Gets the conventional x86 instruction profile. - public static InstructionProfile X86 { get; } = new(CheatEngineArchitecture.X86, PointerSize.Bit32); + /// Gets the conventional x64 instruction profile. + public static InstructionProfile X64 + { + get; + } = new(CheatEngineArchitecture.X64, PointerSize.Bit64); - /// Gets the conventional x64 instruction profile. - public static InstructionProfile X64 { get; } = new(CheatEngineArchitecture.X64, PointerSize.Bit64); + /// Gets the conventional 32-bit ARM instruction profile. + public static InstructionProfile Arm32 + { + get; + } = new(CheatEngineArchitecture.Arm32, PointerSize.Bit32); - /// Gets the conventional 32-bit ARM instruction profile. - public static InstructionProfile Arm32 { get; } = new(CheatEngineArchitecture.Arm32, PointerSize.Bit32); + /// Gets the conventional 64-bit ARM instruction profile. + public static InstructionProfile Arm64 + { + get; + } = new(CheatEngineArchitecture.Arm64, PointerSize.Bit64); - /// Gets the conventional 64-bit ARM instruction profile. - public static InstructionProfile Arm64 { get; } = new(CheatEngineArchitecture.Arm64, PointerSize.Bit64); + /// Gets whether the architecture and width form one supported instruction profile. + public bool IsValid => Architecture switch + { + CheatEngineArchitecture.X86 or CheatEngineArchitecture.Arm32 => AddressWidth == PointerSize.Bit32, + CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64 => AddressWidth == PointerSize.Bit64, + _ => false + }; - /// Gets whether the architecture and width form one supported instruction profile. - public bool IsValid => Architecture switch - { - CheatEngineArchitecture.X86 or CheatEngineArchitecture.Arm32 => AddressWidth == PointerSize.Bit32, - CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64 => AddressWidth == PointerSize.Bit64, - _ => false, - }; + internal InstructionOperationStatus Validate(Address address) + { + if (!IsValid) + { + return InstructionOperationStatus.InvalidProfile; + } - internal InstructionOperationStatus Validate(Address address) - { - if (!IsValid) return InstructionOperationStatus.InvalidProfile; - return AddressWidth == PointerSize.Bit32 && address.Value > uint.MaxValue - ? InstructionOperationStatus.AddressExceedsProfileWidth - : InstructionOperationStatus.Success; - } + return AddressWidth == PointerSize.Bit32 && address.Value > uint.MaxValue + ? InstructionOperationStatus.AddressExceedsProfileWidth + : InstructionOperationStatus.Success; + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs index f02cf99c..c371053a 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs @@ -1,3 +1,5 @@ +using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Lua.Calls; @@ -27,128 +29,179 @@ namespace CheatEngine.SDK.Engine.Assembly; /// public static class InstructionProfiles { - private static readonly LuaRef SGetOpenedProcessId = new(); - private static readonly LuaRef STargetIs64Bit = new(); - private static readonly LuaRef STargetIsX86 = new(); - private static readonly LuaRef STargetIsArm = new(); - - /// Observes the current CE target and returns a profile only when its selected PID and ISA probes agree. - /// The copied profile and selected PID only when the returned status is success. - /// A target, availability, protected-Lua, or profile-consistency outcome. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InstructionOperationStatus TryObserveCurrent(out InstructionTargetProfile targetProfile) - { - targetProfile = default; - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - var status = TryGetCurrentTarget(state, out var firstTarget); - if (status != InstructionOperationStatus.Success) return status; - - status = TryCallBoolean(state, STargetIs64Bit, "targetIs64Bit"u8, out var is64Bit); - if (status != InstructionOperationStatus.Success) return status; - status = TryCallBoolean(state, STargetIsX86, "targetIsX86"u8, out var isX86); - if (status != InstructionOperationStatus.Success) return status; - status = TryCallBoolean(state, STargetIsArm, "targetIsArm"u8, out var isArm); - if (status != InstructionOperationStatus.Success) return status; - - if (!TryCreateProfile(is64Bit, isX86, isArm, out var profile)) - return InstructionOperationStatus.InvalidProfile; - - status = TryGetCurrentTarget(state, out var finalTarget); - if (status != InstructionOperationStatus.Success) return status; - if (firstTarget != finalTarget) return InstructionOperationStatus.TargetChanged; - - targetProfile = new InstructionTargetProfile(firstTarget, profile); - return InstructionOperationStatus.Success; - } - catch (LuaException) - { - targetProfile = default; - return InstructionOperationStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - internal static InstructionOperationStatus TryVerifyCurrent(LuaState state, TargetProcessId expectedTarget) - { - var status = TryGetCurrentTarget(state, out var actualTarget); - return status == InstructionOperationStatus.Success && actualTarget != expectedTarget - ? InstructionOperationStatus.TargetChanged - : status; - } - - private static InstructionOperationStatus TryGetCurrentTarget(LuaState state, out TargetProcessId target) - { - target = default; - var status = TryPushGlobal(state, SGetOpenedProcessId, "getOpenedProcessID"u8); - if (status != InstructionOperationStatus.Success) return status; - if (!state.TryCall(0, 1).IsOk) return InstructionOperationStatus.LuaFailure; - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out var value) || - value is < 0 or > int.MaxValue) - return InstructionOperationStatus.InvalidResult; - if (value == 0) return InstructionOperationStatus.TargetNotSelected; - - target = new TargetProcessId((int)value); - return InstructionOperationStatus.Success; - } - - private static InstructionOperationStatus TryCallBoolean(LuaState state, LuaRef cache, global::System.ReadOnlySpan name, - out bool value) - { - value = default; - var status = TryPushGlobal(state, cache, name); - if (status != InstructionOperationStatus.Success) return status; - if (!state.TryCall(0, 1).IsOk) return InstructionOperationStatus.LuaFailure; - if (state.TypeOf(-1) != LuaType.Boolean) return InstructionOperationStatus.InvalidResult; - - value = state.ToBoolean(-1); - return InstructionOperationStatus.Success; - } - - private static InstructionOperationStatus TryPushGlobal(LuaState state, LuaRef cache, global::System.ReadOnlySpan name) - { - return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch - { - LuaGlobalPushStatus.Success => InstructionOperationStatus.Success, - LuaGlobalPushStatus.Unavailable => InstructionOperationStatus.GlobalUnavailable, - _ => InstructionOperationStatus.LuaFailure, - }; - } - - private static bool TryCreateProfile(bool is64Bit, bool isX86, bool isArm, out InstructionProfile profile) - { - if (isX86 && isArm) - { - profile = default; - return false; - } - - if (isX86) - { - profile = is64Bit ? default : InstructionProfile.X86; - return !is64Bit; - } - - if (isArm) - { - profile = is64Bit ? InstructionProfile.Arm64 : InstructionProfile.Arm32; - return true; - } - - if (is64Bit) - { - profile = InstructionProfile.X64; - return true; - } - - profile = default; - return false; - } + private static readonly LuaRef SGetOpenedProcessId = new(); + private static readonly LuaRef STargetIs64Bit = new(); + private static readonly LuaRef STargetIsX86 = new(); + private static readonly LuaRef STargetIsArm = new(); + + /// Observes the current CE target and returns a profile only when its selected PID and ISA probes agree. + /// The copied profile and selected PID only when the returned status is success. + /// A target, availability, protected-Lua, or profile-consistency outcome. + /// + /// The plugin is not enabled or the calling thread has no Lua + /// state. + /// + [RequiresPluginEnabled] + public static InstructionOperationStatus TryObserveCurrent(out InstructionTargetProfile targetProfile) + { + targetProfile = default; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + InstructionOperationStatus status = TryGetCurrentTarget(state, out TargetProcessId firstTarget); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + status = TryCallBoolean(state, STargetIs64Bit, "targetIs64Bit"u8, out bool is64Bit); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + status = TryCallBoolean(state, STargetIsX86, "targetIsX86"u8, out bool isX86); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + status = TryCallBoolean(state, STargetIsArm, "targetIsArm"u8, out bool isArm); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + if (!TryCreateProfile(is64Bit, isX86, isArm, out InstructionProfile profile)) + { + return InstructionOperationStatus.InvalidProfile; + } + + status = TryGetCurrentTarget(state, out TargetProcessId finalTarget); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + if (firstTarget != finalTarget) + { + return InstructionOperationStatus.TargetChanged; + } + + targetProfile = new InstructionTargetProfile(firstTarget, profile); + return InstructionOperationStatus.Success; + } + catch (LuaException) + { + targetProfile = default; + return InstructionOperationStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + internal static InstructionOperationStatus TryVerifyCurrent(LuaState state, TargetProcessId expectedTarget) + { + InstructionOperationStatus status = TryGetCurrentTarget(state, out TargetProcessId actualTarget); + return status == InstructionOperationStatus.Success && actualTarget != expectedTarget + ? InstructionOperationStatus.TargetChanged + : status; + } + + private static InstructionOperationStatus TryGetCurrentTarget(LuaState state, out TargetProcessId target) + { + target = default; + InstructionOperationStatus status = TryPushGlobal(state, SGetOpenedProcessId, "getOpenedProcessID"u8); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + if (!state.TryCall(0, 1).IsOk) + { + return InstructionOperationStatus.LuaFailure; + } + + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || + value is < 0 or > int.MaxValue) + { + return InstructionOperationStatus.InvalidResult; + } + + if (value == 0) + { + return InstructionOperationStatus.TargetNotSelected; + } + + target = new TargetProcessId((int) value); + return InstructionOperationStatus.Success; + } + + private static InstructionOperationStatus TryCallBoolean(LuaState state, LuaRef cache, ReadOnlySpan name, + out bool value) + { + value = default; + InstructionOperationStatus status = TryPushGlobal(state, cache, name); + if (status != InstructionOperationStatus.Success) + { + return status; + } + + if (!state.TryCall(0, 1).IsOk) + { + return InstructionOperationStatus.LuaFailure; + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return InstructionOperationStatus.InvalidResult; + } + + value = state.ToBoolean(-1); + return InstructionOperationStatus.Success; + } + + private static InstructionOperationStatus TryPushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) + { + return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch + { + LuaGlobalPushStatus.Success => InstructionOperationStatus.Success, + LuaGlobalPushStatus.Unavailable => InstructionOperationStatus.GlobalUnavailable, + _ => InstructionOperationStatus.LuaFailure + }; + } + + private static bool TryCreateProfile(bool is64Bit, bool isX86, bool isArm, out InstructionProfile profile) + { + if (isX86 && isArm) + { + profile = default; + return false; + } + + if (isX86) + { + profile = is64Bit ? default : InstructionProfile.X86; + return !is64Bit; + } + + if (isArm) + { + profile = is64Bit ? InstructionProfile.Arm64 : InstructionProfile.Arm32; + return true; + } + + if (is64Bit) + { + profile = InstructionProfile.X64; + return true; + } + + profile = default; + return false; + } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs index b7952354..d0cae993 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs @@ -1,10 +1,14 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.SDK.Engine.Assembly; -/// A copied selected target process identifier and instruction profile observed through CE's protected Lua globals. +/// +/// A copied selected target process identifier and instruction profile observed through CE's protected Lua +/// globals. +/// /// /// Instances are created only by . Instruction operations /// re-check the target process identifier before and after their CE call and report @@ -15,21 +19,31 @@ namespace CheatEngine.SDK.Engine.Assembly; [StructLayout(LayoutKind.Sequential)] public readonly record struct InstructionTargetProfile { - internal InstructionTargetProfile(TargetProcessId target, InstructionProfile profile) - { - Target = target; - Profile = profile; - } + internal InstructionTargetProfile(TargetProcessId target, InstructionProfile profile) + { + Target = target; + Profile = profile; + } + + /// Gets the positive CE-selected process identifier observed with the profile; this is not an incarnation. + public TargetProcessId Target + { + get; + } - /// Gets the positive CE-selected process identifier observed with the profile; this is not an incarnation. - public TargetProcessId Target { get; } + /// Gets the target instruction architecture and address width observed with . + public InstructionProfile Profile + { + get; + } - /// Gets the target instruction architecture and address width observed with . - public InstructionProfile Profile { get; } + internal InstructionOperationStatus Validate(Address address) + { + if (Target.Value <= 0) + { + return InstructionOperationStatus.TargetNotSelected; + } - internal InstructionOperationStatus Validate(Address address) - { - if (Target.Value <= 0) return InstructionOperationStatus.TargetNotSelected; - return Profile.Validate(address); - } + return Profile.Validate(address); + } } diff --git a/libs/CheatEngine.SDK.Engine/Enums/BreakpointMethod.cs b/libs/CheatEngine.SDK.Engine/Enums/BreakpointMethod.cs index b028bc8b..7a319f3f 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/BreakpointMethod.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/BreakpointMethod.cs @@ -7,12 +7,12 @@ namespace CheatEngine.SDK.Engine.Enums; /// Values verified against defines.lua of Cheat Engine 7.7.0.10621. public enum BreakpointMethod { - /// A software breakpoint: the instruction is replaced by int3. Execute triggers only. CE: bpmInt3. - Int3 = 0, + /// A software breakpoint: the instruction is replaced by int3. Execute triggers only. CE: bpmInt3. + Int3 = 0, - /// A hardware breakpoint in a debug register: at most four, any trigger. CE: bpmDebugRegister. - DebugRegister = 1, + /// A hardware breakpoint in a debug register: at most four, any trigger. CE: bpmDebugRegister. + DebugRegister = 1, - /// A page-protection exception: no register limit, slower. CE: bpmException. - Exception = 2 + /// A page-protection exception: no register limit, slower. CE: bpmException. + Exception = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/BreakpointTrigger.cs b/libs/CheatEngine.SDK.Engine/Enums/BreakpointTrigger.cs index 7391f20a..64683527 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/BreakpointTrigger.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/BreakpointTrigger.cs @@ -8,12 +8,12 @@ namespace CheatEngine.SDK.Engine.Enums; /// Values verified against defines.lua of Cheat Engine 7.7.0.10621. public enum BreakpointTrigger { - /// The instruction at the address is executed; the default. CE: bptExecute. - Execute = 0, + /// The instruction at the address is executed; the default. CE: bptExecute. + Execute = 0, - /// The range is read or written. CE: bptAccess. - Access = 1, + /// The range is read or written. CE: bptAccess. + Access = 1, - /// The range is written. CE: bptWrite. - Write = 2 + /// The range is written. CE: bptWrite. + Write = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs b/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs index 67296906..b793f0af 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs @@ -27,317 +27,335 @@ namespace CheatEngine.SDK.Engine.Enums; /// public static class CEEnumNames { - /// The vt* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this VariableType value) - { - return value switch - { - VariableType.Byte => "vtByte"u8, - VariableType.Word => "vtWord"u8, - VariableType.Dword => "vtDword"u8, - VariableType.Qword => "vtQword"u8, - VariableType.Single => "vtSingle"u8, - VariableType.Double => "vtDouble"u8, - VariableType.String => "vtString"u8, - VariableType.WideString => "vtWideString"u8, - VariableType.ByteArray => "vtByteArray"u8, - VariableType.Binary => "vtBinary"u8, - VariableType.All => "vtAll"u8, - VariableType.AutoAssembler => "vtAutoAssembler"u8, - VariableType.Pointer => "vtPointer"u8, - VariableType.Custom => "vtCustom"u8, - VariableType.Grouped => "vtGrouped"u8, - _ => default - }; - } + /// The vt* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this VariableType value) + { + return value switch + { + VariableType.Byte => "vtByte"u8, + VariableType.Word => "vtWord"u8, + VariableType.Dword => "vtDword"u8, + VariableType.Qword => "vtQword"u8, + VariableType.Single => "vtSingle"u8, + VariableType.Double => "vtDouble"u8, + VariableType.String => "vtString"u8, + VariableType.WideString => "vtWideString"u8, + VariableType.ByteArray => "vtByteArray"u8, + VariableType.Binary => "vtBinary"u8, + VariableType.All => "vtAll"u8, + VariableType.AutoAssembler => "vtAutoAssembler"u8, + VariableType.Pointer => "vtPointer"u8, + VariableType.Custom => "vtCustom"u8, + VariableType.Grouped => "vtGrouped"u8, + _ => default + }; + } - /// Parses a vt* name (vtUnicodeString is accepted as ). - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out VariableType value) - { - if (ceName.SequenceEqual("vtUnicodeString"u8)) - { - value = VariableType.WideString; - return true; - } + /// Parses a vt* name (vtUnicodeString is accepted as ). + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out VariableType value) + { + if (ceName.SequenceEqual("vtUnicodeString"u8)) + { + value = VariableType.WideString; + return true; + } - for (var candidate = VariableType.Byte; candidate <= VariableType.Grouped; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + for (VariableType candidate = VariableType.Byte; candidate <= VariableType.Grouped; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The so* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this ScanOption value) - { - return value switch - { - ScanOption.UnknownValue => "soUnknownValue"u8, - ScanOption.ExactValue => "soExactValue"u8, - ScanOption.ValueBetween => "soValueBetween"u8, - ScanOption.BiggerThan => "soBiggerThan"u8, - ScanOption.SmallerThan => "soSmallerThan"u8, - ScanOption.IncreasedValue => "soIncreasedValue"u8, - ScanOption.IncreasedValueBy => "soIncreasedValueBy"u8, - ScanOption.DecreasedValue => "soDecreasedValue"u8, - ScanOption.DecreasedValueBy => "soDecreasedValueBy"u8, - ScanOption.Changed => "soChanged"u8, - ScanOption.Unchanged => "soUnchanged"u8, - _ => default - }; - } + /// The so* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this ScanOption value) + { + return value switch + { + ScanOption.UnknownValue => "soUnknownValue"u8, + ScanOption.ExactValue => "soExactValue"u8, + ScanOption.ValueBetween => "soValueBetween"u8, + ScanOption.BiggerThan => "soBiggerThan"u8, + ScanOption.SmallerThan => "soSmallerThan"u8, + ScanOption.IncreasedValue => "soIncreasedValue"u8, + ScanOption.IncreasedValueBy => "soIncreasedValueBy"u8, + ScanOption.DecreasedValue => "soDecreasedValue"u8, + ScanOption.DecreasedValueBy => "soDecreasedValueBy"u8, + ScanOption.Changed => "soChanged"u8, + ScanOption.Unchanged => "soUnchanged"u8, + _ => default + }; + } - /// Parses a so* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out ScanOption value) - { - for (var candidate = ScanOption.UnknownValue; candidate <= ScanOption.Unchanged; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses a so* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out ScanOption value) + { + for (ScanOption candidate = ScanOption.UnknownValue; candidate <= ScanOption.Unchanged; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The rt* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this RoundingType value) - { - return value switch - { - RoundingType.Rounded => "rtRounded"u8, - RoundingType.ExtremeRounded => "rtExtremerounded"u8, - RoundingType.Truncated => "rtTruncated"u8, - _ => default - }; - } + /// The rt* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this RoundingType value) + { + return value switch + { + RoundingType.Rounded => "rtRounded"u8, + RoundingType.ExtremeRounded => "rtExtremerounded"u8, + RoundingType.Truncated => "rtTruncated"u8, + _ => default + }; + } - /// Parses an rt* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out RoundingType value) - { - for (var candidate = RoundingType.Rounded; candidate <= RoundingType.Truncated; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses an rt* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out RoundingType value) + { + for (RoundingType candidate = RoundingType.Rounded; candidate <= RoundingType.Truncated; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The fsm* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this FastScanMethod value) - { - return value switch - { - FastScanMethod.NotAligned => "fsmNotAligned"u8, - FastScanMethod.Aligned => "fsmAligned"u8, - FastScanMethod.LastDigits => "fsmLastDigits"u8, - _ => default - }; - } + /// The fsm* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this FastScanMethod value) + { + return value switch + { + FastScanMethod.NotAligned => "fsmNotAligned"u8, + FastScanMethod.Aligned => "fsmAligned"u8, + FastScanMethod.LastDigits => "fsmLastDigits"u8, + _ => default + }; + } - /// Parses an fsm* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out FastScanMethod value) - { - for (var candidate = FastScanMethod.NotAligned; candidate <= FastScanMethod.LastDigits; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses an fsm* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out FastScanMethod value) + { + for (FastScanMethod candidate = FastScanMethod.NotAligned; candidate <= FastScanMethod.LastDigits; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The bpm* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this BreakpointMethod value) - { - return value switch - { - BreakpointMethod.Int3 => "bpmInt3"u8, - BreakpointMethod.DebugRegister => "bpmDebugRegister"u8, - BreakpointMethod.Exception => "bpmException"u8, - _ => default - }; - } + /// The bpm* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this BreakpointMethod value) + { + return value switch + { + BreakpointMethod.Int3 => "bpmInt3"u8, + BreakpointMethod.DebugRegister => "bpmDebugRegister"u8, + BreakpointMethod.Exception => "bpmException"u8, + _ => default + }; + } - /// Parses a bpm* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out BreakpointMethod value) - { - for (var candidate = BreakpointMethod.Int3; candidate <= BreakpointMethod.Exception; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses a bpm* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out BreakpointMethod value) + { + for (BreakpointMethod candidate = BreakpointMethod.Int3; candidate <= BreakpointMethod.Exception; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The bpt* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this BreakpointTrigger value) - { - return value switch - { - BreakpointTrigger.Execute => "bptExecute"u8, - BreakpointTrigger.Access => "bptAccess"u8, - BreakpointTrigger.Write => "bptWrite"u8, - _ => default - }; - } + /// The bpt* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this BreakpointTrigger value) + { + return value switch + { + BreakpointTrigger.Execute => "bptExecute"u8, + BreakpointTrigger.Access => "bptAccess"u8, + BreakpointTrigger.Write => "bptWrite"u8, + _ => default + }; + } - /// Parses a bpt* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out BreakpointTrigger value) - { - for (var candidate = BreakpointTrigger.Execute; candidate <= BreakpointTrigger.Write; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses a bpt* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out BreakpointTrigger value) + { + for (BreakpointTrigger candidate = BreakpointTrigger.Execute; candidate <= BreakpointTrigger.Write; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The co_* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this ContinueMethod value) - { - return value switch - { - ContinueMethod.Run => "co_run"u8, - ContinueMethod.StepInto => "co_stepinto"u8, - ContinueMethod.StepOver => "co_stepover"u8, - _ => default - }; - } + /// The co_* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this ContinueMethod value) + { + return value switch + { + ContinueMethod.Run => "co_run"u8, + ContinueMethod.StepInto => "co_stepinto"u8, + ContinueMethod.StepOver => "co_stepover"u8, + _ => default + }; + } - /// Parses a co_* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out ContinueMethod value) - { - for (var candidate = ContinueMethod.Run; candidate <= ContinueMethod.StepOver; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses a co_* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out ContinueMethod value) + { + for (ContinueMethod candidate = ContinueMethod.Run; candidate <= ContinueMethod.StepOver; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// - /// The PAGE_* name of a single value; a combination of bits, - /// and undefined values give an empty span. - /// - /// The member. - /// The name, or an empty span. - public static ReadOnlySpan ToCEName(this MemoryProtection value) - { - return value switch - { - MemoryProtection.ReadOnly => "PAGE_READONLY"u8, - MemoryProtection.ReadWrite => "PAGE_READWRITE"u8, - MemoryProtection.WriteCopy => "PAGE_WRITECOPY"u8, - MemoryProtection.Execute => "PAGE_EXECUTE"u8, - MemoryProtection.ExecuteRead => "PAGE_EXECUTE_READ"u8, - MemoryProtection.ExecuteReadWrite => "PAGE_EXECUTE_READWRITE"u8, - MemoryProtection.ExecuteWriteCopy => "PAGE_EXECUTE_WRITECOPY"u8, - _ => default - }; - } + /// + /// The PAGE_* name of a single value; a combination of bits, + /// and undefined values give an empty span. + /// + /// The member. + /// The name, or an empty span. + public static ReadOnlySpan ToCEName(this MemoryProtection value) + { + return value switch + { + MemoryProtection.ReadOnly => "PAGE_READONLY"u8, + MemoryProtection.ReadWrite => "PAGE_READWRITE"u8, + MemoryProtection.WriteCopy => "PAGE_WRITECOPY"u8, + MemoryProtection.Execute => "PAGE_EXECUTE"u8, + MemoryProtection.ExecuteRead => "PAGE_EXECUTE_READ"u8, + MemoryProtection.ExecuteReadWrite => "PAGE_EXECUTE_READWRITE"u8, + MemoryProtection.ExecuteWriteCopy => "PAGE_EXECUTE_WRITECOPY"u8, + _ => default + }; + } - /// Parses a single PAGE_* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out MemoryProtection value) - { - for (var candidate = MemoryProtection.ReadOnly; - candidate <= MemoryProtection.ExecuteWriteCopy; - candidate = (MemoryProtection)((uint)candidate << 1)) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses a single PAGE_* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out MemoryProtection value) + { + for (MemoryProtection candidate = MemoryProtection.ReadOnly; + candidate <= MemoryProtection.ExecuteWriteCopy; + candidate = (MemoryProtection) ((uint) candidate << 1)) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } - /// The dup* name of a . - /// The member. - /// The name, or an empty span for an undefined value. - public static ReadOnlySpan ToCEName(this DuplicateHandling value) - { - return value switch - { - DuplicateHandling.Ignore => "dupIgnore"u8, - DuplicateHandling.Accept => "dupAccept"u8, - DuplicateHandling.Error => "dupError"u8, - _ => default - }; - } + /// The dup* name of a . + /// The member. + /// The name, or an empty span for an undefined value. + public static ReadOnlySpan ToCEName(this DuplicateHandling value) + { + return value switch + { + DuplicateHandling.Ignore => "dupIgnore"u8, + DuplicateHandling.Accept => "dupAccept"u8, + DuplicateHandling.Error => "dupError"u8, + _ => default + }; + } - /// Parses a dup* name. - /// The name, UTF-8, exact case. - /// The member; on failure. - /// when the name is not a member. - public static bool TryParseCEName(ReadOnlySpan ceName, out DuplicateHandling value) - { - for (var candidate = DuplicateHandling.Ignore; candidate <= DuplicateHandling.Error; candidate++) - if (ceName.SequenceEqual(candidate.ToCEName())) - { - value = candidate; - return true; - } + /// Parses a dup* name. + /// The name, UTF-8, exact case. + /// The member; on failure. + /// when the name is not a member. + public static bool TryParseCEName(ReadOnlySpan ceName, out DuplicateHandling value) + { + for (DuplicateHandling candidate = DuplicateHandling.Ignore; candidate <= DuplicateHandling.Error; candidate++) + { + if (ceName.SequenceEqual(candidate.ToCEName())) + { + value = candidate; + return true; + } + } - value = default; - return false; - } + value = default; + return false; + } } diff --git a/libs/CheatEngine.SDK.Engine/Enums/ContinueMethod.cs b/libs/CheatEngine.SDK.Engine/Enums/ContinueMethod.cs index 131e742d..c5e9dc44 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/ContinueMethod.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/ContinueMethod.cs @@ -10,12 +10,12 @@ namespace CheatEngine.SDK.Engine.Enums; /// public enum ContinueMethod { - /// Resume normally. CE: co_run. - Run = 0, + /// Resume normally. CE: co_run. + Run = 0, - /// Execute one instruction, following a call into the callee. CE: co_stepinto. - StepInto = 1, + /// Execute one instruction, following a call into the callee. CE: co_stepinto. + StepInto = 1, - /// Execute one instruction, running a call to completion. CE: co_stepover. - StepOver = 2 + /// Execute one instruction, running a call to completion. CE: co_stepover. + StepOver = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/DuplicateHandling.cs b/libs/CheatEngine.SDK.Engine/Enums/DuplicateHandling.cs index d00f4889..94140531 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/DuplicateHandling.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/DuplicateHandling.cs @@ -11,12 +11,12 @@ namespace CheatEngine.SDK.Engine.Enums; /// public enum DuplicateHandling { - /// Drop the duplicate silently. CE: dupIgnore. - Ignore = 0, + /// Drop the duplicate silently. CE: dupIgnore. + Ignore = 0, - /// Keep the duplicate. CE: dupAccept. - Accept = 1, + /// Keep the duplicate. CE: dupAccept. + Accept = 1, - /// Raise an error. CE: dupError. - Error = 2 + /// Raise an error. CE: dupError. + Error = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/EnumMarshaller.cs b/libs/CheatEngine.SDK.Engine/Enums/EnumMarshaller.cs index 48322d00..bbddcd02 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/EnumMarshaller.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/EnumMarshaller.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; @@ -23,89 +24,92 @@ namespace CheatEngine.SDK.Engine.Enums; /// bit cast, with no boxing and no reflection. /// [SuppressMessage("Design", "CA1000:Do not declare static members on generic types", - Justification = - "ILuaMarshaller is a static-abstract contract: a marshaller has static members only, and generic code names the type argument once (EnumMarshaller.Push) exactly as it would for a non-generic marshaller.")] + Justification = + "ILuaMarshaller is a static-abstract contract: a marshaller has static members only, and generic code names the type argument once (EnumMarshaller.Push) exactly as it would for a non-generic marshaller.")] [SuppressMessage("Meziantou.Analyzer", "MA0018", - Justification = - "ILuaMarshaller is a static-abstract contract: a marshaller has static members only, and generic code names the type argument once (EnumMarshaller.Push) exactly as it would for a non-generic marshaller.")] + Justification = + "ILuaMarshaller is a static-abstract contract: a marshaller has static members only, and generic code names the type argument once (EnumMarshaller.Push) exactly as it would for a non-generic marshaller.")] public readonly struct EnumMarshaller : ILuaMarshaller - where TEnum : unmanaged, Enum + where TEnum : unmanaged, Enum { - // Type.GetTypeCode of an enum type is the code of its underlying integer type. - private static readonly TypeCode s_underlying = Type.GetTypeCode(typeof(TEnum)); + // Type.GetTypeCode of an enum type is the code of its underlying integer type. + private static readonly TypeCode s_underlying = Type.GetTypeCode(typeof(TEnum)); - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, TEnum value) - { - state.PushInteger(ToInt64(value)); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, TEnum value) + { + state.PushInteger(ToInt64(value)); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out TEnum value) - { - if (state.TryReadInteger(index, out var bits) && TryFromInt64(bits, out value)) return true; + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out TEnum value) + { + if (state.TryReadInteger(index, out long bits) && TryFromInt64(bits, out value)) + { + return true; + } - value = default; - return false; - } + value = default; + return false; + } - /// The integer value of , as Lua carries it. - /// The member. - /// The value, sign-extended for signed underlying types and zero-extended for unsigned ones. - public static long ToInt64(TEnum value) - { - return s_underlying switch - { - TypeCode.SByte => Unsafe.BitCast(value), - TypeCode.Byte => Unsafe.BitCast(value), - TypeCode.Int16 => Unsafe.BitCast(value), - TypeCode.UInt16 => Unsafe.BitCast(value), - TypeCode.Int32 => Unsafe.BitCast(value), - TypeCode.UInt32 => Unsafe.BitCast(value), - TypeCode.Int64 => Unsafe.BitCast(value), - _ => unchecked((long)Unsafe.BitCast(value)) - }; - } + /// The integer value of , as Lua carries it. + /// The member. + /// The value, sign-extended for signed underlying types and zero-extended for unsigned ones. + public static long ToInt64(TEnum value) + { + return s_underlying switch + { + TypeCode.SByte => Unsafe.BitCast(value), + TypeCode.Byte => Unsafe.BitCast(value), + TypeCode.Int16 => Unsafe.BitCast(value), + TypeCode.UInt16 => Unsafe.BitCast(value), + TypeCode.Int32 => Unsafe.BitCast(value), + TypeCode.UInt32 => Unsafe.BitCast(value), + TypeCode.Int64 => Unsafe.BitCast(value), + _ => unchecked((long) Unsafe.BitCast(value)) + }; + } - /// The member with the integer value , when it fits the underlying type. - /// The Lua integer. - /// The member, defined or not; on failure. - /// when the value does not fit the enum's underlying type. - public static bool TryFromInt64(long bits, out TEnum value) - { - switch (s_underlying) - { - case TypeCode.SByte when bits is >= sbyte.MinValue and <= sbyte.MaxValue: - value = Unsafe.BitCast((sbyte)bits); - return true; - case TypeCode.Byte when bits is >= byte.MinValue and <= byte.MaxValue: - value = Unsafe.BitCast((byte)bits); - return true; - case TypeCode.Int16 when bits is >= short.MinValue and <= short.MaxValue: - value = Unsafe.BitCast((short)bits); - return true; - case TypeCode.UInt16 when bits is >= ushort.MinValue and <= ushort.MaxValue: - value = Unsafe.BitCast((ushort)bits); - return true; - case TypeCode.Int32 when bits is >= int.MinValue and <= int.MaxValue: - value = Unsafe.BitCast((int)bits); - return true; - case TypeCode.UInt32 when bits is >= uint.MinValue and <= uint.MaxValue: - value = Unsafe.BitCast((uint)bits); - return true; - case TypeCode.Int64: - value = Unsafe.BitCast(bits); - return true; - case TypeCode.UInt64: - value = Unsafe.BitCast(unchecked((ulong)bits)); - return true; - default: - value = default; - return false; - } - } + /// The member with the integer value , when it fits the underlying type. + /// The Lua integer. + /// The member, defined or not; on failure. + /// when the value does not fit the enum's underlying type. + public static bool TryFromInt64(long bits, out TEnum value) + { + switch (s_underlying) + { + case TypeCode.SByte when bits is >= sbyte.MinValue and <= sbyte.MaxValue: + value = Unsafe.BitCast((sbyte) bits); + return true; + case TypeCode.Byte when bits is >= byte.MinValue and <= byte.MaxValue: + value = Unsafe.BitCast((byte) bits); + return true; + case TypeCode.Int16 when bits is >= short.MinValue and <= short.MaxValue: + value = Unsafe.BitCast((short) bits); + return true; + case TypeCode.UInt16 when bits is >= ushort.MinValue and <= ushort.MaxValue: + value = Unsafe.BitCast((ushort) bits); + return true; + case TypeCode.Int32 when bits is >= int.MinValue and <= int.MaxValue: + value = Unsafe.BitCast((int) bits); + return true; + case TypeCode.UInt32 when bits is >= uint.MinValue and <= uint.MaxValue: + value = Unsafe.BitCast((uint) bits); + return true; + case TypeCode.Int64: + value = Unsafe.BitCast(bits); + return true; + case TypeCode.UInt64: + value = Unsafe.BitCast(unchecked((ulong) bits)); + return true; + default: + value = default; + return false; + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Enums/FastScanMethod.cs b/libs/CheatEngine.SDK.Engine/Enums/FastScanMethod.cs index fe9a4bad..528ed8d6 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/FastScanMethod.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/FastScanMethod.cs @@ -11,12 +11,12 @@ namespace CheatEngine.SDK.Engine.Enums; /// public enum FastScanMethod { - /// Check every address. CE: fsmNotAligned. - NotAligned = 0, + /// Check every address. CE: fsmNotAligned. + NotAligned = 0, - /// Only addresses divisible by the alignment parameter. CE: fsmAligned. - Aligned = 1, + /// Only addresses divisible by the alignment parameter. CE: fsmAligned. + Aligned = 1, - /// Only addresses whose hexadecimal text ends with the alignment parameter. CE: fsmLastDigits. - LastDigits = 2 + /// Only addresses whose hexadecimal text ends with the alignment parameter. CE: fsmLastDigits. + LastDigits = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs b/libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs index b757ead2..d89f4810 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs @@ -23,27 +23,27 @@ namespace CheatEngine.SDK.Engine.Enums; [Flags] public enum MemoryProtection : uint { - /// No protection value reported (a free or reserved region). Not a CE define. - None = 0, + /// No protection value reported (a free or reserved region). Not a CE define. + None = 0, - /// Read-only. CE: PAGE_READONLY. - ReadOnly = 2, + /// Read-only. CE: PAGE_READONLY. + ReadOnly = 2, - /// Read and write. CE: PAGE_READWRITE. - ReadWrite = 4, + /// Read and write. CE: PAGE_READWRITE. + ReadWrite = 4, - /// Copy-on-write. CE: PAGE_WRITECOPY. - WriteCopy = 8, + /// Copy-on-write. CE: PAGE_WRITECOPY. + WriteCopy = 8, - /// Execute only. CE: PAGE_EXECUTE. - Execute = 16, + /// Execute only. CE: PAGE_EXECUTE. + Execute = 16, - /// Execute and read. CE: PAGE_EXECUTE_READ. - ExecuteRead = 32, + /// Execute and read. CE: PAGE_EXECUTE_READ. + ExecuteRead = 32, - /// Execute, read and write. CE: PAGE_EXECUTE_READWRITE. - ExecuteReadWrite = 64, + /// Execute, read and write. CE: PAGE_EXECUTE_READWRITE. + ExecuteReadWrite = 64, - /// Execute and copy-on-write. CE: PAGE_EXECUTE_WRITECOPY. - ExecuteWriteCopy = 128 + /// Execute and copy-on-write. CE: PAGE_EXECUTE_WRITECOPY. + ExecuteWriteCopy = 128 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/RoundingType.cs b/libs/CheatEngine.SDK.Engine/Enums/RoundingType.cs index 359dc8a2..8cc76d82 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/RoundingType.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/RoundingType.cs @@ -8,15 +8,15 @@ namespace CheatEngine.SDK.Engine.Enums; /// Values verified against defines.lua of Cheat Engine 7.7.0.10621. public enum RoundingType { - /// Match values that round to the input at its precision. CE: rtRounded. - Rounded = 0, + /// Match values that round to the input at its precision. CE: rtRounded. + Rounded = 0, - /// - /// Match a wide band around the input, about one unit of its last digit on each side. CE: rtExtremerounded - /// . - /// - ExtremeRounded = 1, + /// + /// Match a wide band around the input, about one unit of its last digit on each side. CE: rtExtremerounded + /// . + /// + ExtremeRounded = 1, - /// Match values that truncate to the input at its precision. CE: rtTruncated. - Truncated = 2 + /// Match values that truncate to the input at its precision. CE: rtTruncated. + Truncated = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/ScanOption.cs b/libs/CheatEngine.SDK.Engine/Enums/ScanOption.cs index 42f95bba..20e1ff3d 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/ScanOption.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/ScanOption.cs @@ -11,36 +11,36 @@ namespace CheatEngine.SDK.Engine.Enums; /// public enum ScanOption { - /// Record every address without comparing (first scan only). CE: soUnknownValue. - UnknownValue = 0, + /// Record every address without comparing (first scan only). CE: soUnknownValue. + UnknownValue = 0, - /// Equal to the input. CE: soExactValue. - ExactValue = 1, + /// Equal to the input. CE: soExactValue. + ExactValue = 1, - /// Between the two inputs. CE: soValueBetween. - ValueBetween = 2, + /// Between the two inputs. CE: soValueBetween. + ValueBetween = 2, - /// Greater than the input. CE: soBiggerThan. - BiggerThan = 3, + /// Greater than the input. CE: soBiggerThan. + BiggerThan = 3, - /// Less than the input. CE: soSmallerThan. - SmallerThan = 4, + /// Less than the input. CE: soSmallerThan. + SmallerThan = 4, - /// Greater than in the previous scan (next scan only). CE: soIncreasedValue. - IncreasedValue = 5, + /// Greater than in the previous scan (next scan only). CE: soIncreasedValue. + IncreasedValue = 5, - /// Greater than in the previous scan by the input (next scan only). CE: soIncreasedValueBy. - IncreasedValueBy = 6, + /// Greater than in the previous scan by the input (next scan only). CE: soIncreasedValueBy. + IncreasedValueBy = 6, - /// Less than in the previous scan (next scan only). CE: soDecreasedValue. - DecreasedValue = 7, + /// Less than in the previous scan (next scan only). CE: soDecreasedValue. + DecreasedValue = 7, - /// Less than in the previous scan by the input (next scan only). CE: soDecreasedValueBy. - DecreasedValueBy = 8, + /// Less than in the previous scan by the input (next scan only). CE: soDecreasedValueBy. + DecreasedValueBy = 8, - /// Different from the previous scan (next scan only). CE: soChanged. - Changed = 9, + /// Different from the previous scan (next scan only). CE: soChanged. + Changed = 9, - /// Same as in the previous scan (next scan only). CE: soUnchanged. - Unchanged = 10 + /// Same as in the previous scan (next scan only). CE: soUnchanged. + Unchanged = 10 } diff --git a/libs/CheatEngine.SDK.Engine/Enums/VariableType.cs b/libs/CheatEngine.SDK.Engine/Enums/VariableType.cs index e09587b6..7540463c 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/VariableType.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/VariableType.cs @@ -12,55 +12,55 @@ namespace CheatEngine.SDK.Engine.Enums; /// vocabulary (CE calls them byte, word, dword, ...), hence the naming suppression. /// [SuppressMessage("Naming", "CA1720:Identifiers should not contain type names", - Justification = - "The members name Cheat Engine's value types (vtByte, vtSingle, vtDouble, vtString, vtPointer); renaming them would hide the CE names they mirror.")] + Justification = + "The members name Cheat Engine's value types (vtByte, vtSingle, vtDouble, vtString, vtPointer); renaming them would hide the CE names they mirror.")] public enum VariableType { - /// One byte. CE: vtByte. - Byte = 0, + /// One byte. CE: vtByte. + Byte = 0, - /// Two bytes. CE: vtWord. - Word = 1, + /// Two bytes. CE: vtWord. + Word = 1, - /// Four bytes, the default of firstScan. CE: vtDword. - Dword = 2, + /// Four bytes, the default of firstScan. CE: vtDword. + Dword = 2, - /// Eight bytes. CE: vtQword. - Qword = 3, + /// Eight bytes. CE: vtQword. + Qword = 3, - /// Single-precision float. CE: vtSingle. - Single = 4, + /// Single-precision float. CE: vtSingle. + Single = 4, - /// Double-precision float. CE: vtDouble. - Double = 5, + /// Double-precision float. CE: vtDouble. + Double = 5, - /// A text string. CE: vtString. - String = 6, + /// A text string. CE: vtString. + String = 6, - /// - /// A UTF-16 string. CE: vtWideString; the alias vtUnicodeString has the same value and is only used - /// by CE's type guesser. - /// - WideString = 7, + /// + /// A UTF-16 string. CE: vtWideString; the alias vtUnicodeString has the same value and is only used + /// by CE's type guesser. + /// + WideString = 7, - /// An array of bytes. CE: vtByteArray. - ByteArray = 8, + /// An array of bytes. CE: vtByteArray. + ByteArray = 8, - /// A binary (bit field) value. CE: vtBinary. - Binary = 9, + /// A binary (bit field) value. CE: vtBinary. + Binary = 9, - /// Every type at once (scan only). CE: vtAll. - All = 10, + /// Every type at once (scan only). CE: vtAll. + All = 10, - /// An auto assembler script (memory record only). CE: vtAutoAssembler. - AutoAssembler = 11, + /// An auto assembler script (memory record only). CE: vtAutoAssembler. + AutoAssembler = 11, - /// A pointer; only used by CE's type guesser and by structure dissection. CE: vtPointer. - Pointer = 12, + /// A pointer; only used by CE's type guesser and by structure dissection. CE: vtPointer. + Pointer = 12, - /// A user-defined custom type. CE: vtCustom. - Custom = 13, + /// A user-defined custom type. CE: vtCustom. + Custom = 13, - /// A group header in the address list (memory record only). CE: vtGrouped. - Grouped = 14 + /// A group header in the address list (memory record only). CE: vtGrouped. + Grouped = 14 } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineBindingException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineBindingException.cs index 2d50c1ba..25c14e82 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineBindingException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineBindingException.cs @@ -14,39 +14,42 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineBindingException : EngineException { - /// Initializes an exception with the standard message for . - /// The stable public binding identifier. - public EngineBindingException(string binding) - : this(binding, CreateDefaultMessage(binding), innerException: null) - { - } + /// Initializes an exception with the standard message for . + /// The stable public binding identifier. + public EngineBindingException(string binding) + : this(binding, CreateDefaultMessage(binding), null) + { + } - /// Initializes an exception with a caller-supplied stable message. - /// The stable public binding identifier. - /// The stable public failure message. - public EngineBindingException(string binding, string message) - : this(binding, message, innerException: null) - { - } + /// Initializes an exception with a caller-supplied stable message. + /// The stable public binding identifier. + /// The stable public failure message. + public EngineBindingException(string binding, string message) + : this(binding, message, null) + { + } - /// Initializes an exception with a stable message and the lower-level cause. - /// The stable public binding identifier. - /// The stable public failure message. - /// The lower-level cause, when one exists. - public EngineBindingException(string binding, string message, Exception? innerException) - : base(message, innerException) - { - Binding = RequireText(binding, nameof(binding)); - } + /// Initializes an exception with a stable message and the lower-level cause. + /// The stable public binding identifier. + /// The stable public failure message. + /// The lower-level cause, when one exists. + public EngineBindingException(string binding, string message, Exception? innerException) + : base(message, innerException) + { + Binding = RequireText(binding, nameof(binding)); + } - /// Gets the stable public identifier of the invalid binding. - public string Binding { get; } + /// Gets the stable public identifier of the invalid binding. + public string Binding + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.BindingFailure; + /// + public override EngineFailureKind Kind => EngineFailureKind.BindingFailure; - private static string CreateDefaultMessage(string binding) - { - return "The Cheat Engine binding '" + RequireText(binding, nameof(binding)) + "' cannot uphold its contract."; - } + private static string CreateDefaultMessage(string binding) + { + return "The Cheat Engine binding '" + RequireText(binding, nameof(binding)) + "' cannot uphold its contract."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineCapabilityUnavailableException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineCapabilityUnavailableException.cs index ec5a9f38..d4e8116a 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineCapabilityUnavailableException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineCapabilityUnavailableException.cs @@ -13,39 +13,42 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineCapabilityUnavailableException : EngineException { - /// Initializes an exception with the standard message for . - /// The stable public capability identifier. - public EngineCapabilityUnavailableException(string capability) - : this(capability, CreateDefaultMessage(capability), innerException: null) - { - } + /// Initializes an exception with the standard message for . + /// The stable public capability identifier. + public EngineCapabilityUnavailableException(string capability) + : this(capability, CreateDefaultMessage(capability), null) + { + } - /// Initializes an exception with a caller-supplied stable message. - /// The stable public capability identifier. - /// The stable public failure message. - public EngineCapabilityUnavailableException(string capability, string message) - : this(capability, message, innerException: null) - { - } + /// Initializes an exception with a caller-supplied stable message. + /// The stable public capability identifier. + /// The stable public failure message. + public EngineCapabilityUnavailableException(string capability, string message) + : this(capability, message, null) + { + } - /// Initializes an exception with a stable message and the lower-level cause. - /// The stable public capability identifier. - /// The stable public failure message. - /// The lower-level cause, when one exists. - public EngineCapabilityUnavailableException(string capability, string message, Exception? innerException) - : base(message, innerException) - { - Capability = RequireText(capability, nameof(capability)); - } + /// Initializes an exception with a stable message and the lower-level cause. + /// The stable public capability identifier. + /// The stable public failure message. + /// The lower-level cause, when one exists. + public EngineCapabilityUnavailableException(string capability, string message, Exception? innerException) + : base(message, innerException) + { + Capability = RequireText(capability, nameof(capability)); + } - /// Gets the stable public identifier of the unavailable capability. - public string Capability { get; } + /// Gets the stable public identifier of the unavailable capability. + public string Capability + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.CapabilityUnavailable; + /// + public override EngineFailureKind Kind => EngineFailureKind.CapabilityUnavailable; - private static string CreateDefaultMessage(string capability) - { - return "The Cheat Engine capability '" + RequireText(capability, nameof(capability)) + "' is unavailable."; - } + private static string CreateDefaultMessage(string capability) + { + return "The Cheat Engine capability '" + RequireText(capability, nameof(capability)) + "' is unavailable."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineException.cs index bbb589ba..52ab196d 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineException.cs @@ -14,34 +14,39 @@ namespace CheatEngine.SDK.Engine.Errors; /// public abstract class EngineException : Exception { - /// Initializes a domain error with its stable public message. - /// The stable public message. - protected EngineException(string message) - : base(RequireText(message, nameof(message))) - { - } + /// Initializes a domain error with its stable public message. + /// The stable public message. + protected EngineException(string message) + : base(RequireText(message, nameof(message))) + { + } - /// Initializes a domain error with its stable public message and the underlying cause. - /// The stable public message. - /// The lower-level cause, when one exists. - protected EngineException(string message, Exception? innerException) - : base(RequireText(message, nameof(message)), innerException) - { - } + /// Initializes a domain error with its stable public message and the underlying cause. + /// The stable public message. + /// The lower-level cause, when one exists. + protected EngineException(string message, Exception? innerException) + : base(RequireText(message, nameof(message)), innerException) + { + } - /// Gets the stable category of this failure. - public abstract EngineFailureKind Kind { get; } + /// Gets the stable category of this failure. + public abstract EngineFailureKind Kind + { + get; + } - /// Validates and returns public contract text. - /// The text to validate. - /// The parameter name to report when validation fails. - /// when it is nonempty. - /// is or empty. - protected static string RequireText(string value, string parameterName) - { - if (string.IsNullOrEmpty(value)) - throw new ArgumentException("The value cannot be null or empty.", parameterName); + /// Validates and returns public contract text. + /// The text to validate. + /// The parameter name to report when validation fails. + /// when it is nonempty. + /// is or empty. + protected static string RequireText(string value, string parameterName) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("The value cannot be null or empty.", parameterName); + } - return value; - } + return value; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineFailureKind.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineFailureKind.cs index 8efd1e3d..de744796 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineFailureKind.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineFailureKind.cs @@ -6,44 +6,44 @@ namespace CheatEngine.SDK.Engine.Errors; /// public enum EngineFailureKind { - /// - /// Cheat Engine completed a call but reported the expected operation failure defined by that operation's - /// contract, such as an unreadable target address. - /// - ExpectedOperationFailure = 0, - - /// - /// A generated or handwritten Engine binding could not resolve its required Lua global as a callable function. - /// - GlobalUnavailable = 1, - - /// - /// A required public Engine capability is not available in the attached Cheat Engine runtime. - /// - CapabilityUnavailable = 2, - - /// - /// A protected Lua operation failed before the binding could obtain its declared result. - /// - ProtectedLuaFailure = 3, - - /// - /// A generated or handwritten Engine binding does not match its declared contract. - /// - BindingFailure = 4, - - /// - /// A value crossing the Engine/Lua boundary could not be marshalled according to its declared contract. - /// - MarshallingFailure = 5, - - /// - /// A target-bound operation could not establish the current target's required identity facts. - /// - TargetIdentityUnavailable = 6, - - /// - /// A target-bound owner no longer matches the target currently selected by Cheat Engine. - /// - TargetIdentityMismatch = 7, + /// + /// Cheat Engine completed a call but reported the expected operation failure defined by that operation's + /// contract, such as an unreadable target address. + /// + ExpectedOperationFailure = 0, + + /// + /// A generated or handwritten Engine binding could not resolve its required Lua global as a callable function. + /// + GlobalUnavailable = 1, + + /// + /// A required public Engine capability is not available in the attached Cheat Engine runtime. + /// + CapabilityUnavailable = 2, + + /// + /// A protected Lua operation failed before the binding could obtain its declared result. + /// + ProtectedLuaFailure = 3, + + /// + /// A generated or handwritten Engine binding does not match its declared contract. + /// + BindingFailure = 4, + + /// + /// A value crossing the Engine/Lua boundary could not be marshalled according to its declared contract. + /// + MarshallingFailure = 5, + + /// + /// A target-bound operation could not establish the current target's required identity facts. + /// + TargetIdentityUnavailable = 6, + + /// + /// A target-bound owner no longer matches the target currently selected by Cheat Engine. + /// + TargetIdentityMismatch = 7 } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs index b3d69792..f504b52b 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs @@ -12,40 +12,43 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineGlobalUnavailableException : EngineException { - /// Initializes an exception with the standard message for . - /// The stable public Engine operation identifier. - public EngineGlobalUnavailableException(string operation) - : this(operation, CreateDefaultMessage(operation), innerException: null) - { - } + /// Initializes an exception with the standard message for . + /// The stable public Engine operation identifier. + public EngineGlobalUnavailableException(string operation) + : this(operation, CreateDefaultMessage(operation), null) + { + } - /// Initializes an exception with a caller-supplied stable message. - /// The stable public Engine operation identifier. - /// The stable public failure message. - public EngineGlobalUnavailableException(string operation, string message) - : this(operation, message, innerException: null) - { - } + /// Initializes an exception with a caller-supplied stable message. + /// The stable public Engine operation identifier. + /// The stable public failure message. + public EngineGlobalUnavailableException(string operation, string message) + : this(operation, message, null) + { + } - /// Initializes an exception with a stable message and the lower-level cause. - /// The stable public Engine operation identifier. - /// The stable public failure message. - /// The lower-level cause, when one exists. - public EngineGlobalUnavailableException(string operation, string message, Exception? innerException) - : base(message, innerException) - { - Operation = RequireText(operation, nameof(operation)); - } + /// Initializes an exception with a stable message and the lower-level cause. + /// The stable public Engine operation identifier. + /// The stable public failure message. + /// The lower-level cause, when one exists. + public EngineGlobalUnavailableException(string operation, string message, Exception? innerException) + : base(message, innerException) + { + Operation = RequireText(operation, nameof(operation)); + } - /// Gets the stable public identifier of the operation whose required global could not be resolved. - public string Operation { get; } + /// Gets the stable public identifier of the operation whose required global could not be resolved. + public string Operation + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.GlobalUnavailable; + /// + public override EngineFailureKind Kind => EngineFailureKind.GlobalUnavailable; - private static string CreateDefaultMessage(string operation) - { - return "The required binding global for Cheat Engine operation '" + - RequireText(operation, nameof(operation)) + "' is unavailable."; - } + private static string CreateDefaultMessage(string operation) + { + return "The required binding global for Cheat Engine operation '" + + RequireText(operation, nameof(operation)) + "' is unavailable."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs index bdba59e5..77bb10a3 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Engine.Errors; @@ -14,56 +15,66 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineLuaException : EngineException { - /// Initializes an exception with the standard public message for the protected Lua failure. - /// The stable public Engine operation identifier. - /// The non-success status returned by the protected Lua operation. - /// represents a successful Lua operation. - public EngineLuaException(string operation, LuaStatus status) - : this(operation, status, CreateDefaultMessage(operation, status), innerException: null) - { - } + /// Initializes an exception with the standard public message for the protected Lua failure. + /// The stable public Engine operation identifier. + /// The non-success status returned by the protected Lua operation. + /// represents a successful Lua operation. + public EngineLuaException(string operation, LuaStatus status) + : this(operation, status, CreateDefaultMessage(operation, status), null) + { + } - /// Initializes an exception with a caller-supplied stable public message. - /// The stable public Engine operation identifier. - /// The non-success status returned by the protected Lua operation. - /// The stable public failure message. - /// represents a successful Lua operation. - public EngineLuaException(string operation, LuaStatus status, string message) - : this(operation, status, message, innerException: null) - { - } + /// Initializes an exception with a caller-supplied stable public message. + /// The stable public Engine operation identifier. + /// The non-success status returned by the protected Lua operation. + /// The stable public failure message. + /// represents a successful Lua operation. + public EngineLuaException(string operation, LuaStatus status, string message) + : this(operation, status, message, null) + { + } - /// Initializes an exception with a stable public message and the lower-level cause. - /// The stable public Engine operation identifier. - /// The non-success status returned by the protected Lua operation. - /// The stable public failure message. - /// The lower-level cause, when one exists. - /// represents a successful Lua operation. - public EngineLuaException(string operation, LuaStatus status, string message, Exception? innerException) - : base(message, innerException) - { - if (status.IsOk) - throw new ArgumentException("A successful Lua status cannot describe a failure.", nameof(status)); + /// Initializes an exception with a stable public message and the lower-level cause. + /// The stable public Engine operation identifier. + /// The non-success status returned by the protected Lua operation. + /// The stable public failure message. + /// The lower-level cause, when one exists. + /// represents a successful Lua operation. + public EngineLuaException(string operation, LuaStatus status, string message, Exception? innerException) + : base(message, innerException) + { + if (status.IsOk) + { + throw new ArgumentException("A successful Lua status cannot describe a failure.", nameof(status)); + } - Operation = RequireText(operation, nameof(operation)); - Status = status; - } + Operation = RequireText(operation, nameof(operation)); + Status = status; + } - /// Gets the stable public identifier of the Engine operation that ran Lua. - public string Operation { get; } + /// Gets the stable public identifier of the Engine operation that ran Lua. + public string Operation + { + get; + } - /// Gets the non-success protected Lua status. - public LuaStatus Status { get; } + /// Gets the non-success protected Lua status. + public LuaStatus Status + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.ProtectedLuaFailure; + /// + public override EngineFailureKind Kind => EngineFailureKind.ProtectedLuaFailure; - private static string CreateDefaultMessage(string operation, LuaStatus status) - { - if (status.IsOk) - throw new ArgumentException("A successful Lua status cannot describe a failure.", nameof(status)); + private static string CreateDefaultMessage(string operation, LuaStatus status) + { + if (status.IsOk) + { + throw new ArgumentException("A successful Lua status cannot describe a failure.", nameof(status)); + } - return "The protected Lua operation '" + RequireText(operation, nameof(operation)) + "' failed with status " + - status + "."; - } + return "The protected Lua operation '" + RequireText(operation, nameof(operation)) + "' failed with status " + + status + "."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingDirection.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingDirection.cs index 9985bb72..0230fea0 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingDirection.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingDirection.cs @@ -3,9 +3,9 @@ namespace CheatEngine.SDK.Engine.Errors; /// Identifies the direction of a value that could not cross the Engine/Lua boundary. public enum EngineMarshallingDirection { - /// A managed argument could not be represented by the declared Lua contract. - Argument = 0, + /// A managed argument could not be represented by the declared Lua contract. + Argument = 0, - /// A Lua result could not be represented by the declared managed contract. - Result = 1, + /// A Lua result could not be represented by the declared managed contract. + Result = 1 } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs index 3ac034b0..92f6ed1d 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs @@ -13,77 +13,92 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineMarshallingException : EngineException { - /// Initializes an exception with the standard message for the marshalling mismatch. - /// The stable public Engine operation identifier. - /// Whether the mismatch occurred for an argument or a result. - /// The type or shape the binding declared. - /// The type or shape the binding observed. - public EngineMarshallingException(string operation, EngineMarshallingDirection direction, string expected, - string actual) - : this(operation, direction, expected, actual, CreateDefaultMessage(operation, direction, expected, actual), - innerException: null) - { - } + /// Initializes an exception with the standard message for the marshalling mismatch. + /// The stable public Engine operation identifier. + /// Whether the mismatch occurred for an argument or a result. + /// The type or shape the binding declared. + /// The type or shape the binding observed. + public EngineMarshallingException(string operation, EngineMarshallingDirection direction, string expected, + string actual) + : this(operation, direction, expected, actual, CreateDefaultMessage(operation, direction, expected, actual), + null) + { + } - /// Initializes an exception with a caller-supplied stable message. - /// The stable public Engine operation identifier. - /// Whether the mismatch occurred for an argument or a result. - /// The type or shape the binding declared. - /// The type or shape the binding observed. - /// The stable public failure message. - public EngineMarshallingException(string operation, EngineMarshallingDirection direction, string expected, - string actual, string message) - : this(operation, direction, expected, actual, message, innerException: null) - { - } + /// Initializes an exception with a caller-supplied stable message. + /// The stable public Engine operation identifier. + /// Whether the mismatch occurred for an argument or a result. + /// The type or shape the binding declared. + /// The type or shape the binding observed. + /// The stable public failure message. + public EngineMarshallingException(string operation, EngineMarshallingDirection direction, string expected, + string actual, string message) + : this(operation, direction, expected, actual, message, null) + { + } - /// Initializes an exception with a stable message and the lower-level cause. - /// The stable public Engine operation identifier. - /// Whether the mismatch occurred for an argument or a result. - /// The type or shape the binding declared. - /// The type or shape the binding observed. - /// The stable public failure message. - /// The lower-level cause, when one exists. - public EngineMarshallingException(string operation, EngineMarshallingDirection direction, string expected, - string actual, string message, Exception? innerException) - : base(message, innerException) - { - Operation = RequireText(operation, nameof(operation)); - Direction = ValidateDirection(direction); - Expected = RequireText(expected, nameof(expected)); - Actual = RequireText(actual, nameof(actual)); - } + /// Initializes an exception with a stable message and the lower-level cause. + /// The stable public Engine operation identifier. + /// Whether the mismatch occurred for an argument or a result. + /// The type or shape the binding declared. + /// The type or shape the binding observed. + /// The stable public failure message. + /// The lower-level cause, when one exists. + public EngineMarshallingException(string operation, EngineMarshallingDirection direction, string expected, + string actual, string message, Exception? innerException) + : base(message, innerException) + { + Operation = RequireText(operation, nameof(operation)); + Direction = ValidateDirection(direction); + Expected = RequireText(expected, nameof(expected)); + Actual = RequireText(actual, nameof(actual)); + } - /// Gets the stable public identifier of the Engine operation. - public string Operation { get; } + /// Gets the stable public identifier of the Engine operation. + public string Operation + { + get; + } - /// Gets whether the mismatch happened while writing an argument or reading a result. - public EngineMarshallingDirection Direction { get; } + /// Gets whether the mismatch happened while writing an argument or reading a result. + public EngineMarshallingDirection Direction + { + get; + } - /// Gets the type or shape the binding declared. - public string Expected { get; } + /// Gets the type or shape the binding declared. + public string Expected + { + get; + } - /// Gets the type or shape the binding observed. - public string Actual { get; } + /// Gets the type or shape the binding observed. + public string Actual + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.MarshallingFailure; + /// + public override EngineFailureKind Kind => EngineFailureKind.MarshallingFailure; - private static string CreateDefaultMessage(string operation, EngineMarshallingDirection direction, string expected, - string actual) - { - var directionText = ValidateDirection(direction) == EngineMarshallingDirection.Argument ? "argument" : "result"; - return "The " + directionText + " of Cheat Engine operation '" + - RequireText(operation, nameof(operation)) + "' could not be marshalled: expected " + - RequireText(expected, nameof(expected)) + ", observed " + RequireText(actual, nameof(actual)) + "."; - } + private static string CreateDefaultMessage(string operation, EngineMarshallingDirection direction, string expected, + string actual) + { + string directionText = + ValidateDirection(direction) == EngineMarshallingDirection.Argument ? "argument" : "result"; + return "The " + directionText + " of Cheat Engine operation '" + + RequireText(operation, nameof(operation)) + "' could not be marshalled: expected " + + RequireText(expected, nameof(expected)) + ", observed " + RequireText(actual, nameof(actual)) + "."; + } - private static EngineMarshallingDirection ValidateDirection(EngineMarshallingDirection direction) - { - if (direction != EngineMarshallingDirection.Argument && direction != EngineMarshallingDirection.Result) - throw new ArgumentOutOfRangeException(nameof(direction), direction, - "The marshalling direction is not defined."); + private static EngineMarshallingDirection ValidateDirection(EngineMarshallingDirection direction) + { + if (direction != EngineMarshallingDirection.Argument && direction != EngineMarshallingDirection.Result) + { + throw new ArgumentOutOfRangeException(nameof(direction), direction, + "The marshalling direction is not defined."); + } - return direction; - } + return direction; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineOperationFailedException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineOperationFailedException.cs index 6ed778a7..bb313a56 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineOperationFailedException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineOperationFailedException.cs @@ -11,39 +11,42 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineOperationFailedException : EngineException { - /// Initializes an exception with the standard message for . - /// The stable public Engine operation identifier. - public EngineOperationFailedException(string operation) - : this(operation, CreateDefaultMessage(operation), innerException: null) - { - } + /// Initializes an exception with the standard message for . + /// The stable public Engine operation identifier. + public EngineOperationFailedException(string operation) + : this(operation, CreateDefaultMessage(operation), null) + { + } - /// Initializes an exception with a caller-supplied stable message. - /// The stable public Engine operation identifier. - /// The stable public failure message. - public EngineOperationFailedException(string operation, string message) - : this(operation, message, innerException: null) - { - } + /// Initializes an exception with a caller-supplied stable message. + /// The stable public Engine operation identifier. + /// The stable public failure message. + public EngineOperationFailedException(string operation, string message) + : this(operation, message, null) + { + } - /// Initializes an exception with a stable message and the lower-level cause. - /// The stable public Engine operation identifier. - /// The stable public failure message. - /// The lower-level cause, when one exists. - public EngineOperationFailedException(string operation, string message, Exception? innerException) - : base(message, innerException) - { - Operation = RequireText(operation, nameof(operation)); - } + /// Initializes an exception with a stable message and the lower-level cause. + /// The stable public Engine operation identifier. + /// The stable public failure message. + /// The lower-level cause, when one exists. + public EngineOperationFailedException(string operation, string message, Exception? innerException) + : base(message, innerException) + { + Operation = RequireText(operation, nameof(operation)); + } - /// Gets the stable public identifier of the operation that reported failure. - public string Operation { get; } + /// Gets the stable public identifier of the operation that reported failure. + public string Operation + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.ExpectedOperationFailure; + /// + public override EngineFailureKind Kind => EngineFailureKind.ExpectedOperationFailure; - private static string CreateDefaultMessage(string operation) - { - return "The Cheat Engine operation '" + RequireText(operation, nameof(operation)) + "' reported failure."; - } + private static string CreateDefaultMessage(string operation) + { + return "The Cheat Engine operation '" + RequireText(operation, nameof(operation)) + "' reported failure."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs index 0ca65120..a95af947 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Engine.Targets; namespace CheatEngine.SDK.Engine.Errors; @@ -14,34 +15,40 @@ namespace CheatEngine.SDK.Engine.Errors; /// public sealed class EngineResourceHandoffException : EngineException { - /// - /// Initializes a handoff failure for and records the factual result of its single - /// compensation attempt. - /// - /// The stable public identifier of the effectful operation. - /// The compensation result observed after ownership publication failed. - /// The managed failure that prevented publication of the owner. - public EngineResourceHandoffException(string operation, TargetReleaseOutcome cleanupOutcome, - Exception? innerException) - : base(CreateMessage(operation, cleanupOutcome), innerException) - { - Operation = RequireText(operation, nameof(operation)); - CleanupOutcome = cleanupOutcome; - } + /// + /// Initializes a handoff failure for and records the factual result of its single + /// compensation attempt. + /// + /// The stable public identifier of the effectful operation. + /// The compensation result observed after ownership publication failed. + /// The managed failure that prevented publication of the owner. + public EngineResourceHandoffException(string operation, TargetReleaseOutcome cleanupOutcome, + Exception? innerException) + : base(CreateMessage(operation, cleanupOutcome), innerException) + { + Operation = RequireText(operation, nameof(operation)); + CleanupOutcome = cleanupOutcome; + } - /// Gets the stable public identifier of the effect whose ownership could not be published. - public string Operation { get; } + /// Gets the stable public identifier of the effect whose ownership could not be published. + public string Operation + { + get; + } - /// Gets the factual outcome of the one compensation attempt. - public TargetReleaseOutcome CleanupOutcome { get; } + /// Gets the factual outcome of the one compensation attempt. + public TargetReleaseOutcome CleanupOutcome + { + get; + } - /// - public override EngineFailureKind Kind => EngineFailureKind.BindingFailure; + /// + public override EngineFailureKind Kind => EngineFailureKind.BindingFailure; - private static string CreateMessage(string operation, TargetReleaseOutcome cleanupOutcome) - { - return "The Engine operation '" + RequireText(operation, nameof(operation)) + - "' completed before its ownership could be published; compensation ended as " + - cleanupOutcome.Status + "."; - } + private static string CreateMessage(string operation, TargetReleaseOutcome cleanupOutcome) + { + return "The Engine operation '" + RequireText(operation, nameof(operation)) + + "' completed before its ownership could be published; compensation ended as " + + cleanupOutcome.Status + "."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs index 9e612d71..40e0063f 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs @@ -5,31 +5,37 @@ namespace CheatEngine.SDK.Engine.Errors; /// Represents a target-bound operation refused because the original target cannot be verified as current. public sealed class EngineTargetIdentityException : EngineException { - /// Initializes a target-identity refusal for a stable Engine operation identifier. - /// The stable public operation identifier. - /// The factual target validation result. - public EngineTargetIdentityException(string operation, TargetIdentityCheck check) - : base(CreateMessage(operation, check)) - { - Operation = RequireText(operation, nameof(operation)); - Check = check; - } + /// Initializes a target-identity refusal for a stable Engine operation identifier. + /// The stable public operation identifier. + /// The factual target validation result. + public EngineTargetIdentityException(string operation, TargetIdentityCheck check) + : base(CreateMessage(operation, check)) + { + Operation = RequireText(operation, nameof(operation)); + Check = check; + } - /// Gets the stable public operation identifier. - public string Operation { get; } + /// Gets the stable public operation identifier. + public string Operation + { + get; + } - /// Gets the target validation result that caused the safe refusal. - public TargetIdentityCheck Check { get; } + /// Gets the target validation result that caused the safe refusal. + public TargetIdentityCheck Check + { + get; + } - /// - public override EngineFailureKind Kind => Check.Kind is TargetIdentityCheckKind.TargetChanged or - TargetIdentityCheckKind.ProcessReused - ? EngineFailureKind.TargetIdentityMismatch - : EngineFailureKind.TargetIdentityUnavailable; + /// + public override EngineFailureKind Kind => Check.Kind is TargetIdentityCheckKind.TargetChanged or + TargetIdentityCheckKind.ProcessReused + ? EngineFailureKind.TargetIdentityMismatch + : EngineFailureKind.TargetIdentityUnavailable; - private static string CreateMessage(string operation, TargetIdentityCheck check) - { - return "The Engine operation '" + RequireText(operation, nameof(operation)) + - "' was refused because its original target is not verified as current (" + check.Kind + ")."; - } + private static string CreateMessage(string operation, TargetIdentityCheck check) + { + return "The Engine operation '" + RequireText(operation, nameof(operation)) + + "' was refused because its original target is not verified as current (" + check.Kind + ")."; + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs b/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs index 806b8737..bc8b3b5a 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs @@ -13,38 +13,39 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly record struct AddressResolutionOptions { - private readonly bool _useHostSymbolTable; - private readonly bool _shallow; + /// Creates options while retaining the released two-Boolean constructor shape. + public AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) + { + #pragma warning disable CS0618 + this.UseHostSymbolTable = UseHostSymbolTable; + #pragma warning restore CS0618 + this.Shallow = Shallow; + } - /// Creates options while retaining the released two-Boolean constructor shape. - public AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) - { - _useHostSymbolTable = UseHostSymbolTable; - _shallow = Shallow; - } + /// Value for CE's optional shallow argument. + public bool Shallow + { + get; + init; + } - /// Value for CE's optional shallow argument. - public bool Shallow - { - get => _shallow; - init => _shallow = value; - } + /// Gets the removed CE local flag retained for source and binary compatibility. + [Obsolete("Use EngineInspection.ResolveHostAddress for host-symbol resolution; ResolveAddress rejects this flag.", + false)] + public bool UseHostSymbolTable + { + get; + init; + } - /// Gets the removed CE local flag retained for source and binary compatibility. - [Obsolete("Use EngineInspection.ResolveHostAddress for host-symbol resolution; ResolveAddress rejects this flag.", - error: false)] - public bool UseHostSymbolTable - { - get => _useHostSymbolTable; - init => _useHostSymbolTable = value; - } + #pragma warning disable CS0618 + internal bool HostSymbolTableRequested => UseHostSymbolTable; + #pragma warning restore CS0618 - internal bool HostSymbolTableRequested => _useHostSymbolTable; - - /// Deconstructs the compatibility shape used by the released 1.0.0 API. - public void Deconstruct(out bool UseHostSymbolTable, out bool Shallow) - { - UseHostSymbolTable = _useHostSymbolTable; - Shallow = _shallow; - } + /// Deconstructs the compatibility shape used by the released 1.0.0 API. + public void Deconstruct(out bool UseHostSymbolTable, out bool Shallow) + { + UseHostSymbolTable = HostSymbolTableRequested; + Shallow = this.Shallow; + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs index 25f83ef1..ab90da75 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Memory; @@ -45,666 +46,845 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public static class EngineInspection { - private static readonly LuaRef SEnumMemoryRegions = new(); - private static readonly LuaRef SGetMemoryRegionInfo = new(); - private static readonly LuaRef SEnumModules = new(); - private static readonly LuaRef SEnumSectionsOfModule = new(); - private static readonly LuaRef SGetAddressSafe = new(); - private static readonly LuaRef SGetSymbolInfo = new(); - - /// - /// Copies the current target's module table into . - /// - /// The destination for copied module snapshots. - /// The total module count on success; 0 for any other status. - /// - /// , before any - /// element is written, or a binding failure. CE documents the name, address, bitness and file path. A host that - /// additionally supplies Size is represented by a non-null . - /// - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus EnumerateModules(Span destination, out int written) - { - return EnumerateModulesCore(destination, out written, processId: default, hasProcessId: false); - } - - /// - /// Copies the specified process's module table into . - /// - /// The positive process identifier passed as CE's optional processid argument. - /// The destination for copied module snapshots. - /// The total module count on success; 0 for any other status. - /// - /// , before any - /// element is written, or a binding failure. - /// - /// - /// is the default or otherwise non-positive - /// identifier. - /// - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus EnumerateModules(TargetProcessId processId, Span destination, - out int written) - { - ValidateProcessId(processId); - return EnumerateModulesCore(destination, out written, processId: processId, hasProcessId: true); - } - - /// - /// Copies the sections of the module loaded at into - /// . - /// - /// The target-process base address passed as the first form of CE's module selector. - /// The destination for copied section snapshots. - /// The total section count on success; 0 for any other status. - /// - /// , before any - /// element is written, or a binding failure. CE 7.7 documents the returned Name, Size, - /// Address and FileAddress fields. - /// - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus EnumerateSections(Address moduleBase, Span destination, - out int written) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - written = 0; - try - { - var status = PushGlobal(state, SEnumSectionsOfModule, "enumSectionsOfModule"u8); - if (status != InspectionStatus.Success) return status; - - Address.Push(state, moduleBase); - return ReadSectionCollectionAfterCall(state, 1, destination, out written); - } - catch (LuaException) - { - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - /// Copies the sections of the named module into . - /// The module-name form of CE's selector; it is forwarded as UTF-8 without normalization. - /// The destination for copied section snapshots. - /// The total section count on success; 0 for any other status. - /// - /// , before any - /// element is written, or a binding failure. A default or empty is rejected before - /// Lua is entered. - /// - /// has no usable module name. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus EnumerateSections(ModuleName moduleName, Span destination, - out int written) - { - ValidateModuleName(moduleName); - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - written = 0; - try - { - var status = PushGlobal(state, SEnumSectionsOfModule, "enumSectionsOfModule"u8); - if (status != InspectionStatus.Success) return status; - - state.PushString(moduleName.Value.AsSpan()); - return ReadSectionCollectionAfterCall(state, 1, destination, out written); - } - catch (LuaException) - { - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - /// Resolves a symbol expression with CE's non-throwing getAddressSafe global. - /// The non-empty expression supplied to CE's symbol handler. - /// The optional CE lookup flags, forwarded without managed reinterpretation. - /// The resolved target address on success; otherwise. - /// - /// when the result is an address, - /// only when CE returns Lua nil, or a distinct binding failure. - /// - /// has no usable symbol expression. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, - out Address address) - { - ValidateSymbolExpression(expression); - if (options.HostSymbolTableRequested) - throw new ArgumentException( - "Host-symbol resolution must use EngineInspection.ResolveHostAddress.", nameof(options)); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - address = Address.Zero; - try - { - var status = PushGlobal(state, SGetAddressSafe, "getAddressSafe"u8); - if (status != InspectionStatus.Success) return status; - - state.PushString(expression.Value.AsSpan()); - state.PushBoolean(value: false); - state.PushBoolean(options.Shallow); - if (!state.TryCall(3, 1).IsOk) return InspectionStatus.LuaFailure; - if (state.IsNil(-1)) return InspectionStatus.NotFound; - return Address.TryRead(state, -1, out address) - ? InspectionStatus.Success - : InspectionStatus.InvalidResult; - } - catch (LuaException) - { - address = Address.Zero; - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - /// Resolves a symbol expression in Cheat Engine's own symbol table. - /// The non-empty expression supplied to CE's host symbol handler. - /// The optional CE lookup flags, forwarded without managed reinterpretation. - /// The resolved host-process address on success; otherwise. - /// - /// when the result is an address, - /// only when CE returns Lua nil, or a distinct binding failure. - /// - /// has no usable symbol expression. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus ResolveHostAddress(SymbolExpression expression, AddressResolutionOptions options, - out HostAddress address) - { - ValidateSymbolExpression(expression); - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - address = HostAddress.Zero; - try - { - var status = PushGlobal(state, SGetAddressSafe, "getAddressSafe"u8); - if (status != InspectionStatus.Success) return status; - - state.PushString(expression.Value.AsSpan()); - state.PushBoolean(value: true); - state.PushBoolean(options.Shallow); - if (!state.TryCall(3, 1).IsOk) return InspectionStatus.LuaFailure; - if (state.IsNil(-1)) return InspectionStatus.NotFound; - if (!Address.TryRead(state, -1, out var resolved)) return InspectionStatus.InvalidResult; - - address = new HostAddress((nuint)resolved.Value); - return InspectionStatus.Success; - } - catch (LuaException) - { - address = HostAddress.Zero; - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - /// Copies the metadata of a symbol from CE's getSymbolInfo table. - /// The non-empty symbol expression passed to Cheat Engine. - /// The copied symbol information on success; otherwise. - /// - /// only for CE's Lua nil; a malformed non-nil table remains - /// . - /// - /// has no usable symbol expression. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus GetSymbolInfo(SymbolExpression expression, out SymbolInfo symbol) - { - ValidateSymbolExpression(expression); - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - symbol = default; - try - { - var status = PushGlobal(state, SGetSymbolInfo, "getSymbolInfo"u8); - if (status != InspectionStatus.Success) return status; - - state.PushString(expression.Value.AsSpan()); - if (!state.TryCall(1, 1).IsOk) return InspectionStatus.LuaFailure; - if (state.IsNil(-1)) return InspectionStatus.NotFound; - return state.IsTable(-1) && TryReadSymbolInfo(state, -1, out symbol) - ? InspectionStatus.Success - : InspectionStatus.InvalidResult; - } - catch (LuaException) - { - symbol = default; - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - /// Copies CE's complete virtual-memory layout into . - /// The destination for copied memory-region snapshots. - /// The total region count on success; 0 for any other status. - /// - /// , before any - /// element is written, or a binding failure. CE represents mapped-file metadata by an optional Extra field. - /// - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus EnumerateMemoryRegions(Span destination, out int written) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - written = 0; - try - { - var status = PushGlobal(state, SEnumMemoryRegions, "enumMemoryRegions"u8); - if (status != InspectionStatus.Success) return status; - - return ReadMemoryRegionCollectionAfterCall(state, 0, destination, out written); - } - catch (LuaException) - { - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - /// Copies the memory-region record containing . - /// The target-process address passed to CE. - /// The copied region snapshot on success; otherwise. - /// - /// only for a complete CE table. The CE 7.7 text does not document - /// a nil absence return for getMemoryRegionInfo, so a nil result is - /// . - /// - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static InspectionStatus GetMemoryRegionInfo(Address address, out MemoryRegionInfo region) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - region = default; - try - { - var status = PushGlobal(state, SGetMemoryRegionInfo, "getMemoryRegionInfo"u8); - if (status != InspectionStatus.Success) return status; - - Address.Push(state, address); - if (!state.TryCall(1, 1).IsOk) return InspectionStatus.LuaFailure; - return state.IsTable(-1) && TryReadMemoryRegionInfo(state, -1, out region) - ? InspectionStatus.Success - : InspectionStatus.InvalidResult; - } - catch (LuaException) - { - region = default; - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - private static InspectionStatus EnumerateModulesCore(Span destination, out int written, - TargetProcessId processId, bool hasProcessId) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - written = 0; - try - { - var status = PushGlobal(state, SEnumModules, "enumModules"u8); - if (status != InspectionStatus.Success) return status; - - if (hasProcessId) state.PushInteger(processId.Value); - if (!state.TryCall(hasProcessId ? 1 : 0, 1).IsOk) return InspectionStatus.LuaFailure; - return ReadModuleCollection(state, -1, destination, out written); - } - catch (LuaException) - { - return InspectionStatus.LuaFailure; - } - finally - { - state.SetTop(top); - } - } - - private static InspectionStatus ReadSectionCollectionAfterCall(LuaState state, int argumentCount, - Span destination, out int written) - { - if (!state.TryCall(argumentCount, 1).IsOk) - { - written = 0; - return InspectionStatus.LuaFailure; - } - - return ReadSectionCollection(state, -1, destination, out written); - } - - private static InspectionStatus ReadMemoryRegionCollectionAfterCall(LuaState state, int argumentCount, - Span destination, out int written) - { - if (!state.TryCall(argumentCount, 1).IsOk) - { - written = 0; - return InspectionStatus.LuaFailure; - } - - return ReadMemoryRegionCollection(state, -1, destination, out written); - } - - private static InspectionStatus ReadModuleCollection(LuaState state, int tableIndex, Span destination, - out int written) - { - written = 0; - if (!state.IsTable(tableIndex)) return InspectionStatus.InvalidResult; - var count = TryGetSequenceCount(state, tableIndex, out var sequenceCount); - if (count != InspectionStatus.Success) return count; - if (sequenceCount > destination.Length) return InspectionStatus.DestinationTooSmall; - - if (sequenceCount == 0) return InspectionStatus.Success; - - var snapshot = new ModuleInfo[sequenceCount]; - for (var index = 0; index < sequenceCount; index++) - { - if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadModuleInfo(state, -1, out snapshot[index])) - return InspectionStatus.InvalidResult; - - state.Pop(1); - } - - snapshot.AsSpan().CopyTo(destination); - written = sequenceCount; - return InspectionStatus.Success; - } - - private static InspectionStatus ReadSectionCollection(LuaState state, int tableIndex, - Span destination, out int written) - { - written = 0; - if (!state.IsTable(tableIndex)) return InspectionStatus.InvalidResult; - var count = TryGetSequenceCount(state, tableIndex, out var sequenceCount); - if (count != InspectionStatus.Success) return count; - if (sequenceCount > destination.Length) return InspectionStatus.DestinationTooSmall; - - if (sequenceCount == 0) return InspectionStatus.Success; - - var snapshot = new ModuleSectionInfo[sequenceCount]; - for (var index = 0; index < sequenceCount; index++) - { - if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadModuleSectionInfo(state, -1, out snapshot[index])) - return InspectionStatus.InvalidResult; - - state.Pop(1); - } - - snapshot.AsSpan().CopyTo(destination); - written = sequenceCount; - return InspectionStatus.Success; - } - - private static InspectionStatus ReadMemoryRegionCollection(LuaState state, int tableIndex, - Span destination, out int written) - { - written = 0; - if (!state.IsTable(tableIndex)) return InspectionStatus.InvalidResult; - var count = TryGetSequenceCount(state, tableIndex, out var sequenceCount); - if (count != InspectionStatus.Success) return count; - if (sequenceCount > destination.Length) return InspectionStatus.DestinationTooSmall; - - if (sequenceCount == 0) return InspectionStatus.Success; - - var snapshot = new MemoryRegionInfo[sequenceCount]; - for (var index = 0; index < sequenceCount; index++) - { - if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadMemoryRegionInfo(state, -1, out snapshot[index])) - return InspectionStatus.InvalidResult; - - state.Pop(1); - } - - snapshot.AsSpan().CopyTo(destination); - written = sequenceCount; - return InspectionStatus.Success; - } - - private static InspectionStatus TryGetSequenceCount(LuaState state, int tableIndex, out int count) - { - try - { - count = checked((int)state.RawLength(tableIndex)); - return InspectionStatus.Success; - } - catch (OverflowException) - { - count = 0; - return InspectionStatus.InvalidResult; - } - } - - private static bool TryReadModuleInfo(LuaState state, int tableIndex, out ModuleInfo module) - { - module = default; - if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out var name) || - !TryReadAddressField(state, tableIndex, "Address"u8, out var address) || - !TryReadOptionalMemorySizeField(state, tableIndex, "Size"u8, out var size) || - !TryReadBooleanField(state, tableIndex, "Is64Bit"u8, out var is64Bit) || - !TryReadRequiredStringField(state, tableIndex, "PathToFile"u8, out var pathToFile)) - return false; - - module = new ModuleInfo(name, address, size, is64Bit, pathToFile); - return true; - } - - private static bool TryReadModuleSectionInfo(LuaState state, int tableIndex, out ModuleSectionInfo section) - { - section = default; - if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out var name) || - !TryReadMemorySizeField(state, tableIndex, "Size"u8, out var size) || - !TryReadAddressField(state, tableIndex, "Address"u8, out var address) || - !TryReadUInt64Field(state, tableIndex, "FileAddress"u8, out var fileAddress)) - return false; - - section = new ModuleSectionInfo(name, size, address, new ModuleFileOffset(fileAddress)); - return true; - } - - private static bool TryReadSymbolInfo(LuaState state, int tableIndex, out SymbolInfo symbol) - { - symbol = default; - if (!TryReadRequiredStringField(state, tableIndex, "modulename"u8, out var moduleName) || - !TryReadRequiredStringField(state, tableIndex, "searchkey"u8, out var searchKey) || - !TryReadAddressField(state, tableIndex, "address"u8, out var address) || - !TryReadMemorySizeField(state, tableIndex, "symbolsize"u8, out var size)) - return false; - - symbol = new SymbolInfo(moduleName, searchKey, address, size); - return true; - } - - private static bool TryReadMemoryRegionInfo(LuaState state, int tableIndex, out MemoryRegionInfo region) - { - region = default; - if (!TryReadAddressField(state, tableIndex, "BaseAddress"u8, out var baseAddress) || - !TryReadAddressField(state, tableIndex, "AllocationBase"u8, out var allocationBase) || - !TryReadProtectionField(state, tableIndex, "AllocationProtect"u8, out var allocationProtection) || - !TryReadMemorySizeField(state, tableIndex, "RegionSize"u8, out var size) || - !TryReadUInt32Field(state, tableIndex, "State"u8, out var stateValue) || - !TryReadProtectionField(state, tableIndex, "Protect"u8, out var protection) || - !TryReadUInt32Field(state, tableIndex, "Type"u8, out var typeValue) || - !TryReadOptionalStringField(state, tableIndex, "Extra"u8, out var extra)) - return false; - - region = new MemoryRegionInfo(baseAddress, allocationBase, allocationProtection, size, - (MemoryRegionState)stateValue, protection, (MemoryRegionType)typeValue, extra); - return true; - } - - private static bool TryReadRequiredStringField(LuaState state, int tableIndex, ReadOnlySpan field, - [NotNullWhen(true)] out string? value) - { - value = null; - if (!TryGetField(state, tableIndex, field)) return false; - var read = state.TryReadString(-1, out value); - state.Pop(1); - return read; - } - - private static bool TryReadOptionalStringField(LuaState state, int tableIndex, ReadOnlySpan field, - out string? value) - { - value = null; - if (!TryGetField(state, tableIndex, field)) return false; - if (state.IsNil(-1)) - { - state.Pop(1); - return true; - } - - var read = state.TryReadString(-1, out value); - state.Pop(1); - return read; - } - - private static bool TryReadAddressField(LuaState state, int tableIndex, ReadOnlySpan field, out Address value) - { - value = Address.Zero; - if (!TryGetField(state, tableIndex, field)) return false; - var read = Address.TryRead(state, -1, out value); - state.Pop(1); - return read; - } - - private static bool TryReadBooleanField(LuaState state, int tableIndex, ReadOnlySpan field, out bool value) - { - value = false; - if (!TryGetField(state, tableIndex, field)) return false; - var read = state.TypeOf(-1) == LuaType.Boolean; - if (read) value = state.ToBoolean(-1); - state.Pop(1); - return read; - } - - private static bool TryReadMemorySizeField(LuaState state, int tableIndex, ReadOnlySpan field, - out MemorySize value) - { - value = default; - if (!TryReadUInt64Field(state, tableIndex, field, out var bytes)) return false; - value = new MemorySize(bytes); - return true; - } - - private static bool TryReadOptionalMemorySizeField(LuaState state, int tableIndex, ReadOnlySpan field, - out MemorySize? value) - { - value = null; - if (!TryGetField(state, tableIndex, field)) return false; - if (state.IsNil(-1)) - { - state.Pop(1); - return true; - } - - var read = state.TryReadInteger(-1, out var signed); - state.Pop(1); - if (!read || signed < 0) return false; - value = new MemorySize((ulong)signed); - return true; - } - - private static bool TryReadProtectionField(LuaState state, int tableIndex, ReadOnlySpan field, - out MemoryProtection value) - { - value = default; - if (!TryReadUInt32Field(state, tableIndex, field, out var raw)) return false; - value = (MemoryProtection)raw; - return true; - } - - private static bool TryReadUInt32Field(LuaState state, int tableIndex, ReadOnlySpan field, out uint value) - { - value = 0; - if (!TryReadUInt64Field(state, tableIndex, field, out var raw) || raw > uint.MaxValue) return false; - value = (uint)raw; - return true; - } - - private static bool TryReadUInt64Field(LuaState state, int tableIndex, ReadOnlySpan field, out ulong value) - { - value = 0; - if (!TryGetField(state, tableIndex, field)) return false; - var read = state.TryReadInteger(-1, out var signed); - state.Pop(1); - if (!read || signed < 0) return false; - value = (ulong)signed; - return true; - } - - private static InspectionStatus PushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) - { - return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch - { - LuaGlobalPushStatus.Success => InspectionStatus.Success, - LuaGlobalPushStatus.Unavailable => InspectionStatus.GlobalUnavailable, - _ => InspectionStatus.LuaFailure, - }; - } - - private static bool TryGetField(LuaState state, int tableIndex, ReadOnlySpan field) - { - if (state.TryGetField(tableIndex, field).IsOk) return true; - - throw new LuaException("A protected inspection table-field lookup failed."); - } - - private static void ValidateModuleName(ModuleName moduleName) - { - if (string.IsNullOrWhiteSpace(moduleName.Value)) - throw new ArgumentException("A module name must not be empty or white space.", nameof(moduleName)); - } - - private static void ValidateProcessId(TargetProcessId processId) - { - if (processId.Value <= 0) - throw new ArgumentOutOfRangeException(nameof(processId), processId.Value, - "A target process identifier must be positive."); - } - - private static void ValidateSymbolExpression(SymbolExpression expression) - { - if (string.IsNullOrWhiteSpace(expression.Value)) - throw new ArgumentException("A symbol expression must not be empty or white space.", nameof(expression)); - } + private static readonly LuaRef SEnumMemoryRegions = new(); + private static readonly LuaRef SGetMemoryRegionInfo = new(); + private static readonly LuaRef SEnumModules = new(); + private static readonly LuaRef SEnumSectionsOfModule = new(); + private static readonly LuaRef SGetAddressSafe = new(); + private static readonly LuaRef SGetSymbolInfo = new(); + + /// + /// Copies the current target's module table into . + /// + /// The destination for copied module snapshots. + /// The total module count on success; 0 for any other status. + /// + /// , before any + /// element is written, or a binding failure. CE documents the name, address, bitness and file path. A host that + /// additionally supplies Size is represented by a non-null . + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus EnumerateModules(Span destination, out int written) + { + return EnumerateModulesCore(destination, out written, default, false); + } + + /// + /// Copies the specified process's module table into . + /// + /// The positive process identifier passed as CE's optional processid argument. + /// The destination for copied module snapshots. + /// The total module count on success; 0 for any other status. + /// + /// , before any + /// element is written, or a binding failure. + /// + /// + /// is the default or otherwise non-positive + /// identifier. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus EnumerateModules(TargetProcessId processId, Span destination, + out int written) + { + ValidateProcessId(processId); + return EnumerateModulesCore(destination, out written, processId, true); + } + + /// + /// Copies the sections of the module loaded at into + /// . + /// + /// The target-process base address passed as the first form of CE's module selector. + /// The destination for copied section snapshots. + /// The total section count on success; 0 for any other status. + /// + /// , before any + /// element is written, or a binding failure. CE 7.7 documents the returned Name, Size, + /// Address and FileAddress fields. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus EnumerateSections(Address moduleBase, Span destination, + out int written) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + written = 0; + try + { + InspectionStatus status = PushGlobal(state, SEnumSectionsOfModule, "enumSectionsOfModule"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + Address.Push(state, moduleBase); + return ReadSectionCollectionAfterCall(state, 1, destination, out written); + } + catch (LuaException) + { + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + /// Copies the sections of the named module into . + /// The module-name form of CE's selector; it is forwarded as UTF-8 without normalization. + /// The destination for copied section snapshots. + /// The total section count on success; 0 for any other status. + /// + /// , before any + /// element is written, or a binding failure. A default or empty is rejected before + /// Lua is entered. + /// + /// has no usable module name. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus EnumerateSections(ModuleName moduleName, Span destination, + out int written) + { + ValidateModuleName(moduleName); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + written = 0; + try + { + InspectionStatus status = PushGlobal(state, SEnumSectionsOfModule, "enumSectionsOfModule"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + state.PushString(moduleName.Value.AsSpan()); + return ReadSectionCollectionAfterCall(state, 1, destination, out written); + } + catch (LuaException) + { + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + /// Resolves a symbol expression with CE's non-throwing getAddressSafe global. + /// The non-empty expression supplied to CE's symbol handler. + /// The optional CE lookup flags, forwarded without managed reinterpretation. + /// The resolved target address on success; otherwise. + /// + /// when the result is an address, + /// only when CE returns Lua nil, or a distinct binding failure. + /// + /// has no usable symbol expression. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address) + { + ValidateSymbolExpression(expression); + if (options.HostSymbolTableRequested) + { + throw new ArgumentException( + "Host-symbol resolution must use EngineInspection.ResolveHostAddress.", nameof(options)); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + address = Address.Zero; + try + { + InspectionStatus status = PushGlobal(state, SGetAddressSafe, "getAddressSafe"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + state.PushString(expression.Value.AsSpan()); + state.PushBoolean(false); + state.PushBoolean(options.Shallow); + if (!state.TryCall(3, 1).IsOk) + { + return InspectionStatus.LuaFailure; + } + + if (state.IsNil(-1)) + { + return InspectionStatus.NotFound; + } + + return Address.TryRead(state, -1, out address) + ? InspectionStatus.Success + : InspectionStatus.InvalidResult; + } + catch (LuaException) + { + address = Address.Zero; + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + /// Resolves a symbol expression in Cheat Engine's own symbol table. + /// The non-empty expression supplied to CE's host symbol handler. + /// The optional CE lookup flags, forwarded without managed reinterpretation. + /// The resolved host-process address on success; otherwise. + /// + /// when the result is an address, + /// only when CE returns Lua nil, or a distinct binding failure. + /// + /// has no usable symbol expression. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus ResolveHostAddress(SymbolExpression expression, AddressResolutionOptions options, + out HostAddress address) + { + ValidateSymbolExpression(expression); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + address = HostAddress.Zero; + try + { + InspectionStatus status = PushGlobal(state, SGetAddressSafe, "getAddressSafe"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + state.PushString(expression.Value.AsSpan()); + state.PushBoolean(true); + state.PushBoolean(options.Shallow); + if (!state.TryCall(3, 1).IsOk) + { + return InspectionStatus.LuaFailure; + } + + if (state.IsNil(-1)) + { + return InspectionStatus.NotFound; + } + + if (!Address.TryRead(state, -1, out Address resolved)) + { + return InspectionStatus.InvalidResult; + } + + address = new HostAddress((nuint) resolved.Value); + return InspectionStatus.Success; + } + catch (LuaException) + { + address = HostAddress.Zero; + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + /// Copies the metadata of a symbol from CE's getSymbolInfo table. + /// The non-empty symbol expression passed to Cheat Engine. + /// The copied symbol information on success; otherwise. + /// + /// only for CE's Lua nil; a malformed non-nil table remains + /// . + /// + /// has no usable symbol expression. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus GetSymbolInfo(SymbolExpression expression, out SymbolInfo symbol) + { + ValidateSymbolExpression(expression); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + symbol = default; + try + { + InspectionStatus status = PushGlobal(state, SGetSymbolInfo, "getSymbolInfo"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + state.PushString(expression.Value.AsSpan()); + if (!state.TryCall(1, 1).IsOk) + { + return InspectionStatus.LuaFailure; + } + + if (state.IsNil(-1)) + { + return InspectionStatus.NotFound; + } + + return state.IsTable(-1) && TryReadSymbolInfo(state, -1, out symbol) + ? InspectionStatus.Success + : InspectionStatus.InvalidResult; + } + catch (LuaException) + { + symbol = default; + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + /// Copies CE's complete virtual-memory layout into . + /// The destination for copied memory-region snapshots. + /// The total region count on success; 0 for any other status. + /// + /// , before any + /// element is written, or a binding failure. CE represents mapped-file metadata by an optional Extra field. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus EnumerateMemoryRegions(Span destination, out int written) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + written = 0; + try + { + InspectionStatus status = PushGlobal(state, SEnumMemoryRegions, "enumMemoryRegions"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + return ReadMemoryRegionCollectionAfterCall(state, 0, destination, out written); + } + catch (LuaException) + { + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + /// Copies the memory-region record containing . + /// The target-process address passed to CE. + /// The copied region snapshot on success; otherwise. + /// + /// only for a complete CE table. The CE 7.7 text does not document + /// a nil absence return for getMemoryRegionInfo, so a nil result is + /// . + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static InspectionStatus GetMemoryRegionInfo(Address address, out MemoryRegionInfo region) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + region = default; + try + { + InspectionStatus status = PushGlobal(state, SGetMemoryRegionInfo, "getMemoryRegionInfo"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + Address.Push(state, address); + if (!state.TryCall(1, 1).IsOk) + { + return InspectionStatus.LuaFailure; + } + + return state.IsTable(-1) && TryReadMemoryRegionInfo(state, -1, out region) + ? InspectionStatus.Success + : InspectionStatus.InvalidResult; + } + catch (LuaException) + { + region = default; + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + private static InspectionStatus EnumerateModulesCore(Span destination, out int written, + TargetProcessId processId, bool hasProcessId) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + written = 0; + try + { + InspectionStatus status = PushGlobal(state, SEnumModules, "enumModules"u8); + if (status != InspectionStatus.Success) + { + return status; + } + + if (hasProcessId) + { + state.PushInteger(processId.Value); + } + + if (!state.TryCall(hasProcessId ? 1 : 0, 1).IsOk) + { + return InspectionStatus.LuaFailure; + } + + return ReadModuleCollection(state, -1, destination, out written); + } + catch (LuaException) + { + return InspectionStatus.LuaFailure; + } + finally + { + state.SetTop(top); + } + } + + private static InspectionStatus ReadSectionCollectionAfterCall(LuaState state, int argumentCount, + Span destination, out int written) + { + if (!state.TryCall(argumentCount, 1).IsOk) + { + written = 0; + return InspectionStatus.LuaFailure; + } + + return ReadSectionCollection(state, -1, destination, out written); + } + + private static InspectionStatus ReadMemoryRegionCollectionAfterCall(LuaState state, int argumentCount, + Span destination, out int written) + { + if (!state.TryCall(argumentCount, 1).IsOk) + { + written = 0; + return InspectionStatus.LuaFailure; + } + + return ReadMemoryRegionCollection(state, -1, destination, out written); + } + + private static InspectionStatus ReadModuleCollection(LuaState state, int tableIndex, Span destination, + out int written) + { + written = 0; + if (!state.IsTable(tableIndex)) + { + return InspectionStatus.InvalidResult; + } + + InspectionStatus count = TryGetSequenceCount(state, tableIndex, out int sequenceCount); + if (count != InspectionStatus.Success) + { + return count; + } + + if (sequenceCount > destination.Length) + { + return InspectionStatus.DestinationTooSmall; + } + + if (sequenceCount == 0) + { + return InspectionStatus.Success; + } + + ModuleInfo[] snapshot = new ModuleInfo[sequenceCount]; + for (int index = 0; index < sequenceCount; index++) + { + if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || + !TryReadModuleInfo(state, -1, out snapshot[index])) + { + return InspectionStatus.InvalidResult; + } + + state.Pop(1); + } + + snapshot.AsSpan().CopyTo(destination); + written = sequenceCount; + return InspectionStatus.Success; + } + + private static InspectionStatus ReadSectionCollection(LuaState state, int tableIndex, + Span destination, out int written) + { + written = 0; + if (!state.IsTable(tableIndex)) + { + return InspectionStatus.InvalidResult; + } + + InspectionStatus count = TryGetSequenceCount(state, tableIndex, out int sequenceCount); + if (count != InspectionStatus.Success) + { + return count; + } + + if (sequenceCount > destination.Length) + { + return InspectionStatus.DestinationTooSmall; + } + + if (sequenceCount == 0) + { + return InspectionStatus.Success; + } + + ModuleSectionInfo[] snapshot = new ModuleSectionInfo[sequenceCount]; + for (int index = 0; index < sequenceCount; index++) + { + if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || + !TryReadModuleSectionInfo(state, -1, out snapshot[index])) + { + return InspectionStatus.InvalidResult; + } + + state.Pop(1); + } + + snapshot.AsSpan().CopyTo(destination); + written = sequenceCount; + return InspectionStatus.Success; + } + + private static InspectionStatus ReadMemoryRegionCollection(LuaState state, int tableIndex, + Span destination, out int written) + { + written = 0; + if (!state.IsTable(tableIndex)) + { + return InspectionStatus.InvalidResult; + } + + InspectionStatus count = TryGetSequenceCount(state, tableIndex, out int sequenceCount); + if (count != InspectionStatus.Success) + { + return count; + } + + if (sequenceCount > destination.Length) + { + return InspectionStatus.DestinationTooSmall; + } + + if (sequenceCount == 0) + { + return InspectionStatus.Success; + } + + MemoryRegionInfo[] snapshot = new MemoryRegionInfo[sequenceCount]; + for (int index = 0; index < sequenceCount; index++) + { + if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || + !TryReadMemoryRegionInfo(state, -1, out snapshot[index])) + { + return InspectionStatus.InvalidResult; + } + + state.Pop(1); + } + + snapshot.AsSpan().CopyTo(destination); + written = sequenceCount; + return InspectionStatus.Success; + } + + private static InspectionStatus TryGetSequenceCount(LuaState state, int tableIndex, out int count) + { + try + { + count = checked((int) state.RawLength(tableIndex)); + return InspectionStatus.Success; + } + catch (OverflowException) + { + count = 0; + return InspectionStatus.InvalidResult; + } + } + + private static bool TryReadModuleInfo(LuaState state, int tableIndex, out ModuleInfo module) + { + module = default; + if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out string? name) || + !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || + !TryReadOptionalMemorySizeField(state, tableIndex, "Size"u8, out MemorySize? size) || + !TryReadBooleanField(state, tableIndex, "Is64Bit"u8, out bool is64Bit) || + !TryReadRequiredStringField(state, tableIndex, "PathToFile"u8, out string? pathToFile)) + { + return false; + } + + module = new ModuleInfo(name, address, size, is64Bit, pathToFile); + return true; + } + + private static bool TryReadModuleSectionInfo(LuaState state, int tableIndex, out ModuleSectionInfo section) + { + section = default; + if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out string? name) || + !TryReadMemorySizeField(state, tableIndex, "Size"u8, out MemorySize size) || + !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || + !TryReadUInt64Field(state, tableIndex, "FileAddress"u8, out ulong fileAddress)) + { + return false; + } + + section = new ModuleSectionInfo(name, size, address, new ModuleFileOffset(fileAddress)); + return true; + } + + private static bool TryReadSymbolInfo(LuaState state, int tableIndex, out SymbolInfo symbol) + { + symbol = default; + if (!TryReadRequiredStringField(state, tableIndex, "modulename"u8, out string? moduleName) || + !TryReadRequiredStringField(state, tableIndex, "searchkey"u8, out string? searchKey) || + !TryReadAddressField(state, tableIndex, "address"u8, out Address address) || + !TryReadMemorySizeField(state, tableIndex, "symbolsize"u8, out MemorySize size)) + { + return false; + } + + symbol = new SymbolInfo(moduleName, searchKey, address, size); + return true; + } + + private static bool TryReadMemoryRegionInfo(LuaState state, int tableIndex, out MemoryRegionInfo region) + { + region = default; + if (!TryReadAddressField(state, tableIndex, "BaseAddress"u8, out Address baseAddress) || + !TryReadAddressField(state, tableIndex, "AllocationBase"u8, out Address allocationBase) || + !TryReadProtectionField(state, tableIndex, "AllocationProtect"u8, + out MemoryProtection allocationProtection) || + !TryReadMemorySizeField(state, tableIndex, "RegionSize"u8, out MemorySize size) || + !TryReadUInt32Field(state, tableIndex, "State"u8, out uint stateValue) || + !TryReadProtectionField(state, tableIndex, "Protect"u8, out MemoryProtection protection) || + !TryReadUInt32Field(state, tableIndex, "Type"u8, out uint typeValue) || + !TryReadOptionalStringField(state, tableIndex, "Extra"u8, out string? extra)) + { + return false; + } + + region = new MemoryRegionInfo(baseAddress, allocationBase, allocationProtection, size, + (MemoryRegionState) stateValue, protection, (MemoryRegionType) typeValue, extra); + return true; + } + + private static bool TryReadRequiredStringField(LuaState state, int tableIndex, ReadOnlySpan field, + [NotNullWhen(true)] out string? value) + { + value = null; + if (!TryGetField(state, tableIndex, field)) + { + return false; + } + + bool read = state.TryReadString(-1, out value); + state.Pop(1); + return read; + } + + private static bool TryReadOptionalStringField(LuaState state, int tableIndex, ReadOnlySpan field, + out string? value) + { + value = null; + if (!TryGetField(state, tableIndex, field)) + { + return false; + } + + if (state.IsNil(-1)) + { + state.Pop(1); + return true; + } + + bool read = state.TryReadString(-1, out value); + state.Pop(1); + return read; + } + + private static bool TryReadAddressField(LuaState state, int tableIndex, ReadOnlySpan field, out Address value) + { + value = Address.Zero; + if (!TryGetField(state, tableIndex, field)) + { + return false; + } + + bool read = Address.TryRead(state, -1, out value); + state.Pop(1); + return read; + } + + private static bool TryReadBooleanField(LuaState state, int tableIndex, ReadOnlySpan field, out bool value) + { + value = false; + if (!TryGetField(state, tableIndex, field)) + { + return false; + } + + bool read = state.TypeOf(-1) == LuaType.Boolean; + if (read) + { + value = state.ToBoolean(-1); + } + + state.Pop(1); + return read; + } + + private static bool TryReadMemorySizeField(LuaState state, int tableIndex, ReadOnlySpan field, + out MemorySize value) + { + value = default; + if (!TryReadUInt64Field(state, tableIndex, field, out ulong bytes)) + { + return false; + } + + value = new MemorySize(bytes); + return true; + } + + private static bool TryReadOptionalMemorySizeField(LuaState state, int tableIndex, ReadOnlySpan field, + out MemorySize? value) + { + value = null; + if (!TryGetField(state, tableIndex, field)) + { + return false; + } + + if (state.IsNil(-1)) + { + state.Pop(1); + return true; + } + + bool read = state.TryReadInteger(-1, out long signed); + state.Pop(1); + if (!read || signed < 0) + { + return false; + } + + value = new MemorySize((ulong) signed); + return true; + } + + private static bool TryReadProtectionField(LuaState state, int tableIndex, ReadOnlySpan field, + out MemoryProtection value) + { + value = default; + if (!TryReadUInt32Field(state, tableIndex, field, out uint raw)) + { + return false; + } + + value = (MemoryProtection) raw; + return true; + } + + private static bool TryReadUInt32Field(LuaState state, int tableIndex, ReadOnlySpan field, out uint value) + { + value = 0; + if (!TryReadUInt64Field(state, tableIndex, field, out ulong raw) || raw > uint.MaxValue) + { + return false; + } + + value = (uint) raw; + return true; + } + + private static bool TryReadUInt64Field(LuaState state, int tableIndex, ReadOnlySpan field, out ulong value) + { + value = 0; + if (!TryGetField(state, tableIndex, field)) + { + return false; + } + + bool read = state.TryReadInteger(-1, out long signed); + state.Pop(1); + if (!read || signed < 0) + { + return false; + } + + value = (ulong) signed; + return true; + } + + private static InspectionStatus PushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) + { + return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch + { + LuaGlobalPushStatus.Success => InspectionStatus.Success, + LuaGlobalPushStatus.Unavailable => InspectionStatus.GlobalUnavailable, + _ => InspectionStatus.LuaFailure + }; + } + + private static bool TryGetField(LuaState state, int tableIndex, ReadOnlySpan field) + { + if (state.TryGetField(tableIndex, field).IsOk) + { + return true; + } + + throw new LuaException("A protected inspection table-field lookup failed."); + } + + private static void ValidateModuleName(ModuleName moduleName) + { + if (string.IsNullOrWhiteSpace(moduleName.Value)) + { + throw new ArgumentException("A module name must not be empty or white space.", nameof(moduleName)); + } + } + + private static void ValidateProcessId(TargetProcessId processId) + { + if (processId.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(processId), processId.Value, + "A target process identifier must be positive."); + } + } + + private static void ValidateSymbolExpression(SymbolExpression expression) + { + if (string.IsNullOrWhiteSpace(expression.Value)) + { + throw new ArgumentException("A symbol expression must not be empty or white space.", nameof(expression)); + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/InspectionStatus.cs b/libs/CheatEngine.SDK.Engine/Inspection/InspectionStatus.cs index 7ed15d74..2273d821 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/InspectionStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/InspectionStatus.cs @@ -11,21 +11,21 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public enum InspectionStatus { - /// The operation completed and all returned fields matched their documented Lua shapes. - Success, + /// The operation completed and all returned fields matched their documented Lua shapes. + Success, - /// The requested symbol or symbol metadata does not exist; this is represented by Lua nil. - NotFound, + /// The requested symbol or symbol metadata does not exist; this is represented by Lua nil. + NotFound, - /// The output collection is larger than the caller-supplied destination; no item was written. - DestinationTooSmall, + /// The output collection is larger than the caller-supplied destination; no item was written. + DestinationTooSmall, - /// The required Cheat Engine Lua global is absent or is not a function. - GlobalUnavailable, + /// The required Cheat Engine Lua global is absent or is not a function. + GlobalUnavailable, - /// The protected Lua call or an argument push failed. - LuaFailure, + /// The protected Lua call or an argument push failed. + LuaFailure, - /// The call succeeded but returned a value whose table, field or scalar shape is not the CE 7.7 contract. - InvalidResult, + /// The call succeeded but returned a value whose table, field or scalar shape is not the CE 7.7 contract. + InvalidResult } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionInfo.cs b/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionInfo.cs index 0faa6283..4e147ab5 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionInfo.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionInfo.cs @@ -20,11 +20,11 @@ namespace CheatEngine.SDK.Engine.Inspection; /// The Windows MEM_* backing type. /// The optional mapped-file description, or when omitted. public readonly record struct MemoryRegionInfo( - Address BaseAddress, - Address AllocationBase, - MemoryProtection AllocationProtection, - MemorySize Size, - MemoryRegionState State, - MemoryProtection Protection, - MemoryRegionType Type, - string? Extra); + Address BaseAddress, + Address AllocationBase, + MemoryProtection AllocationProtection, + MemorySize Size, + MemoryRegionState State, + MemoryProtection Protection, + MemoryRegionType Type, + string? Extra); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionState.cs b/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionState.cs index 5366b91f..21cb7313 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionState.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionState.cs @@ -8,12 +8,12 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public enum MemoryRegionState : uint { - /// Pages are committed (MEM_COMMIT). - Committed = 0x1000, + /// Pages are committed (MEM_COMMIT). + Committed = 0x1000, - /// Pages are reserved (MEM_RESERVE). - Reserved = 0x2000, + /// Pages are reserved (MEM_RESERVE). + Reserved = 0x2000, - /// The address range is free (MEM_FREE). - Free = 0x10000, + /// The address range is free (MEM_FREE). + Free = 0x10000 } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionType.cs b/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionType.cs index 29337f17..1474b563 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionType.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/MemoryRegionType.cs @@ -7,12 +7,12 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public enum MemoryRegionType : uint { - /// Private committed pages (MEM_PRIVATE). - Private = 0x20000, + /// Private committed pages (MEM_PRIVATE). + Private = 0x20000, - /// Mapped-file pages (MEM_MAPPED). - Mapped = 0x40000, + /// Mapped-file pages (MEM_MAPPED). + Mapped = 0x40000, - /// Image-mapped pages (MEM_IMAGE). - Image = 0x1000000, + /// Image-mapped pages (MEM_IMAGE). + Image = 0x1000000 } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/MemorySize.cs b/libs/CheatEngine.SDK.Engine/Inspection/MemorySize.cs index 72245a0f..07124f73 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/MemorySize.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/MemorySize.cs @@ -11,80 +11,83 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly struct MemorySize : IEquatable, IComparable { - /// Creates a byte count. - /// The count in bytes. - public MemorySize(ulong value) - { - Value = value; - } + /// Creates a byte count. + /// The count in bytes. + public MemorySize(ulong value) + { + Value = value; + } - /// Gets the count in bytes. - public ulong Value { get; } + /// Gets the count in bytes. + public ulong Value + { + get; + } - /// - public bool Equals(MemorySize other) - { - return Value == other.Value; - } + /// + public bool Equals(MemorySize other) + { + return Value == other.Value; + } - /// - public override bool Equals(object? obj) - { - return obj is MemorySize other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is MemorySize other && Equals(other); + } - /// - public override int GetHashCode() - { - return Value.GetHashCode(); - } + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } - /// - public int CompareTo(MemorySize other) - { - return Value.CompareTo(other.Value); - } + /// + public int CompareTo(MemorySize other) + { + return Value.CompareTo(other.Value); + } - /// Formats the count as invariant decimal digits. - /// The byte count. - public override string ToString() - { - return Value.ToString(CultureInfo.InvariantCulture); - } + /// Formats the count as invariant decimal digits. + /// The byte count. + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } - /// Tests two sizes for equality. - public static bool operator ==(MemorySize left, MemorySize right) - { - return left.Equals(right); - } + /// Tests two sizes for equality. + public static bool operator ==(MemorySize left, MemorySize right) + { + return left.Equals(right); + } - /// Tests two sizes for inequality. - public static bool operator !=(MemorySize left, MemorySize right) - { - return !left.Equals(right); - } + /// Tests two sizes for inequality. + public static bool operator !=(MemorySize left, MemorySize right) + { + return !left.Equals(right); + } - /// Orders two sizes by their unsigned byte counts. - public static bool operator <(MemorySize left, MemorySize right) - { - return left.Value < right.Value; - } + /// Orders two sizes by their unsigned byte counts. + public static bool operator <(MemorySize left, MemorySize right) + { + return left.Value < right.Value; + } - /// Orders two sizes by their unsigned byte counts. - public static bool operator >(MemorySize left, MemorySize right) - { - return left.Value > right.Value; - } + /// Orders two sizes by their unsigned byte counts. + public static bool operator >(MemorySize left, MemorySize right) + { + return left.Value > right.Value; + } - /// Orders two sizes by their unsigned byte counts. - public static bool operator <=(MemorySize left, MemorySize right) - { - return left.Value <= right.Value; - } + /// Orders two sizes by their unsigned byte counts. + public static bool operator <=(MemorySize left, MemorySize right) + { + return left.Value <= right.Value; + } - /// Orders two sizes by their unsigned byte counts. - public static bool operator >=(MemorySize left, MemorySize right) - { - return left.Value >= right.Value; - } + /// Orders two sizes by their unsigned byte counts. + public static bool operator >=(MemorySize left, MemorySize right) + { + return left.Value >= right.Value; + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/ModuleFileOffset.cs b/libs/CheatEngine.SDK.Engine/Inspection/ModuleFileOffset.cs index 85c68c2e..5ddb41ef 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/ModuleFileOffset.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/ModuleFileOffset.cs @@ -13,50 +13,53 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly struct ModuleFileOffset : IEquatable { - /// Creates a module-file offset. - /// The zero-based byte offset in the module file. - public ModuleFileOffset(ulong value) - { - Value = value; - } - - /// Gets the zero-based byte offset in the module file. - public ulong Value { get; } - - /// - public bool Equals(ModuleFileOffset other) - { - return Value == other.Value; - } - - /// - public override bool Equals(object? obj) - { - return obj is ModuleFileOffset other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Value.GetHashCode(); - } - - /// Formats the offset as invariant hexadecimal. - /// The offset in hexadecimal. - public override string ToString() - { - return Value.ToString("X", CultureInfo.InvariantCulture); - } - - /// Tests two module-file offsets for equality. - public static bool operator ==(ModuleFileOffset left, ModuleFileOffset right) - { - return left.Equals(right); - } - - /// Tests two module-file offsets for inequality. - public static bool operator !=(ModuleFileOffset left, ModuleFileOffset right) - { - return !left.Equals(right); - } + /// Creates a module-file offset. + /// The zero-based byte offset in the module file. + public ModuleFileOffset(ulong value) + { + Value = value; + } + + /// Gets the zero-based byte offset in the module file. + public ulong Value + { + get; + } + + /// + public bool Equals(ModuleFileOffset other) + { + return Value == other.Value; + } + + /// + public override bool Equals(object? obj) + { + return obj is ModuleFileOffset other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + /// Formats the offset as invariant hexadecimal. + /// The offset in hexadecimal. + public override string ToString() + { + return Value.ToString("X", CultureInfo.InvariantCulture); + } + + /// Tests two module-file offsets for equality. + public static bool operator ==(ModuleFileOffset left, ModuleFileOffset right) + { + return left.Equals(right); + } + + /// Tests two module-file offsets for inequality. + public static bool operator !=(ModuleFileOffset left, ModuleFileOffset right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/ModuleInfo.cs b/libs/CheatEngine.SDK.Engine/Inspection/ModuleInfo.cs index b8803090..3757a3ac 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/ModuleInfo.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/ModuleInfo.cs @@ -19,8 +19,8 @@ namespace CheatEngine.SDK.Engine.Inspection; /// Whether Cheat Engine reports this module as a 64-bit module. /// The path from which Cheat Engine reports the module was loaded. public readonly record struct ModuleInfo( - string Name, - Address BaseAddress, - MemorySize? ImageSize, - bool Is64Bit, - string PathToFile); + string Name, + Address BaseAddress, + MemorySize? ImageSize, + bool Is64Bit, + string PathToFile); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/ModuleName.cs b/libs/CheatEngine.SDK.Engine/Inspection/ModuleName.cs index e83b71ad..237144f0 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/ModuleName.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/ModuleName.cs @@ -9,54 +9,59 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly struct ModuleName : IEquatable { - /// Creates a module-name selector. - /// The non-empty module name understood by Cheat Engine's symbol handler. - /// is null, empty or white space. - public ModuleName(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("A module name must not be empty or white space.", nameof(value)); - - Value = value; - } - - /// Gets the module name as supplied by the caller. - public string Value { get; } - - /// - public bool Equals(ModuleName other) - { - return StringComparer.Ordinal.Equals(Value, other.Value); - } - - /// - public override bool Equals(object? obj) - { - return obj is ModuleName other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Value is null ? 0 : StringComparer.Ordinal.GetHashCode(Value); - } - - /// Returns the module name. - /// The original module name. - public override string ToString() - { - return Value ?? string.Empty; - } - - /// Tests two module names with ordinal comparison. - public static bool operator ==(ModuleName left, ModuleName right) - { - return left.Equals(right); - } - - /// Tests two module names with ordinal comparison. - public static bool operator !=(ModuleName left, ModuleName right) - { - return !left.Equals(right); - } + /// Creates a module-name selector. + /// The non-empty module name understood by Cheat Engine's symbol handler. + /// is null, empty or white space. + public ModuleName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A module name must not be empty or white space.", nameof(value)); + } + + Value = value; + } + + /// Gets the module name as supplied by the caller. + public string Value + { + get; + } + + /// + public bool Equals(ModuleName other) + { + return StringComparer.Ordinal.Equals(Value, other.Value); + } + + /// + public override bool Equals(object? obj) + { + return obj is ModuleName other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value is null ? 0 : StringComparer.Ordinal.GetHashCode(Value); + } + + /// Returns the module name. + /// The original module name. + public override string ToString() + { + return Value ?? string.Empty; + } + + /// Tests two module names with ordinal comparison. + public static bool operator ==(ModuleName left, ModuleName right) + { + return left.Equals(right); + } + + /// Tests two module names with ordinal comparison. + public static bool operator !=(ModuleName left, ModuleName right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/ModuleSectionInfo.cs b/libs/CheatEngine.SDK.Engine/Inspection/ModuleSectionInfo.cs index f68d1bca..1023b114 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/ModuleSectionInfo.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/ModuleSectionInfo.cs @@ -14,7 +14,7 @@ namespace CheatEngine.SDK.Engine.Inspection; /// The section's current target-process address. /// The section's byte offset in the module file. public readonly record struct ModuleSectionInfo( - string Name, - MemorySize Size, - Address Address, - ModuleFileOffset FileOffset); + string Name, + MemorySize Size, + Address Address, + ModuleFileOffset FileOffset); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolExpression.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolExpression.cs index ab8dd13d..6f24eb4c 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolExpression.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolExpression.cs @@ -9,54 +9,59 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly struct SymbolExpression : IEquatable { - /// Creates a symbol expression. - /// The non-empty expression passed to Cheat Engine. - /// is null, empty or white space. - public SymbolExpression(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("A symbol expression must not be empty or white space.", nameof(value)); - - Value = value; - } - - /// Gets the original expression. - public string Value { get; } - - /// - public bool Equals(SymbolExpression other) - { - return StringComparer.Ordinal.Equals(Value, other.Value); - } - - /// - public override bool Equals(object? obj) - { - return obj is SymbolExpression other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Value is null ? 0 : StringComparer.Ordinal.GetHashCode(Value); - } - - /// Returns the original expression. - /// The expression passed to Cheat Engine. - public override string ToString() - { - return Value ?? string.Empty; - } - - /// Tests two expressions with ordinal comparison. - public static bool operator ==(SymbolExpression left, SymbolExpression right) - { - return left.Equals(right); - } - - /// Tests two expressions with ordinal comparison. - public static bool operator !=(SymbolExpression left, SymbolExpression right) - { - return !left.Equals(right); - } + /// Creates a symbol expression. + /// The non-empty expression passed to Cheat Engine. + /// is null, empty or white space. + public SymbolExpression(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A symbol expression must not be empty or white space.", nameof(value)); + } + + Value = value; + } + + /// Gets the original expression. + public string Value + { + get; + } + + /// + public bool Equals(SymbolExpression other) + { + return StringComparer.Ordinal.Equals(Value, other.Value); + } + + /// + public override bool Equals(object? obj) + { + return obj is SymbolExpression other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value is null ? 0 : StringComparer.Ordinal.GetHashCode(Value); + } + + /// Returns the original expression. + /// The expression passed to Cheat Engine. + public override string ToString() + { + return Value ?? string.Empty; + } + + /// Tests two expressions with ordinal comparison. + public static bool operator ==(SymbolExpression left, SymbolExpression right) + { + return left.Equals(right); + } + + /// Tests two expressions with ordinal comparison. + public static bool operator !=(SymbolExpression left, SymbolExpression right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolInfo.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolInfo.cs index d3406acd..57090118 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolInfo.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolInfo.cs @@ -15,7 +15,7 @@ namespace CheatEngine.SDK.Engine.Inspection; /// The target-process address of the symbol. /// The symbol extent in bytes. public readonly record struct SymbolInfo( - string ModuleName, - string SearchKey, - Address Address, - MemorySize Size); + string ModuleName, + string SearchKey, + Address Address, + MemorySize Size); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolName.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolName.cs index 2a892627..e217fbe1 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolName.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolName.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; @@ -13,81 +14,86 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly struct SymbolName : IEquatable, ILuaMarshaller { - /// Creates a user-defined symbol name. - /// The non-empty name supplied to CE's registerSymbol and unregisterSymbol globals. - /// is null, empty or white space. - public SymbolName(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("A symbol name must not be empty or white space.", nameof(value)); + /// Creates a user-defined symbol name. + /// The non-empty name supplied to CE's registerSymbol and unregisterSymbol globals. + /// is null, empty or white space. + public SymbolName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A symbol name must not be empty or white space.", nameof(value)); + } - Value = value; - } + Value = value; + } - /// Gets the original symbol name. - public string Value { get; } + /// Gets the original symbol name. + public string Value + { + get; + } - /// - public bool Equals(SymbolName other) - { - return StringComparer.Ordinal.Equals(Value, other.Value); - } + /// + public bool Equals(SymbolName other) + { + return StringComparer.Ordinal.Equals(Value, other.Value); + } - /// - public override bool Equals(object? obj) - { - return obj is SymbolName other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is SymbolName other && Equals(other); + } - /// - public override int GetHashCode() - { - return Value is null ? 0 : StringComparer.Ordinal.GetHashCode(Value); - } + /// + public override int GetHashCode() + { + return Value is null ? 0 : StringComparer.Ordinal.GetHashCode(Value); + } - /// Returns the original symbol name. - /// The name supplied to Cheat Engine. - public override string ToString() - { - return Value ?? string.Empty; - } + /// Returns the original symbol name. + /// The name supplied to Cheat Engine. + public override string ToString() + { + return Value ?? string.Empty; + } - /// Tests two symbol names with ordinal comparison. - /// The first symbol name. - /// The second symbol name. - /// when the names have the same ordinal text. - public static bool operator ==(SymbolName left, SymbolName right) - { - return left.Equals(right); - } + /// Tests two symbol names with ordinal comparison. + /// The first symbol name. + /// The second symbol name. + /// when the names have the same ordinal text. + public static bool operator ==(SymbolName left, SymbolName right) + { + return left.Equals(right); + } - /// Tests two symbol names with ordinal comparison. - /// The first symbol name. - /// The second symbol name. - /// when the names have different ordinal text. - public static bool operator !=(SymbolName left, SymbolName right) - { - return !left.Equals(right); - } + /// Tests two symbol names with ordinal comparison. + /// The first symbol name. + /// The second symbol name. + /// when the names have different ordinal text. + public static bool operator !=(SymbolName left, SymbolName right) + { + return !left.Equals(right); + } - /// - [LuaStackEffect(1)] - public static void Push(LuaState state, SymbolName value) - { - StringMarshaller.Push(state, value.Value); - } + /// + [LuaStackEffect(1)] + public static void Push(LuaState state, SymbolName value) + { + StringMarshaller.Push(state, value.Value); + } - /// - [LuaStackEffect(0)] - public static bool TryRead(LuaState state, int index, out SymbolName value) - { - if (StringMarshaller.TryRead(state, index, out var text) && !string.IsNullOrWhiteSpace(text)) - { - value = new SymbolName(text); - return true; - } + /// + [LuaStackEffect(0)] + public static bool TryRead(LuaState state, int index, out SymbolName value) + { + if (StringMarshaller.TryRead(state, index, out string? text) && !string.IsNullOrWhiteSpace(text)) + { + value = new SymbolName(text); + return true; + } - value = default; - return false; - } + value = default; + return false; + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationAcquireOutcome.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationAcquireOutcome.cs index ac6ce9f2..84c598a0 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationAcquireOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationAcquireOutcome.cs @@ -5,18 +5,24 @@ namespace CheatEngine.SDK.Engine.Inspection; /// The result of registering a symbol through the SDK ownership coordinator. public readonly struct SymbolRegistrationAcquireOutcome { - internal SymbolRegistrationAcquireOutcome(LuaOperationStatus status, SymbolRegistrationLease? lease) - { - Status = status; - Lease = lease; - } + internal SymbolRegistrationAcquireOutcome(LuaOperationStatus status, SymbolRegistrationLease? lease) + { + Status = status; + Lease = lease; + } - /// Gets the protected CE registration status. - public LuaOperationStatus Status { get; } + /// Gets the protected CE registration status. + public LuaOperationStatus Status + { + get; + } - /// Gets the coordinated cleanup lease only when is successful. - public SymbolRegistrationLease? Lease { get; } + /// Gets the coordinated cleanup lease only when is successful. + public SymbolRegistrationLease? Lease + { + get; + } - /// Gets whether a coordinated cleanup lease was created. - public bool HasLease => Lease is not null; + /// Gets whether a coordinated cleanup lease was created. + public bool HasLease => Lease is not null; } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs index e5c12675..1abdc9f2 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Inspection; @@ -20,64 +21,78 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public sealed class SymbolRegistrationLease : IDisposable { - private readonly SymbolName _name; - private readonly SymbolRegistrationOptions _options; - private SymbolRegistrationReleaseKind? _terminalKind; - private bool _terminalOutcomeObserved; + private SymbolRegistrationReleaseKind? _terminalKind; + private bool _terminalOutcomeObserved; + + internal SymbolRegistrationLease(SymbolName name, SymbolRegistrationOptions options, LuaStateIdentity identity) + { + Name = name; + Options = options; + Identity = identity; + } - internal SymbolRegistrationLease(SymbolName name, SymbolRegistrationOptions options, LuaStateIdentity identity) - { - _name = name; - _options = options; - Identity = identity; - } + /// Gets the registered name. + public SymbolName Name + { + get; + } - /// Gets the registered name. - public SymbolName Name => _name; + /// Gets the persistence option used when registering the name. + public SymbolRegistrationOptions Options + { + get; + } - /// Gets the persistence option used when registering the name. - public SymbolRegistrationOptions Options => _options; + /// Gets whether this lease has reached a terminal outcome. + public bool IsTerminal => _terminalKind.HasValue; - /// Gets whether this lease has reached a terminal outcome. - public bool IsTerminal => _terminalKind.HasValue; + internal LuaStateIdentity Identity + { + get; + } - /// Attempts the coordinator-qualified unregister for this lease. - /// A result that distinguishes no call, a protected failure after the call began, and a local supersession. - public SymbolRegistrationReleaseOutcome Release() - { - return SymbolRegistry.ReleaseOwned(this); - } + /// Calls and intentionally discards its structured outcome. + /// + /// A outcome retains this lease so a caller + /// may invoke later. A protected failure after CE cleanup starts is terminal and is not + /// automatically retried. + /// + public void Dispose() + { + _ = Release(); + } - /// Calls and intentionally discards its structured outcome. - /// - /// A outcome retains this lease so a caller - /// may invoke later. A protected failure after CE cleanup starts is terminal and is not - /// automatically retried. - /// - public void Dispose() - { - _ = Release(); - } + /// Attempts the coordinator-qualified unregister for this lease. + /// A result that distinguishes no call, a protected failure after the call began, and a local supersession. + public SymbolRegistrationReleaseOutcome Release() + { + return SymbolRegistry.ReleaseOwned(this); + } - internal LuaStateIdentity Identity { get; } + internal void MarkTerminal(SymbolRegistrationReleaseKind kind) + { + _terminalKind ??= kind; + } - internal void MarkTerminal(SymbolRegistrationReleaseKind kind) - { - _terminalKind ??= kind; - } + internal void MarkTerminalAndObserve(SymbolRegistrationReleaseKind kind) + { + MarkTerminal(kind); + _terminalOutcomeObserved = true; + } - internal void MarkTerminalAndObserve(SymbolRegistrationReleaseKind kind) - { - MarkTerminal(kind); - _terminalOutcomeObserved = true; - } + internal SymbolRegistrationReleaseKind? ObserveTerminalKind() + { + if (!_terminalKind.HasValue) + { + return null; + } - internal SymbolRegistrationReleaseKind? ObserveTerminalKind() - { - if (!_terminalKind.HasValue) return null; - if (_terminalOutcomeObserved) return SymbolRegistrationReleaseKind.AlreadyReleased; + if (_terminalOutcomeObserved) + { + return SymbolRegistrationReleaseKind.AlreadyReleased; + } - _terminalOutcomeObserved = true; - return _terminalKind.Value; - } + _terminalOutcomeObserved = true; + return _terminalKind.Value; + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs index 55c23312..7187b798 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs @@ -3,16 +3,21 @@ namespace CheatEngine.SDK.Engine.Inspection; /// Classifies an explicit attempt to release a coordinated symbol-registration lease. public enum SymbolRegistrationReleaseKind { - /// The coordinator removed this lease's current CE registration. - Released, - /// A terminal cleanup outcome had already been returned for this lease. - AlreadyReleased, - /// A newer registration through this SDK coordinator replaced the lease, so no CE unregister was sent. - Superseded, - /// The Lua attach epoch or state generation changed, so no CE unregister was sent to a new runtime. - StaleRuntime, - /// No unregister call began because the runtime could not currently admit the operation. - CleanupUnavailable, - /// An unregister call began and failed, so the host-side registration is indeterminate and will not retry. - CleanupIndeterminate, + /// The coordinator removed this lease's current CE registration. + Released, + + /// A terminal cleanup outcome had already been returned for this lease. + AlreadyReleased, + + /// A newer registration through this SDK coordinator replaced the lease, so no CE unregister was sent. + Superseded, + + /// The Lua attach epoch or state generation changed, so no CE unregister was sent to a new runtime. + StaleRuntime, + + /// No unregister call began because the runtime could not currently admit the operation. + CleanupUnavailable, + + /// An unregister call began and failed, so the host-side registration is indeterminate and will not retry. + CleanupIndeterminate } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs index 26344144..37e83c69 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs @@ -1,24 +1,31 @@ -using CheatEngine.SDK.Lua.Calls; using System.Runtime.InteropServices; +using CheatEngine.SDK.Lua.Calls; + namespace CheatEngine.SDK.Engine.Inspection; /// The structured outcome of a coordinated symbol-registration cleanup attempt. [StructLayout(LayoutKind.Sequential)] public readonly struct SymbolRegistrationReleaseOutcome { - internal SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind kind, LuaOperationStatus status) - { - Kind = kind; - Status = status; - } + internal SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind kind, LuaOperationStatus status) + { + Kind = kind; + Status = status; + } - /// Gets how cleanup progressed. - public SymbolRegistrationReleaseKind Kind { get; } + /// Gets how cleanup progressed. + public SymbolRegistrationReleaseKind Kind + { + get; + } - /// Gets CE's protected unregister status when a CE lookup or unregister was attempted. - public LuaOperationStatus Status { get; } + /// Gets CE's protected unregister status when a CE lookup or unregister was attempted. + public LuaOperationStatus Status + { + get; + } - /// Gets whether no later explicit release attempt can be made through this lease. - public bool IsTerminal => Kind is not SymbolRegistrationReleaseKind.CleanupUnavailable; + /// Gets whether no later explicit release attempt can be made through this lease. + public bool IsTerminal => Kind is not SymbolRegistrationReleaseKind.CleanupUnavailable; } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs index dc971f4a..bdc64b9b 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Values; @@ -30,184 +32,213 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public static partial class SymbolRegistry { - private static readonly Lock SOwnedRegistrationGate = new(); - private static readonly Dictionary SOwnedRegistrations = new(); - - /// Gets CE's formatted name for a target-process address with CE's default name sources. - /// The target-process address passed to CE as the sole argument. - /// A copied managed string only when the returned status is successful. - /// The protected binding outcome. - /// The plugin is not enabled or the calling thread has no Lua state. - [LuaGlobal("getNameFromAddress")] - [RequiresPluginEnabled] - public static partial LuaOperationStatus TryGetName([LuaMarshaller(typeof(Address))] Address address, - out string? name); - - /// Registers a user-defined symbol at a target-process address. - /// The registration name CE will add to its symbol handler. - /// The target-process address associated with . - /// The persistence option for the registration. - /// The protected binding outcome. - /// is default or otherwise invalid. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static LuaOperationStatus Register(SymbolName name, Address address, - SymbolRegistrationOptions options = default) - { - ValidateName(name); - lock (SOwnedRegistrationGate) - { - var status = RegisterCore(name, address, options.DoNotSave); - if (OperationCouldHaveStarted(status)) SupersedeCurrentLease(name); - return status; - } - } - - /// Removes a user-defined symbol name from CE's symbol handler. - /// The registration name to remove. - /// The protected binding outcome. - /// is default or otherwise invalid. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static LuaOperationStatus Unregister(SymbolName name) - { - ValidateName(name); - lock (SOwnedRegistrationGate) - { - var status = UnregisterCore(name); - if (OperationCouldHaveStarted(status)) SupersedeCurrentLease(name); - return status; - } - } - - /// Registers a symbol and returns an explicit, coordinator-qualified cleanup lease on success. - /// The registration name CE will add to its symbol handler. - /// The target-process address associated with . - /// The persistence option for the registration. - /// The protected registration status and a lease only on successful registration. - /// is default or otherwise invalid. - /// The plugin is not enabled or the calling thread has no Lua state. - /// - /// The lease prevents older leases from unregistering a newer registration made through this coordinator. CE has - /// no registration token, so this method makes no claim about replacements made outside that coordinator. - /// - [RequiresPluginEnabled] - public static SymbolRegistrationAcquireOutcome TryRegisterOwned(SymbolName name, Address address, - SymbolRegistrationOptions options = default) - { - ValidateName(name); - lock (SOwnedRegistrationGate) - { - var identity = LuaRuntime.CurrentStateIdentity; - var status = RegisterCore(name, address, options.DoNotSave); - if (!status.IsSuccess) - { - if (OperationCouldHaveStarted(status)) SupersedeCurrentLease(name); - return new SymbolRegistrationAcquireOutcome(status, lease: null); - } - if (identity != LuaRuntime.CurrentStateIdentity) - { - SupersedeCurrentLease(name); - return new SymbolRegistrationAcquireOutcome(LuaOperationStatus.GlobalUnavailable, lease: null); - } - - SupersedeCurrentLease(name); - var lease = new SymbolRegistrationLease(name, options, identity); - SOwnedRegistrations.Add(name, lease); - return new SymbolRegistrationAcquireOutcome(status, lease); - } - } - - [LuaGlobal("registerSymbol")] - private static partial LuaOperationStatus RegisterCore([LuaMarshaller(typeof(SymbolName))] SymbolName name, - [LuaMarshaller(typeof(Address))] Address address, bool doNotSave); - - [LuaGlobal("unregisterSymbol")] - private static partial LuaOperationStatus UnregisterCore([LuaMarshaller(typeof(SymbolName))] SymbolName name); - - internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistrationLease lease) - { - lock (SOwnedRegistrationGate) - { - if (lease.ObserveTerminalKind() is { } terminalKind) - return new SymbolRegistrationReleaseOutcome(terminalKind, LuaOperationStatus.Success); - - if (!LuaRuntime.IsAttached || lease.Identity != LuaRuntime.CurrentStateIdentity) - { - RemoveCurrentLease(lease); - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, - LuaOperationStatus.Success); - } - - if (!SOwnedRegistrations.TryGetValue(lease.Name, out var current) || !ReferenceEquals(current, lease)) - { - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Superseded); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Superseded, - LuaOperationStatus.Success); - } - - LuaOperationStatus status; - try - { - status = UnregisterCore(lease.Name); - } - catch (InvalidOperationException) when (!LuaRuntime.IsAttached || lease.Identity != LuaRuntime.CurrentStateIdentity) - { - RemoveCurrentLease(lease); - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, - LuaOperationStatus.Success); - } - catch (InvalidOperationException) - { - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, - LuaOperationStatus.GlobalUnavailable); - } - - if (status.IsSuccess) - { - RemoveCurrentLease(lease); - if (lease.Identity != LuaRuntime.CurrentStateIdentity) - { - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, status); - } - - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Released); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Released, status); - } - - if (status.Kind is LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable) - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status); - - RemoveCurrentLease(lease); - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.CleanupIndeterminate); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, status); - } - } - - private static void ValidateName(SymbolName name) - { - if (string.IsNullOrWhiteSpace(name.Value)) - throw new ArgumentException("A symbol name must not be default, empty or white space.", nameof(name)); - } - - private static void SupersedeCurrentLease(SymbolName name) - { - if (!SOwnedRegistrations.Remove(name, out var existing)) return; - - existing.MarkTerminal(SymbolRegistrationReleaseKind.Superseded); - } - - private static void RemoveCurrentLease(SymbolRegistrationLease lease) - { - if (SOwnedRegistrations.TryGetValue(lease.Name, out var current) && ReferenceEquals(current, lease)) - SOwnedRegistrations.Remove(lease.Name); - } - - private static bool OperationCouldHaveStarted(LuaOperationStatus status) - { - return status.Kind is not (LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable); - } + private static readonly Lock SOwnedRegistrationGate = new(); + private static readonly Dictionary SOwnedRegistrations = new(); + + /// Gets CE's formatted name for a target-process address with CE's default name sources. + /// The target-process address passed to CE as the sole argument. + /// A copied managed string only when the returned status is successful. + /// The protected binding outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [LuaGlobal("getNameFromAddress")] + [RequiresPluginEnabled] + public static partial LuaOperationStatus TryGetName([LuaMarshaller(typeof(Address))] Address address, + out string? name); + + /// Registers a user-defined symbol at a target-process address. + /// The registration name CE will add to its symbol handler. + /// The target-process address associated with . + /// The persistence option for the registration. + /// The protected binding outcome. + /// is default or otherwise invalid. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static LuaOperationStatus Register(SymbolName name, Address address, + SymbolRegistrationOptions options = default) + { + ValidateName(name); + lock (SOwnedRegistrationGate) + { + LuaOperationStatus status = RegisterCore(name, address, options.DoNotSave); + if (OperationCouldHaveStarted(status)) + { + SupersedeCurrentLease(name); + } + + return status; + } + } + + /// Removes a user-defined symbol name from CE's symbol handler. + /// The registration name to remove. + /// The protected binding outcome. + /// is default or otherwise invalid. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static LuaOperationStatus Unregister(SymbolName name) + { + ValidateName(name); + lock (SOwnedRegistrationGate) + { + LuaOperationStatus status = UnregisterCore(name); + if (OperationCouldHaveStarted(status)) + { + SupersedeCurrentLease(name); + } + + return status; + } + } + + /// Registers a symbol and returns an explicit, coordinator-qualified cleanup lease on success. + /// The registration name CE will add to its symbol handler. + /// The target-process address associated with . + /// The persistence option for the registration. + /// The protected registration status and a lease only on successful registration. + /// is default or otherwise invalid. + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// The lease prevents older leases from unregistering a newer registration made through this coordinator. CE has + /// no registration token, so this method makes no claim about replacements made outside that coordinator. + /// + [RequiresPluginEnabled] + public static SymbolRegistrationAcquireOutcome TryRegisterOwned(SymbolName name, Address address, + SymbolRegistrationOptions options = default) + { + ValidateName(name); + lock (SOwnedRegistrationGate) + { + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + LuaOperationStatus status = RegisterCore(name, address, options.DoNotSave); + if (!status.IsSuccess) + { + if (OperationCouldHaveStarted(status)) + { + SupersedeCurrentLease(name); + } + + return new SymbolRegistrationAcquireOutcome(status, null); + } + + if (identity != LuaRuntime.CurrentStateIdentity) + { + SupersedeCurrentLease(name); + return new SymbolRegistrationAcquireOutcome(LuaOperationStatus.GlobalUnavailable, null); + } + + SupersedeCurrentLease(name); + SymbolRegistrationLease lease = new(name, options, identity); + SOwnedRegistrations.Add(name, lease); + return new SymbolRegistrationAcquireOutcome(status, lease); + } + } + + [LuaGlobal("registerSymbol")] + private static partial LuaOperationStatus RegisterCore([LuaMarshaller(typeof(SymbolName))] SymbolName name, + [LuaMarshaller(typeof(Address))] Address address, bool doNotSave); + + [LuaGlobal("unregisterSymbol")] + private static partial LuaOperationStatus UnregisterCore([LuaMarshaller(typeof(SymbolName))] SymbolName name); + + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "ReleaseOwned is the single atomic lease-cleanup transaction and must preserve its state ordering.")] + internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistrationLease lease) + { + lock (SOwnedRegistrationGate) + { + if (lease.ObserveTerminalKind() is { } terminalKind) + { + return new SymbolRegistrationReleaseOutcome(terminalKind, LuaOperationStatus.Success); + } + + if (!LuaRuntime.IsAttached || lease.Identity != LuaRuntime.CurrentStateIdentity) + { + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, + LuaOperationStatus.Success); + } + + if (!SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) || + !ReferenceEquals(current, lease)) + { + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Superseded); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Superseded, + LuaOperationStatus.Success); + } + + LuaOperationStatus status; + try + { + status = UnregisterCore(lease.Name); + } + catch (InvalidOperationException) when (!LuaRuntime.IsAttached || + lease.Identity != LuaRuntime.CurrentStateIdentity) + { + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, + LuaOperationStatus.Success); + } + catch (InvalidOperationException) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.GlobalUnavailable); + } + + if (status.IsSuccess) + { + RemoveCurrentLease(lease); + if (lease.Identity != LuaRuntime.CurrentStateIdentity) + { + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, status); + } + + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Released); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Released, status); + } + + if (status.Kind is LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status); + } + + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.CleanupIndeterminate); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, status); + } + } + + private static void ValidateName(SymbolName name) + { + if (string.IsNullOrWhiteSpace(name.Value)) + { + throw new ArgumentException("A symbol name must not be default, empty or white space.", nameof(name)); + } + } + + private static void SupersedeCurrentLease(SymbolName name) + { + if (!SOwnedRegistrations.Remove(name, out SymbolRegistrationLease? existing)) + { + return; + } + + existing.MarkTerminal(SymbolRegistrationReleaseKind.Superseded); + } + + private static void RemoveCurrentLease(SymbolRegistrationLease lease) + { + if (SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) && + ReferenceEquals(current, lease)) + { + SOwnedRegistrations.Remove(lease.Name); + } + } + + private static bool OperationCouldHaveStarted(LuaOperationStatus status) + { + return status.Kind is not (LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable); + } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/TargetProcessId.cs b/libs/CheatEngine.SDK.Engine/Inspection/TargetProcessId.cs index 75c661be..4a9fbe70 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/TargetProcessId.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/TargetProcessId.cs @@ -14,55 +14,60 @@ namespace CheatEngine.SDK.Engine.Inspection; /// public readonly struct TargetProcessId : IEquatable { - /// Creates a target process identifier. - /// A positive Windows process identifier. - /// is zero or negative. - public TargetProcessId(int value) - { - if (value <= 0) - throw new ArgumentOutOfRangeException(nameof(value), value, - "A target process identifier must be positive."); + /// Creates a target process identifier. + /// A positive Windows process identifier. + /// is zero or negative. + public TargetProcessId(int value) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "A target process identifier must be positive."); + } - Value = value; - } + Value = value; + } - /// Gets the integer value accepted by Cheat Engine. - public int Value { get; } + /// Gets the integer value accepted by Cheat Engine. + public int Value + { + get; + } - /// - public bool Equals(TargetProcessId other) - { - return Value == other.Value; - } + /// + public bool Equals(TargetProcessId other) + { + return Value == other.Value; + } - /// - public override bool Equals(object? obj) - { - return obj is TargetProcessId other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is TargetProcessId other && Equals(other); + } - /// - public override int GetHashCode() - { - return Value; - } + /// + public override int GetHashCode() + { + return Value; + } - /// Formats the identifier using invariant decimal digits. - /// The decimal process identifier. - public override string ToString() - { - return Value.ToString(CultureInfo.InvariantCulture); - } + /// Formats the identifier using invariant decimal digits. + /// The decimal process identifier. + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } - /// Tests two process identifiers for equality. - public static bool operator ==(TargetProcessId left, TargetProcessId right) - { - return left.Equals(right); - } + /// Tests two process identifiers for equality. + public static bool operator ==(TargetProcessId left, TargetProcessId right) + { + return left.Equals(right); + } - /// Tests two process identifiers for inequality. - public static bool operator !=(TargetProcessId left, TargetProcessId right) - { - return !left.Equals(right); - } + /// Tests two process identifiers for inequality. + public static bool operator !=(TargetProcessId left, TargetProcessId right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Memory/HostAddress.cs b/libs/CheatEngine.SDK.Engine/Memory/HostAddress.cs index 27eaff34..49d17c40 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/HostAddress.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/HostAddress.cs @@ -19,83 +19,86 @@ namespace CheatEngine.SDK.Engine.Memory; /// public readonly struct HostAddress : IEquatable, IFormattable { - /// Initializes an address in Cheat Engine's host process. - /// The pointer-sized address. - public HostAddress(nuint value) - { - Value = value; - } + /// Initializes an address in Cheat Engine's host process. + /// The pointer-sized address. + public HostAddress(nuint value) + { + Value = value; + } - /// Gets the null address. - public static HostAddress Zero => default; + /// Gets the null address. + public static HostAddress Zero => default; - /// Gets the pointer-sized numeric value. - public nuint Value { get; } + /// Gets the pointer-sized numeric value. + public nuint Value + { + get; + } - /// Gets a value indicating whether this is the null address. - public bool IsZero => Value == 0; + /// Gets a value indicating whether this is the null address. + public bool IsZero => Value == 0; - /// Converts the value to the signed Lua integer representation without changing its bits. - /// The pointer bits represented as a signed 64-bit Lua integer. - public long ToInt64() - { - return unchecked((long)Value); - } + /// Converts the value to the signed Lua integer representation without changing its bits. + /// The pointer bits represented as a signed 64-bit Lua integer. + public long ToInt64() + { + return unchecked((long) Value); + } - /// Creates a host address from the low pointer-sized bits of a Lua integer. - /// The signed Lua integer returned by Cheat Engine. - /// The same low pointer-sized bits as a host address. - public static HostAddress FromInt64(long value) - { - return new HostAddress(unchecked((nuint)value)); - } + /// Creates a host address from the low pointer-sized bits of a Lua integer. + /// The signed Lua integer returned by Cheat Engine. + /// The same low pointer-sized bits as a host address. + public static HostAddress FromInt64(long value) + { + return new HostAddress(unchecked((nuint) value)); + } - /// Compares two host-process addresses. - /// The first address. - /// The second address. - /// when the values are equal. - public static bool operator ==(HostAddress left, HostAddress right) - { - return left.Value == right.Value; - } + /// Compares two host-process addresses. + /// The first address. + /// The second address. + /// when the values are equal. + public static bool operator ==(HostAddress left, HostAddress right) + { + return left.Value == right.Value; + } - /// Compares two host-process addresses. - /// The first address. - /// The second address. - /// when the values differ. - public static bool operator !=(HostAddress left, HostAddress right) - { - return left.Value != right.Value; - } + /// Compares two host-process addresses. + /// The first address. + /// The second address. + /// when the values differ. + public static bool operator !=(HostAddress left, HostAddress right) + { + return left.Value != right.Value; + } - /// - public bool Equals(HostAddress other) - { - return Value == other.Value; - } + /// + public bool Equals(HostAddress other) + { + return Value == other.Value; + } - /// - public override bool Equals(object? obj) - { - return obj is HostAddress other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is HostAddress other && Equals(other); + } - /// - public override int GetHashCode() - { - return Value.GetHashCode(); - } + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } - /// Formats the address as uppercase hexadecimal with the host pointer width. - /// The hexadecimal representation, without a prefix. - public override string ToString() - { - return Value.ToString(IntPtr.Size == 8 ? "X16" : "X8", CultureInfo.InvariantCulture); - } + /// Formats the address as uppercase hexadecimal with the host pointer width. + /// The hexadecimal representation, without a prefix. + public override string ToString() + { + return Value.ToString(IntPtr.Size == 8 ? "X16" : "X8", CultureInfo.InvariantCulture); + } - /// - public string ToString(string? format, IFormatProvider? formatProvider) - { - return Value.ToString(format, formatProvider); - } + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + return Value.ToString(format, formatProvider); + } } diff --git a/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs b/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs index 2f6e4d56..aec09e51 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Lua.References; @@ -21,326 +22,351 @@ namespace CheatEngine.SDK.Engine.Memory; [RequiresPluginEnabled] public static class HostMemory { - private static readonly LuaRef SReadBytes = new(); - private static readonly LuaRef SReadSmallInteger = new(); - private static readonly LuaRef SReadInteger = new(); - private static readonly LuaRef SReadQword = new(); - private static readonly LuaRef SReadPointer = new(); - private static readonly LuaRef SReadFloat = new(); - private static readonly LuaRef SReadDouble = new(); - private static readonly LuaRef SReadString = new(); - private static readonly LuaRef SWriteBytes = new(); - private static readonly LuaRef SWriteSmallInteger = new(); - private static readonly LuaRef SWriteInteger = new(); - private static readonly LuaRef SWriteQword = new(); - private static readonly LuaRef SWritePointer = new(); - private static readonly LuaRef SWriteFloat = new(); - private static readonly LuaRef SWriteDouble = new(); - private static readonly LuaRef SWriteString = new(); - - /// Reads an unsigned 8-bit value through CE's documented local byte-table operation. - public static bool TryReadUInt8(HostAddress address, out byte value, out MemoryAccessFailure failure) - { - Span bytes = stackalloc byte[1]; - if (!MemoryLua.TryReadBytes(SReadBytes, "readBytesLocal"u8, address.ToInt64(), bytes, out _, out failure)) - { - value = default; - return false; - } - - value = bytes[0]; - return true; - } - - /// Reads a signed 8-bit value through CE's documented local byte-table operation. - public static bool TryReadInt8(HostAddress address, out sbyte value, out MemoryAccessFailure failure) - { - if (!TryReadUInt8(address, out var raw, out failure)) - { - value = default; - return false; - } - - value = unchecked((sbyte)raw); - return true; - } - - /// Reads an unsigned 16-bit value from Cheat Engine's process. - public static bool TryReadUInt16(HostAddress address, out ushort value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), signed: false, - hasSignedArgument: true, - out var raw, out failure) || raw < 0 || raw > ushort.MaxValue) - { - value = default; - if (failure == MemoryAccessFailure.None) failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (ushort)raw; - return true; - } - - /// Reads a signed 16-bit value from Cheat Engine's process. - public static bool TryReadInt16(HostAddress address, out short value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), signed: true, - hasSignedArgument: true, - out var raw, out failure) || raw < short.MinValue || raw > short.MaxValue) - { - value = default; - if (failure == MemoryAccessFailure.None) failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (short)raw; - return true; - } - - /// Reads an unsigned 32-bit value from Cheat Engine's process. - public static bool TryReadUInt32(HostAddress address, out uint value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), signed: false, - hasSignedArgument: true, out var raw, - out failure) || raw < 0 || (ulong)raw > uint.MaxValue) - { - value = default; - if (failure == MemoryAccessFailure.None) failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (uint)raw; - return true; - } - - /// Reads a signed 32-bit value from Cheat Engine's process. - public static bool TryReadInt32(HostAddress address, out int value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), signed: true, - hasSignedArgument: true, out var raw, - out failure) || raw < int.MinValue || raw > int.MaxValue) - { - value = default; - if (failure == MemoryAccessFailure.None) failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (int)raw; - return true; - } - - /// Reads an unsigned 64-bit value from Cheat Engine's process without changing its bits. - public static bool TryReadUInt64(HostAddress address, out ulong value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadQword, "readQwordLocal"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out var raw, - out failure)) - { - value = default; - return false; - } - - value = unchecked((ulong)raw); - return true; - } - - /// Reads a signed 64-bit value from Cheat Engine's process. - public static bool TryReadInt64(HostAddress address, out long value, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadInteger(SReadQword, "readQwordLocal"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out value, - out failure); - } - - /// Reads a host-width pointer from Cheat Engine's process. - public static bool TryReadPointer(HostAddress address, out HostAddress value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadPointer, "readPointerLocal"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out var raw, - out failure)) - { - value = default; - return false; - } - - value = HostAddress.FromInt64(raw); - return true; - } - - /// Reads a single-precision floating-point value from Cheat Engine's process. - public static bool TryReadSingle(HostAddress address, out float value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadNumber(SReadFloat, "readFloatLocal"u8, address.ToInt64(), out var raw, out failure)) - { - value = default; - return false; - } - - value = (float)raw; - return true; - } - - /// Reads a double-precision floating-point value from Cheat Engine's process. - public static bool TryReadDouble(HostAddress address, out double value, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadNumber(SReadDouble, "readDoubleLocal"u8, address.ToInt64(), out value, out failure); - } - - /// Writes an unsigned 8-bit value through CE's ordered local byte-table operation. - public static bool TryWriteUInt8(HostAddress address, byte value, out MemoryAccessFailure failure) - { - Span bytes = stackalloc byte[1]; - bytes[0] = value; - return MemoryLua.TryWriteBytes(SWriteBytes, "writeBytesLocal"u8, address.ToInt64(), bytes, out _, out failure); - } - - /// Writes a signed 8-bit value through CE's ordered local byte-table operation. - public static bool TryWriteInt8(HostAddress address, sbyte value, out MemoryAccessFailure failure) - { - return TryWriteUInt8(address, unchecked((byte)value), out failure); - } - - /// Writes an unsigned 16-bit value to Cheat Engine's process. - public static bool TryWriteUInt16(HostAddress address, ushort value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallIntegerLocal"u8, address.ToInt64(), value, - out failure); - } - - /// Writes a signed 16-bit value to Cheat Engine's process. - public static bool TryWriteInt16(HostAddress address, short value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallIntegerLocal"u8, address.ToInt64(), value, - out failure); - } - - /// Writes an unsigned 32-bit value to Cheat Engine's process. - public static bool TryWriteUInt32(HostAddress address, uint value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteInteger, "writeIntegerLocal"u8, address.ToInt64(), value, out failure); - } - - /// Writes a signed 32-bit value to Cheat Engine's process. - public static bool TryWriteInt32(HostAddress address, int value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteInteger, "writeIntegerLocal"u8, address.ToInt64(), value, out failure); - } - - /// Writes an unsigned 64-bit value to Cheat Engine's process without changing its bits. - public static bool TryWriteUInt64(HostAddress address, ulong value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteQword, "writeQwordLocal"u8, address.ToInt64(), unchecked((long)value), - out failure); - } - - /// Writes a signed 64-bit value to Cheat Engine's process. - public static bool TryWriteInt64(HostAddress address, long value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteQword, "writeQwordLocal"u8, address.ToInt64(), value, out failure); - } - - /// Writes a host-width pointer to Cheat Engine's process. - public static bool TryWritePointer(HostAddress address, HostAddress value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWritePointer, "writePointerLocal"u8, address.ToInt64(), value.ToInt64(), - out failure); - } - - /// Writes a single-precision floating-point value to Cheat Engine's process. - public static bool TryWriteSingle(HostAddress address, float value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteNumber(SWriteFloat, "writeFloatLocal"u8, address.ToInt64(), value, out failure); - } - - /// Writes a double-precision floating-point value to Cheat Engine's process. - public static bool TryWriteDouble(HostAddress address, double value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteNumber(SWriteDouble, "writeDoubleLocal"u8, address.ToInt64(), value, out failure); - } - - /// Reads exactly .Length local bytes into caller-owned storage. - public static bool TryReadBytes(HostAddress address, Span destination, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadBytes(SReadBytes, "readBytesLocal"u8, address.ToInt64(), destination, out failure); - } - - /// Reads exactly .Length local bytes and reports the number copied. - /// The CE-host address to read. - /// The caller-owned storage. This overload can copy a validated contiguous prefix before it observes an incomplete or malformed table. - /// The verified number copied, including a confirmed contiguous prefix on . - /// The factual CE or buffer-contract failure. - /// only after all requested bytes have been copied. - public static bool TryReadBytes(HostAddress address, Span destination, out int written, - out MemoryAccessFailure failure) - { - return MemoryLua.TryReadBytes(SReadBytes, "readBytesLocal"u8, address.ToInt64(), destination, out written, - out failure); - } - - /// Writes the caller-owned byte sequence in source order as one local Lua byte table. - public static bool TryWriteBytes(HostAddress address, ReadOnlySpan value, out MemoryAccessFailure failure) - { - return TryWriteBytes(address, value, out _, out failure); - } - - /// Writes a local byte sequence and reports the exact byte count returned by Cheat Engine. - /// The CE-host address to write. - /// The caller-owned bytes in source order. - /// The CE-reported count, including a confirmed partial count on . - /// The factual CE or result-contract failure. - /// only when CE reports the complete requested count. - public static bool TryWriteBytes(HostAddress address, ReadOnlySpan value, out int written, - out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteBytes(SWriteBytes, "writeBytesLocal"u8, address.ToInt64(), value, out written, - out failure); - } - - /// Reads a local UTF-8 Lua string into caller-owned storage. - public static bool TryReadUtf8(HostAddress address, int maximumLength, Span destination, bool wideCharacter, - out int written, out MemoryAccessFailure failure) - { - return TryReadUtf8(address, maximumLength, destination, wideCharacter, out written, out _, out failure); - } - - /// Reads local UTF-8 text and reports the exact capacity required by the returned value. - /// The CE-host address to read. - /// The maximum character count passed to CE's documented string primitive. - /// The caller-owned UTF-8 storage; it is unchanged when it is too small. - /// Whether CE should read a wide-character string. - /// The copied byte count, which is zero on failure. - /// The returned UTF-8 byte count when CE supplied a string, including a short destination. - /// The factual CE or capacity failure. - /// only after the complete UTF-8 value has been copied. - public static bool TryReadUtf8(HostAddress address, int maximumLength, Span destination, bool wideCharacter, - out int written, out int requiredLength, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadUtf8(SReadString, "readStringLocal"u8, address.ToInt64(), maximumLength, wideCharacter, - destination, out written, out requiredLength, out failure); - } - - /// Reads a local string into a managed string; use the byte-span overload on allocation-sensitive paths. - public static bool TryReadString(HostAddress address, int maximumLength, bool wideCharacter, out string? value, - out MemoryAccessFailure failure) - { - return MemoryLua.TryReadString(SReadString, "readStringLocal"u8, address.ToInt64(), maximumLength, - wideCharacter, - out value, out failure); - } - - /// Writes a UTF-8 byte sequence through CE's local writeString operation. - public static bool TryWriteUtf8(HostAddress address, ReadOnlySpan value, bool wideCharacter, - out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteUtf8(SWriteString, "writeStringLocal"u8, address.ToInt64(), value, wideCharacter, - out failure); - } - - /// Writes UTF-16 text as a Lua UTF-8 string in Cheat Engine's host address space. - public static bool TryWriteString(HostAddress address, ReadOnlySpan value, bool wideCharacter, - out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteText(SWriteString, "writeStringLocal"u8, address.ToInt64(), value, wideCharacter, - out failure); - } + private static readonly LuaRef SReadBytes = new(); + private static readonly LuaRef SReadSmallInteger = new(); + private static readonly LuaRef SReadInteger = new(); + private static readonly LuaRef SReadQword = new(); + private static readonly LuaRef SReadPointer = new(); + private static readonly LuaRef SReadFloat = new(); + private static readonly LuaRef SReadDouble = new(); + private static readonly LuaRef SReadString = new(); + private static readonly LuaRef SWriteBytes = new(); + private static readonly LuaRef SWriteSmallInteger = new(); + private static readonly LuaRef SWriteInteger = new(); + private static readonly LuaRef SWriteQword = new(); + private static readonly LuaRef SWritePointer = new(); + private static readonly LuaRef SWriteFloat = new(); + private static readonly LuaRef SWriteDouble = new(); + private static readonly LuaRef SWriteString = new(); + + /// Reads an unsigned 8-bit value through CE's documented local byte-table operation. + public static bool TryReadUInt8(HostAddress address, out byte value, out MemoryAccessFailure failure) + { + Span bytes = stackalloc byte[1]; + if (!MemoryLua.TryReadBytes(SReadBytes, "readBytesLocal"u8, address.ToInt64(), bytes, out _, out failure)) + { + value = default; + return false; + } + + value = bytes[0]; + return true; + } + + /// Reads a signed 8-bit value through CE's documented local byte-table operation. + public static bool TryReadInt8(HostAddress address, out sbyte value, out MemoryAccessFailure failure) + { + if (!TryReadUInt8(address, out byte raw, out failure)) + { + value = default; + return false; + } + + value = unchecked((sbyte) raw); + return true; + } + + /// Reads an unsigned 16-bit value from Cheat Engine's process. + public static bool TryReadUInt16(HostAddress address, out ushort value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), false, + true, + out long raw, out failure) || raw < 0 || raw > ushort.MaxValue) + { + value = default; + if (failure == MemoryAccessFailure.None) + { + failure = MemoryAccessFailure.InvalidResult; + } + + return false; + } + + value = (ushort) raw; + return true; + } + + /// Reads a signed 16-bit value from Cheat Engine's process. + public static bool TryReadInt16(HostAddress address, out short value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), true, + true, + out long raw, out failure) || raw < short.MinValue || raw > short.MaxValue) + { + value = default; + if (failure == MemoryAccessFailure.None) + { + failure = MemoryAccessFailure.InvalidResult; + } + + return false; + } + + value = (short) raw; + return true; + } + + /// Reads an unsigned 32-bit value from Cheat Engine's process. + public static bool TryReadUInt32(HostAddress address, out uint value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), false, + true, out long raw, + out failure) || raw < 0 || (ulong) raw > uint.MaxValue) + { + value = default; + if (failure == MemoryAccessFailure.None) + { + failure = MemoryAccessFailure.InvalidResult; + } + + return false; + } + + value = (uint) raw; + return true; + } + + /// Reads a signed 32-bit value from Cheat Engine's process. + public static bool TryReadInt32(HostAddress address, out int value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), true, + true, out long raw, + out failure) || raw < int.MinValue || raw > int.MaxValue) + { + value = default; + if (failure == MemoryAccessFailure.None) + { + failure = MemoryAccessFailure.InvalidResult; + } + + return false; + } + + value = (int) raw; + return true; + } + + /// Reads an unsigned 64-bit value from Cheat Engine's process without changing its bits. + public static bool TryReadUInt64(HostAddress address, out ulong value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadQword, "readQwordLocal"u8, address.ToInt64(), false, + false, out long raw, + out failure)) + { + value = default; + return false; + } + + value = unchecked((ulong) raw); + return true; + } + + /// Reads a signed 64-bit value from Cheat Engine's process. + public static bool TryReadInt64(HostAddress address, out long value, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadInteger(SReadQword, "readQwordLocal"u8, address.ToInt64(), false, + false, out value, + out failure); + } + + /// Reads a host-width pointer from Cheat Engine's process. + public static bool TryReadPointer(HostAddress address, out HostAddress value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadPointer, "readPointerLocal"u8, address.ToInt64(), false, + false, out long raw, + out failure)) + { + value = default; + return false; + } + + value = HostAddress.FromInt64(raw); + return true; + } + + /// Reads a single-precision floating-point value from Cheat Engine's process. + public static bool TryReadSingle(HostAddress address, out float value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadNumber(SReadFloat, "readFloatLocal"u8, address.ToInt64(), out double raw, out failure)) + { + value = default; + return false; + } + + value = (float) raw; + return true; + } + + /// Reads a double-precision floating-point value from Cheat Engine's process. + public static bool TryReadDouble(HostAddress address, out double value, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadNumber(SReadDouble, "readDoubleLocal"u8, address.ToInt64(), out value, out failure); + } + + /// Writes an unsigned 8-bit value through CE's ordered local byte-table operation. + public static bool TryWriteUInt8(HostAddress address, byte value, out MemoryAccessFailure failure) + { + Span bytes = stackalloc byte[1]; + bytes[0] = value; + return MemoryLua.TryWriteBytes(SWriteBytes, "writeBytesLocal"u8, address.ToInt64(), bytes, out _, out failure); + } + + /// Writes a signed 8-bit value through CE's ordered local byte-table operation. + public static bool TryWriteInt8(HostAddress address, sbyte value, out MemoryAccessFailure failure) + { + return TryWriteUInt8(address, unchecked((byte) value), out failure); + } + + /// Writes an unsigned 16-bit value to Cheat Engine's process. + public static bool TryWriteUInt16(HostAddress address, ushort value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallIntegerLocal"u8, address.ToInt64(), value, + out failure); + } + + /// Writes a signed 16-bit value to Cheat Engine's process. + public static bool TryWriteInt16(HostAddress address, short value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallIntegerLocal"u8, address.ToInt64(), value, + out failure); + } + + /// Writes an unsigned 32-bit value to Cheat Engine's process. + public static bool TryWriteUInt32(HostAddress address, uint value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteInteger, "writeIntegerLocal"u8, address.ToInt64(), value, out failure); + } + + /// Writes a signed 32-bit value to Cheat Engine's process. + public static bool TryWriteInt32(HostAddress address, int value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteInteger, "writeIntegerLocal"u8, address.ToInt64(), value, out failure); + } + + /// Writes an unsigned 64-bit value to Cheat Engine's process without changing its bits. + public static bool TryWriteUInt64(HostAddress address, ulong value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteQword, "writeQwordLocal"u8, address.ToInt64(), unchecked((long) value), + out failure); + } + + /// Writes a signed 64-bit value to Cheat Engine's process. + public static bool TryWriteInt64(HostAddress address, long value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteQword, "writeQwordLocal"u8, address.ToInt64(), value, out failure); + } + + /// Writes a host-width pointer to Cheat Engine's process. + public static bool TryWritePointer(HostAddress address, HostAddress value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWritePointer, "writePointerLocal"u8, address.ToInt64(), value.ToInt64(), + out failure); + } + + /// Writes a single-precision floating-point value to Cheat Engine's process. + public static bool TryWriteSingle(HostAddress address, float value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteNumber(SWriteFloat, "writeFloatLocal"u8, address.ToInt64(), value, out failure); + } + + /// Writes a double-precision floating-point value to Cheat Engine's process. + public static bool TryWriteDouble(HostAddress address, double value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteNumber(SWriteDouble, "writeDoubleLocal"u8, address.ToInt64(), value, out failure); + } + + /// Reads exactly .Length local bytes into caller-owned storage. + public static bool TryReadBytes(HostAddress address, Span destination, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadBytes(SReadBytes, "readBytesLocal"u8, address.ToInt64(), destination, out failure); + } + + /// Reads exactly .Length local bytes and reports the number copied. + /// The CE-host address to read. + /// + /// The caller-owned storage. This overload can copy a validated contiguous prefix before it + /// observes an incomplete or malformed table. + /// + /// + /// The verified number copied, including a confirmed contiguous prefix on + /// . + /// + /// The factual CE or buffer-contract failure. + /// only after all requested bytes have been copied. + public static bool TryReadBytes(HostAddress address, Span destination, out int written, + out MemoryAccessFailure failure) + { + return MemoryLua.TryReadBytes(SReadBytes, "readBytesLocal"u8, address.ToInt64(), destination, out written, + out failure); + } + + /// Writes the caller-owned byte sequence in source order as one local Lua byte table. + public static bool TryWriteBytes(HostAddress address, ReadOnlySpan value, out MemoryAccessFailure failure) + { + return TryWriteBytes(address, value, out _, out failure); + } + + /// Writes a local byte sequence and reports the exact byte count returned by Cheat Engine. + /// The CE-host address to write. + /// The caller-owned bytes in source order. + /// + /// The CE-reported count, including a confirmed partial count on + /// . + /// + /// The factual CE or result-contract failure. + /// only when CE reports the complete requested count. + public static bool TryWriteBytes(HostAddress address, ReadOnlySpan value, out int written, + out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteBytes(SWriteBytes, "writeBytesLocal"u8, address.ToInt64(), value, out written, + out failure); + } + + /// Reads a local UTF-8 Lua string into caller-owned storage. + public static bool TryReadUtf8(HostAddress address, int maximumLength, Span destination, bool wideCharacter, + out int written, out MemoryAccessFailure failure) + { + return TryReadUtf8(address, maximumLength, destination, wideCharacter, out written, out _, out failure); + } + + /// Reads local UTF-8 text and reports the exact capacity required by the returned value. + /// The CE-host address to read. + /// The maximum character count passed to CE's documented string primitive. + /// The caller-owned UTF-8 storage; it is unchanged when it is too small. + /// Whether CE should read a wide-character string. + /// The copied byte count, which is zero on failure. + /// The returned UTF-8 byte count when CE supplied a string, including a short destination. + /// The factual CE or capacity failure. + /// only after the complete UTF-8 value has been copied. + public static bool TryReadUtf8(HostAddress address, int maximumLength, Span destination, bool wideCharacter, + out int written, out int requiredLength, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadUtf8(SReadString, "readStringLocal"u8, address.ToInt64(), maximumLength, wideCharacter, + destination, out written, out requiredLength, out failure); + } + + /// Reads a local string into a managed string; use the byte-span overload on allocation-sensitive paths. + public static bool TryReadString(HostAddress address, int maximumLength, bool wideCharacter, out string? value, + out MemoryAccessFailure failure) + { + return MemoryLua.TryReadString(SReadString, "readStringLocal"u8, address.ToInt64(), maximumLength, + wideCharacter, + out value, out failure); + } + + /// Writes a UTF-8 byte sequence through CE's local writeString operation. + public static bool TryWriteUtf8(HostAddress address, ReadOnlySpan value, bool wideCharacter, + out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteUtf8(SWriteString, "writeStringLocal"u8, address.ToInt64(), value, wideCharacter, + out failure); + } + + /// Writes UTF-16 text as a Lua UTF-8 string in Cheat Engine's host address space. + public static bool TryWriteString(HostAddress address, ReadOnlySpan value, bool wideCharacter, + out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteText(SWriteString, "writeStringLocal"u8, address.ToInt64(), value, wideCharacter, + out failure); + } } diff --git a/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs b/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs index 70a888cc..c49f7160 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs @@ -16,39 +16,39 @@ namespace CheatEngine.SDK.Engine.Memory; /// public enum MemoryAccessFailure { - /// The operation succeeded. - None, + /// The operation succeeded. + None, - /// The CE Lua global was absent, not a function, or could not be resolved under protection. - GlobalUnavailable, + /// The CE Lua global was absent, not a function, or could not be resolved under protection. + GlobalUnavailable, - /// The protected Lua call raised, including an allocation failure reported by the Lua protection bridge. - LuaError, + /// The protected Lua call raised, including an allocation failure reported by the Lua protection bridge. + LuaError, - /// Cheat Engine returned or an incomplete byte table for a read operation. - ReadFailed, + /// Cheat Engine returned or an incomplete byte table for a read operation. + ReadFailed, - /// - /// Cheat Engine returned a contiguous byte-table prefix that was shorter than the requested destination. This is - /// reported only by the overload that returns a copied byte count. - /// - PartialRead, + /// + /// Cheat Engine returned a contiguous byte-table prefix that was shorter than the requested destination. This is + /// reported only by the overload that returns a copied byte count. + /// + PartialRead, - /// The caller-provided destination was smaller than the Lua string returned by Cheat Engine. - DestinationTooSmall, + /// The caller-provided destination was smaller than the Lua string returned by Cheat Engine. + DestinationTooSmall, - /// The caller requested a target-qualified pointer read without an observed target pointer width. - PointerWidthUnknown, + /// The caller requested a target-qualified pointer read without an observed target pointer width. + PointerWidthUnknown, - /// The pointer returned by Cheat Engine cannot fit in the caller's observed target pointer width. - PointerValueExceedsTargetWidth, + /// The pointer returned by Cheat Engine cannot fit in the caller's observed target pointer width. + PointerValueExceedsTargetWidth, - /// - /// Cheat Engine explicitly returned for a scalar or string write, or reported a byte - /// count different from the requested non-empty byte write. - /// - WriteFailed, + /// + /// Cheat Engine explicitly returned for a scalar or string write, or reported a byte + /// count different from the requested non-empty byte write. + /// + WriteFailed, - /// The Lua global returned a value of a different kind or outside the documented range. - InvalidResult, + /// The Lua global returned a value of a different kind or outside the documented range. + InvalidResult } diff --git a/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs b/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs index 8f575f01..6ac46977 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; @@ -11,500 +12,600 @@ namespace CheatEngine.SDK.Engine.Memory; // shape once so TargetMemory and HostMemory cannot accidentally diverge in stack restoration or failure classification. internal static class MemoryLua { - internal static bool TryReadInteger(LuaRef cache, ReadOnlySpan name, long address, bool signed, - bool hasSignedArgument, out long value, out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) - { - value = default; - return false; - } - - state.PushInteger(address); - if (hasSignedArgument) state.PushBoolean(signed); - - var status = state.TryCall(hasSignedArgument ? 2 : 1, 1); - if (!status.IsOk) return Fail(out value, out failure, MemoryAccessFailure.LuaError); - // lua_tointegerx accepts strings as a convenience conversion. Memory globals must not: a CE scalar - // contract is a Lua number, while an integral Lua floating-point result remains a valid scalar. - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out value)) - return Fail(out value, out failure, - state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out value, out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryReadNumber(LuaRef cache, ReadOnlySpan name, long address, out double value, - out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) - { - value = default; - return false; - } - - state.PushInteger(address); - var status = state.TryCall(1, 1); - if (!status.IsOk) return Fail(out value, out failure, MemoryAccessFailure.LuaError); - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadNumber(-1, out value)) - return Fail(out value, out failure, - state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out value, out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryReadUtf8(LuaRef cache, ReadOnlySpan name, long address, int maximumLength, - bool wideCharacter, Span destination, out int written, out MemoryAccessFailure failure) - { - return TryReadUtf8(cache, name, address, maximumLength, wideCharacter, destination, out written, out _, - out failure); - } - - internal static bool TryReadUtf8(LuaRef cache, ReadOnlySpan name, long address, int maximumLength, - bool wideCharacter, Span destination, out int written, out int requiredLength, - out MemoryAccessFailure failure) - { - ArgumentOutOfRangeException.ThrowIfNegative(maximumLength); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) - { - written = 0; - requiredLength = 0; - return false; - } - - state.PushInteger(address); - state.PushInteger(maximumLength); - state.PushBoolean(wideCharacter); - var status = state.TryCall(3, 1); - if (!status.IsOk) - { - written = 0; - requiredLength = 0; - failure = MemoryAccessFailure.LuaError; - return false; - } - - if (!state.TryReadUtf8(-1, out var utf8)) - { - written = 0; - requiredLength = 0; - failure = state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult; - return false; - } - - requiredLength = utf8.Length; - if (utf8.Length > destination.Length) - { - written = 0; - failure = MemoryAccessFailure.DestinationTooSmall; - return false; - } - - utf8.CopyTo(destination); - written = requiredLength; - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - written = 0; - requiredLength = 0; - failure = MemoryAccessFailure.LuaError; - return false; - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryReadString(LuaRef cache, ReadOnlySpan name, long address, int maximumLength, - bool wideCharacter, out string? value, out MemoryAccessFailure failure) - { - ArgumentOutOfRangeException.ThrowIfNegative(maximumLength); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) - { - value = default; - return false; - } - - state.PushInteger(address); - state.PushInteger(maximumLength); - state.PushBoolean(wideCharacter); - var status = state.TryCall(3, 1); - if (!status.IsOk) return Fail(out value, out failure, MemoryAccessFailure.LuaError); - if (!state.TryReadString(-1, out value)) - return Fail(out value, out failure, - state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out value, out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryReadBytes(LuaRef cache, ReadOnlySpan name, long address, Span destination, - out MemoryAccessFailure failure) - { - return TryReadBytesCore(cache, name, address, destination, copyPartial: false, out _, out failure); - } - - internal static bool TryReadBytes(LuaRef cache, ReadOnlySpan name, long address, Span destination, - out int written, out MemoryAccessFailure failure) - { - return TryReadBytesCore(cache, name, address, destination, copyPartial: true, out written, out failure); - } - - private static bool TryReadBytesCore(LuaRef cache, ReadOnlySpan name, long address, Span destination, - bool copyPartial, out int written, out MemoryAccessFailure failure) - { - written = 0; - using var operation = LuaRuntime.AcquireOperation(); - if (destination.IsEmpty) - { - failure = MemoryAccessFailure.None; - return true; - } - - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) - { - written = 0; - return false; - } - - state.PushInteger(address); - state.PushInteger(destination.Length); - state.PushBoolean(value: true); - var status = state.TryCall(3, 1); - if (!status.IsOk) return Fail(out written, out failure, MemoryAccessFailure.LuaError); - if (!state.IsTable(-1)) - return Fail(out written, out failure, - state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); - - var table = state.AbsoluteIndex(-1); - if (copyPartial) - return TryCopyPartialBytes(state, table, destination, out written, out failure); - return TryCopyCompleteBytes(state, table, destination, out written, out failure); - } - catch (LuaException) - { - return Fail(out written, out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - private static bool TryCopyCompleteBytes(LuaState state, int table, Span destination, out int written, - out MemoryAccessFailure failure) - { - for (var index = 0; index < destination.Length; index++) - { - var type = state.RawGetIndex(table, index + 1L); - var valid = type == LuaType.Number && state.TryReadInteger(-1, out var value) - && (ulong)value <= byte.MaxValue; - state.Pop(1); - if (!valid) - return Fail(out written, out failure, - type == LuaType.Nil ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); - } - - for (var index = 0; index < destination.Length; index++) - { - state.RawGetIndex(table, index + 1L); - _ = state.TryReadInteger(-1, out var value); - state.Pop(1); - destination[index] = (byte)value; - } - - written = destination.Length; - failure = MemoryAccessFailure.None; - return true; - } - - private static bool TryCopyPartialBytes(LuaState state, int table, Span destination, out int written, - out MemoryAccessFailure failure) - { - written = 0; - for (var index = 0; index < destination.Length; index++) - { - var type = state.RawGetIndex(table, index + 1L); - long value = default; - var valid = type == LuaType.Number && state.TryReadInteger(-1, out value) - && (ulong)value <= byte.MaxValue; - state.Pop(1); - if (!valid) - { - failure = type == LuaType.Nil && written != 0 - ? MemoryAccessFailure.PartialRead - : type == LuaType.Nil ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult; - return false; - } - - destination[index] = (byte)value; - written = index + 1; - } - - failure = MemoryAccessFailure.None; - return true; - } - - internal static bool TryWriteInteger(LuaRef cache, ReadOnlySpan name, long address, long value, - out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) return false; - - state.PushInteger(address); - state.PushInteger(value); - var status = state.TryCall(2, 1); - if (!status.IsOk) return Fail(out failure, MemoryAccessFailure.LuaError); - if (state.TypeOf(-1) != LuaType.Boolean) return Fail(out failure, MemoryAccessFailure.InvalidResult); - if (!state.ToBoolean(-1)) return Fail(out failure, MemoryAccessFailure.WriteFailed); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryWriteNumber(LuaRef cache, ReadOnlySpan name, long address, double value, - out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) return false; - - state.PushInteger(address); - state.PushNumber(value); - var status = state.TryCall(2, 1); - if (!status.IsOk) return Fail(out failure, MemoryAccessFailure.LuaError); - if (state.TypeOf(-1) != LuaType.Boolean) return Fail(out failure, MemoryAccessFailure.InvalidResult); - if (!state.ToBoolean(-1)) return Fail(out failure, MemoryAccessFailure.WriteFailed); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryWriteUtf8(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, - bool wideCharacter, out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) return false; - - state.PushInteger(address); - state.PushString(value); - state.PushBoolean(wideCharacter); - var status = state.TryCall(3, 1); - if (!status.IsOk) return Fail(out failure, MemoryAccessFailure.LuaError); - if (state.TypeOf(-1) != LuaType.Boolean) return Fail(out failure, MemoryAccessFailure.InvalidResult); - if (!state.ToBoolean(-1)) return Fail(out failure, MemoryAccessFailure.WriteFailed); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryWriteText(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, - bool wideCharacter, out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) return false; - - state.PushInteger(address); - state.PushString(value); - state.PushBoolean(wideCharacter); - var status = state.TryCall(3, 1); - if (!status.IsOk) return Fail(out failure, MemoryAccessFailure.LuaError); - if (state.TypeOf(-1) != LuaType.Boolean) return Fail(out failure, MemoryAccessFailure.InvalidResult); - if (!state.ToBoolean(-1)) return Fail(out failure, MemoryAccessFailure.WriteFailed); - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - internal static bool TryWriteBytes(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, - out MemoryAccessFailure failure) - { - return TryWriteBytes(cache, name, address, value, out _, out failure); - } - - internal static bool TryWriteBytes(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, - out int written, out MemoryAccessFailure failure) - { - using var operation = LuaRuntime.AcquireOperation(); - - if (value.IsEmpty) - { - written = 0; - failure = MemoryAccessFailure.None; - return true; - } - - var state = operation.State; - var top = state.Top; - try - { - if (!TryPushGlobal(state, cache, name, out failure)) - { - written = 0; - return false; - } - - state.PushInteger(address); - state.PushByteTable(value); - - var status = state.TryCall(2, 1); - if (!status.IsOk) return Fail(out written, out failure, MemoryAccessFailure.LuaError); - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out var reported)) - return Fail(out written, out failure, MemoryAccessFailure.InvalidResult); - if (reported < 0 || reported > value.Length) - return Fail(out written, out failure, MemoryAccessFailure.InvalidResult); - written = (int)reported; - if (written != value.Length) - { - failure = MemoryAccessFailure.WriteFailed; - return false; - } - - failure = MemoryAccessFailure.None; - return true; - } - catch (LuaException) - { - return Fail(out written, out failure, MemoryAccessFailure.LuaError); - } - finally - { - state.SetTop(top); - } - } - - private static bool Fail(out MemoryAccessFailure failure, MemoryAccessFailure value) - { - failure = value; - return false; - } - - private static bool TryPushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name, - out MemoryAccessFailure failure) - { - failure = LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch - { - LuaGlobalPushStatus.Success => MemoryAccessFailure.None, - LuaGlobalPushStatus.Unavailable => MemoryAccessFailure.GlobalUnavailable, - _ => MemoryAccessFailure.LuaError, - }; - return failure == MemoryAccessFailure.None; - } - - private static bool Fail(out T value, out MemoryAccessFailure failure, MemoryAccessFailure failureValue) - { - value = default!; - failure = failureValue; - return false; - } + internal static bool TryReadInteger(LuaRef cache, ReadOnlySpan name, long address, bool signed, + bool hasSignedArgument, out long value, out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + value = default; + return false; + } + + state.PushInteger(address); + if (hasSignedArgument) + { + state.PushBoolean(signed); + } + + LuaStatus status = state.TryCall(hasSignedArgument ? 2 : 1, 1); + if (!status.IsOk) + { + return Fail(out value, out failure, MemoryAccessFailure.LuaError); + } + + // lua_tointegerx accepts strings as a convenience conversion. Memory globals must not: a CE scalar + // contract is a Lua number, while an integral Lua floating-point result remains a valid scalar. + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out value)) + { + return Fail(out value, out failure, + state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out value, out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryReadNumber(LuaRef cache, ReadOnlySpan name, long address, out double value, + out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + value = default; + return false; + } + + state.PushInteger(address); + LuaStatus status = state.TryCall(1, 1); + if (!status.IsOk) + { + return Fail(out value, out failure, MemoryAccessFailure.LuaError); + } + + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadNumber(-1, out value)) + { + return Fail(out value, out failure, + state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out value, out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryReadUtf8(LuaRef cache, ReadOnlySpan name, long address, int maximumLength, + bool wideCharacter, Span destination, out int written, out MemoryAccessFailure failure) + { + return TryReadUtf8(cache, name, address, maximumLength, wideCharacter, destination, out written, out _, + out failure); + } + + internal static bool TryReadUtf8(LuaRef cache, ReadOnlySpan name, long address, int maximumLength, + bool wideCharacter, Span destination, out int written, out int requiredLength, + out MemoryAccessFailure failure) + { + ArgumentOutOfRangeException.ThrowIfNegative(maximumLength); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + written = 0; + requiredLength = 0; + return false; + } + + state.PushInteger(address); + state.PushInteger(maximumLength); + state.PushBoolean(wideCharacter); + LuaStatus status = state.TryCall(3, 1); + if (!status.IsOk) + { + written = 0; + requiredLength = 0; + failure = MemoryAccessFailure.LuaError; + return false; + } + + if (!state.TryReadUtf8(-1, out ReadOnlySpan utf8)) + { + written = 0; + requiredLength = 0; + failure = state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult; + return false; + } + + requiredLength = utf8.Length; + if (utf8.Length > destination.Length) + { + written = 0; + failure = MemoryAccessFailure.DestinationTooSmall; + return false; + } + + utf8.CopyTo(destination); + written = requiredLength; + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + written = 0; + requiredLength = 0; + failure = MemoryAccessFailure.LuaError; + return false; + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryReadString(LuaRef cache, ReadOnlySpan name, long address, int maximumLength, + bool wideCharacter, out string? value, out MemoryAccessFailure failure) + { + ArgumentOutOfRangeException.ThrowIfNegative(maximumLength); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + value = default; + return false; + } + + state.PushInteger(address); + state.PushInteger(maximumLength); + state.PushBoolean(wideCharacter); + LuaStatus status = state.TryCall(3, 1); + if (!status.IsOk) + { + return Fail(out value, out failure, MemoryAccessFailure.LuaError); + } + + if (!state.TryReadString(-1, out value)) + { + return Fail(out value, out failure, + state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out value, out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryReadBytes(LuaRef cache, ReadOnlySpan name, long address, Span destination, + out MemoryAccessFailure failure) + { + return TryReadBytesCore(cache, name, address, destination, false, out _, out failure); + } + + internal static bool TryReadBytes(LuaRef cache, ReadOnlySpan name, long address, Span destination, + out int written, out MemoryAccessFailure failure) + { + return TryReadBytesCore(cache, name, address, destination, true, out written, out failure); + } + + private static bool TryReadBytesCore(LuaRef cache, ReadOnlySpan name, long address, Span destination, + bool copyPartial, out int written, out MemoryAccessFailure failure) + { + written = 0; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + if (destination.IsEmpty) + { + failure = MemoryAccessFailure.None; + return true; + } + + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + written = 0; + return false; + } + + state.PushInteger(address); + state.PushInteger(destination.Length); + state.PushBoolean(true); + LuaStatus status = state.TryCall(3, 1); + if (!status.IsOk) + { + return Fail(out written, out failure, MemoryAccessFailure.LuaError); + } + + if (!state.IsTable(-1)) + { + return Fail(out written, out failure, + state.IsNil(-1) ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); + } + + int table = state.AbsoluteIndex(-1); + if (copyPartial) + { + return TryCopyPartialBytes(state, table, destination, out written, out failure); + } + + return TryCopyCompleteBytes(state, table, destination, out written, out failure); + } + catch (LuaException) + { + return Fail(out written, out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + private static bool TryCopyCompleteBytes(LuaState state, int table, Span destination, out int written, + out MemoryAccessFailure failure) + { + for (int index = 0; index < destination.Length; index++) + { + LuaType type = state.RawGetIndex(table, index + 1L); + bool valid = type == LuaType.Number && state.TryReadInteger(-1, out long value) + && (ulong) value <= byte.MaxValue; + state.Pop(1); + if (!valid) + { + return Fail(out written, out failure, + type == LuaType.Nil ? MemoryAccessFailure.ReadFailed : MemoryAccessFailure.InvalidResult); + } + } + + for (int index = 0; index < destination.Length; index++) + { + state.RawGetIndex(table, index + 1L); + _ = state.TryReadInteger(-1, out long value); + state.Pop(1); + destination[index] = (byte) value; + } + + written = destination.Length; + failure = MemoryAccessFailure.None; + return true; + } + + private static bool TryCopyPartialBytes(LuaState state, int table, Span destination, out int written, + out MemoryAccessFailure failure) + { + written = 0; + for (int index = 0; index < destination.Length; index++) + { + LuaType type = state.RawGetIndex(table, index + 1L); + long value = default; + bool valid = type == LuaType.Number && state.TryReadInteger(-1, out value) + && (ulong) value <= byte.MaxValue; + state.Pop(1); + if (!valid) + { + failure = type == LuaType.Nil && written != 0 + ? MemoryAccessFailure.PartialRead + : type == LuaType.Nil + ? MemoryAccessFailure.ReadFailed + : MemoryAccessFailure.InvalidResult; + return false; + } + + destination[index] = (byte) value; + written = index + 1; + } + + failure = MemoryAccessFailure.None; + return true; + } + + internal static bool TryWriteInteger(LuaRef cache, ReadOnlySpan name, long address, long value, + out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + return false; + } + + state.PushInteger(address); + state.PushInteger(value); + LuaStatus status = state.TryCall(2, 1); + if (!status.IsOk) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return Fail(out failure, MemoryAccessFailure.InvalidResult); + } + + if (!state.ToBoolean(-1)) + { + return Fail(out failure, MemoryAccessFailure.WriteFailed); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryWriteNumber(LuaRef cache, ReadOnlySpan name, long address, double value, + out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + return false; + } + + state.PushInteger(address); + state.PushNumber(value); + LuaStatus status = state.TryCall(2, 1); + if (!status.IsOk) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return Fail(out failure, MemoryAccessFailure.InvalidResult); + } + + if (!state.ToBoolean(-1)) + { + return Fail(out failure, MemoryAccessFailure.WriteFailed); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryWriteUtf8(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, + bool wideCharacter, out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + return false; + } + + state.PushInteger(address); + state.PushString(value); + state.PushBoolean(wideCharacter); + LuaStatus status = state.TryCall(3, 1); + if (!status.IsOk) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return Fail(out failure, MemoryAccessFailure.InvalidResult); + } + + if (!state.ToBoolean(-1)) + { + return Fail(out failure, MemoryAccessFailure.WriteFailed); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryWriteText(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, + bool wideCharacter, out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + return false; + } + + state.PushInteger(address); + state.PushString(value); + state.PushBoolean(wideCharacter); + LuaStatus status = state.TryCall(3, 1); + if (!status.IsOk) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return Fail(out failure, MemoryAccessFailure.InvalidResult); + } + + if (!state.ToBoolean(-1)) + { + return Fail(out failure, MemoryAccessFailure.WriteFailed); + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + internal static bool TryWriteBytes(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, + out MemoryAccessFailure failure) + { + return TryWriteBytes(cache, name, address, value, out _, out failure); + } + + internal static bool TryWriteBytes(LuaRef cache, ReadOnlySpan name, long address, ReadOnlySpan value, + out int written, out MemoryAccessFailure failure) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + + if (value.IsEmpty) + { + written = 0; + failure = MemoryAccessFailure.None; + return true; + } + + LuaState state = operation.State; + int top = state.Top; + try + { + if (!TryPushGlobal(state, cache, name, out failure)) + { + written = 0; + return false; + } + + state.PushInteger(address); + state.PushByteTable(value); + + LuaStatus status = state.TryCall(2, 1); + if (!status.IsOk) + { + return Fail(out written, out failure, MemoryAccessFailure.LuaError); + } + + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long reported)) + { + return Fail(out written, out failure, MemoryAccessFailure.InvalidResult); + } + + if (reported < 0 || reported > value.Length) + { + return Fail(out written, out failure, MemoryAccessFailure.InvalidResult); + } + + written = (int) reported; + if (written != value.Length) + { + failure = MemoryAccessFailure.WriteFailed; + return false; + } + + failure = MemoryAccessFailure.None; + return true; + } + catch (LuaException) + { + return Fail(out written, out failure, MemoryAccessFailure.LuaError); + } + finally + { + state.SetTop(top); + } + } + + private static bool Fail(out MemoryAccessFailure failure, MemoryAccessFailure value) + { + failure = value; + return false; + } + + private static bool TryPushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name, + out MemoryAccessFailure failure) + { + failure = LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch + { + LuaGlobalPushStatus.Success => MemoryAccessFailure.None, + LuaGlobalPushStatus.Unavailable => MemoryAccessFailure.GlobalUnavailable, + _ => MemoryAccessFailure.LuaError + }; + return failure == MemoryAccessFailure.None; + } + + private static bool Fail(out T value, out MemoryAccessFailure failure, MemoryAccessFailure failureValue) + { + value = default!; + failure = failureValue; + return false; + } } diff --git a/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs b/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs index a4f5ac42..41ab0122 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; @@ -31,456 +32,471 @@ namespace CheatEngine.SDK.Engine.Memory; [RequiresPluginEnabled] public static class TargetMemory { - private static readonly LuaRef SReadByte = new(); - private static readonly LuaRef SReadSmallInteger = new(); - private static readonly LuaRef SReadInteger = new(); - private static readonly LuaRef SReadQword = new(); - private static readonly LuaRef SReadPointer = new(); - private static readonly LuaRef SReadFloat = new(); - private static readonly LuaRef SReadDouble = new(); - private static readonly LuaRef SReadBytes = new(); - private static readonly LuaRef SReadString = new(); - private static readonly LuaRef SWriteByte = new(); - private static readonly LuaRef SWriteSmallInteger = new(); - private static readonly LuaRef SWriteInteger = new(); - private static readonly LuaRef SWriteQword = new(); - private static readonly LuaRef SWritePointer = new(); - private static readonly LuaRef SWriteFloat = new(); - private static readonly LuaRef SWriteDouble = new(); - private static readonly LuaRef SWriteBytes = new(); - private static readonly LuaRef SWriteString = new(); - - /// Reads an unsigned 8-bit integer from the target. - public static bool TryReadUInt8(Address address, out byte value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadByte, "readByte"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out var raw, - out failure)) - { - value = default; - return false; - } - - return TryUnsigned(raw, byte.MaxValue, out value, out failure); - } - - /// Reads a signed 8-bit integer from the target. - public static bool TryReadInt8(Address address, out sbyte value, out MemoryAccessFailure failure) - { - if (!TryReadUInt8(address, out var raw, out failure)) - { - value = default; - return false; - } - - value = unchecked((sbyte)raw); - return true; - } - - /// Reads an unsigned 16-bit integer from the target. - public static bool TryReadUInt16(Address address, out ushort value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), signed: false, - hasSignedArgument: true, - out var raw, - out failure)) - { - value = default; - return false; - } - - return TryUnsigned(raw, ushort.MaxValue, out value, out failure); - } - - /// Reads a signed 16-bit integer from the target. - public static bool TryReadInt16(Address address, out short value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), signed: true, - hasSignedArgument: true, - out var raw, - out failure)) - { - value = default; - return false; - } - - return TrySigned(raw, short.MinValue, short.MaxValue, out value, out failure); - } - - /// Reads an unsigned 32-bit integer from the target. - public static bool TryReadUInt32(Address address, out uint value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), signed: false, - hasSignedArgument: true, out var raw, - out failure)) - { - value = default; - return false; - } - - return TryUnsigned(raw, uint.MaxValue, out value, out failure); - } - - /// Reads a signed 32-bit integer from the target. - public static bool TryReadInt32(Address address, out int value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), signed: true, - hasSignedArgument: true, out var raw, - out failure)) - { - value = default; - return false; - } - - return TrySigned(raw, int.MinValue, int.MaxValue, out value, out failure); - } - - /// Reads an unsigned 64-bit integer from the target, preserving all Lua integer bits. - public static bool TryReadUInt64(Address address, out ulong value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadQword, "readQword"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out var raw, - out failure)) - { - value = default; - return false; - } - - value = unchecked((ulong)raw); - return true; - } - - /// Reads a signed 64-bit integer from the target. - public static bool TryReadInt64(Address address, out long value, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadInteger(SReadQword, "readQword"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out value, out failure); - } - - /// Reads a pointer whose width Cheat Engine selects from the attached target architecture. - public static bool TryReadPointer(Address address, out Address value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadInteger(SReadPointer, "readPointer"u8, address.ToInt64(), signed: false, - hasSignedArgument: false, out var raw, - out failure)) - { - value = default; - return false; - } - - value = Address.FromInt64(raw); - return true; - } - - /// Reads a pointer and verifies that it fits the explicitly observed target pointer width. - /// The target address of the pointer value. - /// The observed target pointer width; is refused. - /// The pointer address, or zero when this method returns . - /// The factual CE or target-width failure. - /// when CE returned a pointer that fits . - /// - /// This overload qualifies an ambient CE readPointer result with a target fact supplied by the caller. - /// It deliberately never uses : the x64 plugin host can inspect an x86 target. - /// - public static bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, - out MemoryAccessFailure failure) - { - if (!pointerSize.IsKnown) - { - value = default; - failure = MemoryAccessFailure.PointerWidthUnknown; - return false; - } - - if (!TryReadPointer(address, out value, out failure)) return false; - - if (pointerSize == PointerSize.Bit32 && value.Value > uint.MaxValue) - { - value = default; - failure = MemoryAccessFailure.PointerValueExceedsTargetWidth; - return false; - } - - return true; - } - - /// Reads a single-precision floating-point value from the target. - public static bool TryReadSingle(Address address, out float value, out MemoryAccessFailure failure) - { - if (!MemoryLua.TryReadNumber(SReadFloat, "readFloat"u8, address.ToInt64(), out var raw, out failure)) - { - value = default; - return false; - } - - value = (float)raw; - return true; - } - - /// Reads a double-precision floating-point value from the target. - public static bool TryReadDouble(Address address, out double value, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadNumber(SReadDouble, "readDouble"u8, address.ToInt64(), out value, out failure); - } - - /// Writes an unsigned 8-bit integer to the target. - public static bool TryWriteUInt8(Address address, byte value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteByte, "writeByte"u8, address.ToInt64(), value, out failure); - } - - /// Writes a signed 8-bit integer to the target. - public static bool TryWriteInt8(Address address, sbyte value, out MemoryAccessFailure failure) - { - return TryWriteUInt8(address, unchecked((byte)value), out failure); - } - - /// Writes an unsigned 16-bit integer to the target. - public static bool TryWriteUInt16(Address address, ushort value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallInteger"u8, address.ToInt64(), value, - out failure); - } - - /// Writes a signed 16-bit integer to the target. - public static bool TryWriteInt16(Address address, short value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallInteger"u8, address.ToInt64(), value, - out failure); - } - - /// Writes an unsigned 32-bit integer to the target. - public static bool TryWriteUInt32(Address address, uint value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteInteger, "writeInteger"u8, address.ToInt64(), value, out failure); - } - - /// Writes a signed 32-bit integer to the target. - public static bool TryWriteInt32(Address address, int value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteInteger, "writeInteger"u8, address.ToInt64(), value, out failure); - } - - /// Writes an unsigned 64-bit integer to the target, preserving all bits. - public static bool TryWriteUInt64(Address address, ulong value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteQword, "writeQword"u8, address.ToInt64(), unchecked((long)value), - out failure); - } - - /// Writes a signed 64-bit integer to the target. - public static bool TryWriteInt64(Address address, long value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWriteQword, "writeQword"u8, address.ToInt64(), value, out failure); - } - - /// Writes a target-aware pointer value to the target without independently qualifying its target width. - /// - /// Retained for compatibility. Call the overload that accepts an observed whenever - /// a 32-bit target could be selected: CE's legacy x86 pointer primitive truncates a value wider than 32 bits. - /// - public static bool TryWritePointer(Address address, Address value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteInteger(SWritePointer, "writePointer"u8, address.ToInt64(), value.ToInt64(), - out failure); - } - - /// Writes a pointer after verifying that it fits the explicitly observed target pointer width. - /// The target address of the pointer value. - /// The pointer value to write. - /// The observed target pointer width; is refused. - /// The factual CE or target-width failure. - /// when fits and CE reports success. - /// - /// The observation is supplied by the caller and cannot make CE's ambient target selection atomic with this - /// write. It does prevent this SDK call from silently narrowing a 64-bit value through CE's x86 primitive. - /// - public static bool TryWritePointer(Address address, Address value, PointerSize pointerSize, - out MemoryAccessFailure failure) - { - if (!pointerSize.IsKnown) - { - failure = MemoryAccessFailure.PointerWidthUnknown; - return false; - } - - if (pointerSize == PointerSize.Bit32 && value.Value > uint.MaxValue) - { - failure = MemoryAccessFailure.PointerValueExceedsTargetWidth; - return false; - } - - return TryWritePointer(address, value, out failure); - } - - /// Writes a single-precision floating-point value to the target. - public static bool TryWriteSingle(Address address, float value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteNumber(SWriteFloat, "writeFloat"u8, address.ToInt64(), value, out failure); - } - - /// Writes a double-precision floating-point value to the target. - public static bool TryWriteDouble(Address address, double value, out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteNumber(SWriteDouble, "writeDouble"u8, address.ToInt64(), value, out failure); - } - - /// Reads exactly .Length bytes from the target into caller-owned storage. - public static bool TryReadBytes(Address address, Span destination, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadBytes(SReadBytes, "readBytes"u8, address.ToInt64(), destination, out failure); - } - - /// - /// Reads exactly .Length bytes into caller-owned storage and reports the number - /// copied. - /// - /// The target address to read. - /// The caller-owned storage. This overload can copy a validated contiguous prefix before it observes an incomplete or malformed table. - /// The verified number copied, including a confirmed contiguous prefix on . - /// The factual CE or buffer-contract failure. - /// only after all requested bytes have been copied. - public static bool TryReadBytes(Address address, Span destination, out int written, - out MemoryAccessFailure failure) - { - return MemoryLua.TryReadBytes(SReadBytes, "readBytes"u8, address.ToInt64(), destination, out written, - out failure); - } - - /// Writes the caller-owned byte sequence as one ordered Lua byte table. - public static bool TryWriteBytes(Address address, ReadOnlySpan value, out MemoryAccessFailure failure) - { - return TryWriteBytes(address, value, out _, out failure); - } - - /// Writes a caller-owned byte sequence and reports the exact byte count returned by Cheat Engine. - /// The target address to write. - /// The caller-owned bytes in source order. - /// The CE-reported count, including a confirmed partial count on . - /// The factual CE or result-contract failure. - /// only when CE reports the complete requested count. - public static bool TryWriteBytes(Address address, ReadOnlySpan value, out int written, - out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteBytes(SWriteBytes, "writeBytes"u8, address.ToInt64(), value, out written, - out failure); - } - - /// Reads a UTF-8 Lua string into caller-owned storage without retaining a Lua-owned span. - public static bool TryReadUtf8(Address address, int maximumLength, Span destination, bool wideCharacter, - out int written, out MemoryAccessFailure failure) - { - return TryReadUtf8(address, maximumLength, destination, wideCharacter, out written, out _, out failure); - } - - /// Reads UTF-8 text into caller-owned storage and reports the exact capacity required by the returned text. - /// The target address to read. - /// The maximum character count passed to CE's documented string primitive. - /// The caller-owned UTF-8 storage; it is unchanged when it is too small. - /// Whether CE should read a wide-character string. - /// The copied byte count, which is zero on failure. - /// The returned UTF-8 byte count when CE supplied a string, including a short destination. - /// The factual CE or capacity failure. - /// only after the complete UTF-8 value has been copied. - public static bool TryReadUtf8(Address address, int maximumLength, Span destination, bool wideCharacter, - out int written, out int requiredLength, out MemoryAccessFailure failure) - { - return MemoryLua.TryReadUtf8(SReadString, "readString"u8, address.ToInt64(), maximumLength, wideCharacter, - destination, out written, out requiredLength, out failure); - } - - /// Reads a UTF-8 Lua string into a managed string; use the byte-span overload on allocation-sensitive paths. - public static bool TryReadString(Address address, int maximumLength, bool wideCharacter, out string? value, - out MemoryAccessFailure failure) - { - return MemoryLua.TryReadString(SReadString, "readString"u8, address.ToInt64(), maximumLength, wideCharacter, - out value, out failure); - } - - /// Writes a UTF-8 byte sequence through CE's writeString global. - public static bool TryWriteUtf8(Address address, ReadOnlySpan value, bool wideCharacter, - out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteUtf8(SWriteString, "writeString"u8, address.ToInt64(), value, wideCharacter, - out failure); - } - - /// Writes UTF-16 text as a Lua UTF-8 string without exposing an ambiguous raw address. - public static bool TryWriteString(Address address, ReadOnlySpan value, bool wideCharacter, - out MemoryAccessFailure failure) - { - return MemoryLua.TryWriteText(SWriteString, "writeString"u8, address.ToInt64(), value, wideCharacter, - out failure); - } - - private static bool TryUnsigned(long raw, ulong maximum, out byte value, out MemoryAccessFailure failure) - { - if (raw < 0 || (ulong)raw > maximum) - { - value = default; - failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (byte)raw; - failure = MemoryAccessFailure.None; - return true; - } - - private static bool TryUnsigned(long raw, ulong maximum, out ushort value, out MemoryAccessFailure failure) - { - if (raw < 0 || (ulong)raw > maximum) - { - value = default; - failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (ushort)raw; - failure = MemoryAccessFailure.None; - return true; - } - - private static bool TryUnsigned(long raw, ulong maximum, out uint value, out MemoryAccessFailure failure) - { - if (raw < 0 || (ulong)raw > maximum) - { - value = default; - failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (uint)raw; - failure = MemoryAccessFailure.None; - return true; - } - - private static bool TrySigned(long raw, long minimum, long maximum, out short value, - out MemoryAccessFailure failure) - { - if (raw < minimum || raw > maximum) - { - value = default; - failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (short)raw; - failure = MemoryAccessFailure.None; - return true; - } - - private static bool TrySigned(long raw, long minimum, long maximum, out int value, out MemoryAccessFailure failure) - { - if (raw < minimum || raw > maximum) - { - value = default; - failure = MemoryAccessFailure.InvalidResult; - return false; - } - - value = (int)raw; - failure = MemoryAccessFailure.None; - return true; - } + private static readonly LuaRef SReadByte = new(); + private static readonly LuaRef SReadSmallInteger = new(); + private static readonly LuaRef SReadInteger = new(); + private static readonly LuaRef SReadQword = new(); + private static readonly LuaRef SReadPointer = new(); + private static readonly LuaRef SReadFloat = new(); + private static readonly LuaRef SReadDouble = new(); + private static readonly LuaRef SReadBytes = new(); + private static readonly LuaRef SReadString = new(); + private static readonly LuaRef SWriteByte = new(); + private static readonly LuaRef SWriteSmallInteger = new(); + private static readonly LuaRef SWriteInteger = new(); + private static readonly LuaRef SWriteQword = new(); + private static readonly LuaRef SWritePointer = new(); + private static readonly LuaRef SWriteFloat = new(); + private static readonly LuaRef SWriteDouble = new(); + private static readonly LuaRef SWriteBytes = new(); + private static readonly LuaRef SWriteString = new(); + + /// Reads an unsigned 8-bit integer from the target. + public static bool TryReadUInt8(Address address, out byte value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadByte, "readByte"u8, address.ToInt64(), false, + false, out long raw, + out failure)) + { + value = default; + return false; + } + + return TryUnsigned(raw, byte.MaxValue, out value, out failure); + } + + /// Reads a signed 8-bit integer from the target. + public static bool TryReadInt8(Address address, out sbyte value, out MemoryAccessFailure failure) + { + if (!TryReadUInt8(address, out byte raw, out failure)) + { + value = default; + return false; + } + + value = unchecked((sbyte) raw); + return true; + } + + /// Reads an unsigned 16-bit integer from the target. + public static bool TryReadUInt16(Address address, out ushort value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), false, + true, + out long raw, + out failure)) + { + value = default; + return false; + } + + return TryUnsigned(raw, ushort.MaxValue, out value, out failure); + } + + /// Reads a signed 16-bit integer from the target. + public static bool TryReadInt16(Address address, out short value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), true, + true, + out long raw, + out failure)) + { + value = default; + return false; + } + + return TrySigned(raw, short.MinValue, short.MaxValue, out value, out failure); + } + + /// Reads an unsigned 32-bit integer from the target. + public static bool TryReadUInt32(Address address, out uint value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), false, + true, out long raw, + out failure)) + { + value = default; + return false; + } + + return TryUnsigned(raw, uint.MaxValue, out value, out failure); + } + + /// Reads a signed 32-bit integer from the target. + public static bool TryReadInt32(Address address, out int value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), true, + true, out long raw, + out failure)) + { + value = default; + return false; + } + + return TrySigned(raw, int.MinValue, int.MaxValue, out value, out failure); + } + + /// Reads an unsigned 64-bit integer from the target, preserving all Lua integer bits. + public static bool TryReadUInt64(Address address, out ulong value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadQword, "readQword"u8, address.ToInt64(), false, + false, out long raw, + out failure)) + { + value = default; + return false; + } + + value = unchecked((ulong) raw); + return true; + } + + /// Reads a signed 64-bit integer from the target. + public static bool TryReadInt64(Address address, out long value, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadInteger(SReadQword, "readQword"u8, address.ToInt64(), false, + false, out value, out failure); + } + + /// Reads a pointer whose width Cheat Engine selects from the attached target architecture. + public static bool TryReadPointer(Address address, out Address value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadInteger(SReadPointer, "readPointer"u8, address.ToInt64(), false, + false, out long raw, + out failure)) + { + value = default; + return false; + } + + value = Address.FromInt64(raw); + return true; + } + + /// Reads a pointer and verifies that it fits the explicitly observed target pointer width. + /// The target address of the pointer value. + /// The observed target pointer width; is refused. + /// The pointer address, or zero when this method returns . + /// The factual CE or target-width failure. + /// when CE returned a pointer that fits . + /// + /// This overload qualifies an ambient CE readPointer result with a target fact supplied by the caller. + /// It deliberately never uses : the x64 plugin host can inspect an x86 target. + /// + public static bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, + out MemoryAccessFailure failure) + { + if (!pointerSize.IsKnown) + { + value = default; + failure = MemoryAccessFailure.PointerWidthUnknown; + return false; + } + + if (!TryReadPointer(address, out value, out failure)) + { + return false; + } + + if (pointerSize == PointerSize.Bit32 && value.Value > uint.MaxValue) + { + value = default; + failure = MemoryAccessFailure.PointerValueExceedsTargetWidth; + return false; + } + + return true; + } + + /// Reads a single-precision floating-point value from the target. + public static bool TryReadSingle(Address address, out float value, out MemoryAccessFailure failure) + { + if (!MemoryLua.TryReadNumber(SReadFloat, "readFloat"u8, address.ToInt64(), out double raw, out failure)) + { + value = default; + return false; + } + + value = (float) raw; + return true; + } + + /// Reads a double-precision floating-point value from the target. + public static bool TryReadDouble(Address address, out double value, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadNumber(SReadDouble, "readDouble"u8, address.ToInt64(), out value, out failure); + } + + /// Writes an unsigned 8-bit integer to the target. + public static bool TryWriteUInt8(Address address, byte value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteByte, "writeByte"u8, address.ToInt64(), value, out failure); + } + + /// Writes a signed 8-bit integer to the target. + public static bool TryWriteInt8(Address address, sbyte value, out MemoryAccessFailure failure) + { + return TryWriteUInt8(address, unchecked((byte) value), out failure); + } + + /// Writes an unsigned 16-bit integer to the target. + public static bool TryWriteUInt16(Address address, ushort value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallInteger"u8, address.ToInt64(), value, + out failure); + } + + /// Writes a signed 16-bit integer to the target. + public static bool TryWriteInt16(Address address, short value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteSmallInteger, "writeSmallInteger"u8, address.ToInt64(), value, + out failure); + } + + /// Writes an unsigned 32-bit integer to the target. + public static bool TryWriteUInt32(Address address, uint value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteInteger, "writeInteger"u8, address.ToInt64(), value, out failure); + } + + /// Writes a signed 32-bit integer to the target. + public static bool TryWriteInt32(Address address, int value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteInteger, "writeInteger"u8, address.ToInt64(), value, out failure); + } + + /// Writes an unsigned 64-bit integer to the target, preserving all bits. + public static bool TryWriteUInt64(Address address, ulong value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteQword, "writeQword"u8, address.ToInt64(), unchecked((long) value), + out failure); + } + + /// Writes a signed 64-bit integer to the target. + public static bool TryWriteInt64(Address address, long value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWriteQword, "writeQword"u8, address.ToInt64(), value, out failure); + } + + /// Writes a target-aware pointer value to the target without independently qualifying its target width. + /// + /// Retained for compatibility. Call the overload that accepts an observed whenever + /// a 32-bit target could be selected: CE's legacy x86 pointer primitive truncates a value wider than 32 bits. + /// + public static bool TryWritePointer(Address address, Address value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteInteger(SWritePointer, "writePointer"u8, address.ToInt64(), value.ToInt64(), + out failure); + } + + /// Writes a pointer after verifying that it fits the explicitly observed target pointer width. + /// The target address of the pointer value. + /// The pointer value to write. + /// The observed target pointer width; is refused. + /// The factual CE or target-width failure. + /// + /// when fits and CE reports + /// success. + /// + /// + /// The observation is supplied by the caller and cannot make CE's ambient target selection atomic with this + /// write. It does prevent this SDK call from silently narrowing a 64-bit value through CE's x86 primitive. + /// + public static bool TryWritePointer(Address address, Address value, PointerSize pointerSize, + out MemoryAccessFailure failure) + { + if (!pointerSize.IsKnown) + { + failure = MemoryAccessFailure.PointerWidthUnknown; + return false; + } + + if (pointerSize == PointerSize.Bit32 && value.Value > uint.MaxValue) + { + failure = MemoryAccessFailure.PointerValueExceedsTargetWidth; + return false; + } + + return TryWritePointer(address, value, out failure); + } + + /// Writes a single-precision floating-point value to the target. + public static bool TryWriteSingle(Address address, float value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteNumber(SWriteFloat, "writeFloat"u8, address.ToInt64(), value, out failure); + } + + /// Writes a double-precision floating-point value to the target. + public static bool TryWriteDouble(Address address, double value, out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteNumber(SWriteDouble, "writeDouble"u8, address.ToInt64(), value, out failure); + } + + /// Reads exactly .Length bytes from the target into caller-owned storage. + public static bool TryReadBytes(Address address, Span destination, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadBytes(SReadBytes, "readBytes"u8, address.ToInt64(), destination, out failure); + } + + /// + /// Reads exactly .Length bytes into caller-owned storage and reports the number + /// copied. + /// + /// The target address to read. + /// + /// The caller-owned storage. This overload can copy a validated contiguous prefix before it + /// observes an incomplete or malformed table. + /// + /// + /// The verified number copied, including a confirmed contiguous prefix on + /// . + /// + /// The factual CE or buffer-contract failure. + /// only after all requested bytes have been copied. + public static bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + return MemoryLua.TryReadBytes(SReadBytes, "readBytes"u8, address.ToInt64(), destination, out written, + out failure); + } + + /// Writes the caller-owned byte sequence as one ordered Lua byte table. + public static bool TryWriteBytes(Address address, ReadOnlySpan value, out MemoryAccessFailure failure) + { + return TryWriteBytes(address, value, out _, out failure); + } + + /// Writes a caller-owned byte sequence and reports the exact byte count returned by Cheat Engine. + /// The target address to write. + /// The caller-owned bytes in source order. + /// + /// The CE-reported count, including a confirmed partial count on + /// . + /// + /// The factual CE or result-contract failure. + /// only when CE reports the complete requested count. + public static bool TryWriteBytes(Address address, ReadOnlySpan value, out int written, + out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteBytes(SWriteBytes, "writeBytes"u8, address.ToInt64(), value, out written, + out failure); + } + + /// Reads a UTF-8 Lua string into caller-owned storage without retaining a Lua-owned span. + public static bool TryReadUtf8(Address address, int maximumLength, Span destination, bool wideCharacter, + out int written, out MemoryAccessFailure failure) + { + return TryReadUtf8(address, maximumLength, destination, wideCharacter, out written, out _, out failure); + } + + /// Reads UTF-8 text into caller-owned storage and reports the exact capacity required by the returned text. + /// The target address to read. + /// The maximum character count passed to CE's documented string primitive. + /// The caller-owned UTF-8 storage; it is unchanged when it is too small. + /// Whether CE should read a wide-character string. + /// The copied byte count, which is zero on failure. + /// The returned UTF-8 byte count when CE supplied a string, including a short destination. + /// The factual CE or capacity failure. + /// only after the complete UTF-8 value has been copied. + public static bool TryReadUtf8(Address address, int maximumLength, Span destination, bool wideCharacter, + out int written, out int requiredLength, out MemoryAccessFailure failure) + { + return MemoryLua.TryReadUtf8(SReadString, "readString"u8, address.ToInt64(), maximumLength, wideCharacter, + destination, out written, out requiredLength, out failure); + } + + /// Reads a UTF-8 Lua string into a managed string; use the byte-span overload on allocation-sensitive paths. + public static bool TryReadString(Address address, int maximumLength, bool wideCharacter, out string? value, + out MemoryAccessFailure failure) + { + return MemoryLua.TryReadString(SReadString, "readString"u8, address.ToInt64(), maximumLength, wideCharacter, + out value, out failure); + } + + /// Writes a UTF-8 byte sequence through CE's writeString global. + public static bool TryWriteUtf8(Address address, ReadOnlySpan value, bool wideCharacter, + out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteUtf8(SWriteString, "writeString"u8, address.ToInt64(), value, wideCharacter, + out failure); + } + + /// Writes UTF-16 text as a Lua UTF-8 string without exposing an ambiguous raw address. + public static bool TryWriteString(Address address, ReadOnlySpan value, bool wideCharacter, + out MemoryAccessFailure failure) + { + return MemoryLua.TryWriteText(SWriteString, "writeString"u8, address.ToInt64(), value, wideCharacter, + out failure); + } + + private static bool TryUnsigned(long raw, ulong maximum, out byte value, out MemoryAccessFailure failure) + { + if (raw < 0 || (ulong) raw > maximum) + { + value = default; + failure = MemoryAccessFailure.InvalidResult; + return false; + } + + value = (byte) raw; + failure = MemoryAccessFailure.None; + return true; + } + + private static bool TryUnsigned(long raw, ulong maximum, out ushort value, out MemoryAccessFailure failure) + { + if (raw < 0 || (ulong) raw > maximum) + { + value = default; + failure = MemoryAccessFailure.InvalidResult; + return false; + } + + value = (ushort) raw; + failure = MemoryAccessFailure.None; + return true; + } + + private static bool TryUnsigned(long raw, ulong maximum, out uint value, out MemoryAccessFailure failure) + { + if (raw < 0 || (ulong) raw > maximum) + { + value = default; + failure = MemoryAccessFailure.InvalidResult; + return false; + } + + value = (uint) raw; + failure = MemoryAccessFailure.None; + return true; + } + + private static bool TrySigned(long raw, long minimum, long maximum, out short value, + out MemoryAccessFailure failure) + { + if (raw < minimum || raw > maximum) + { + value = default; + failure = MemoryAccessFailure.InvalidResult; + return false; + } + + value = (short) raw; + failure = MemoryAccessFailure.None; + return true; + } + + private static bool TrySigned(long raw, long minimum, long maximum, out int value, out MemoryAccessFailure failure) + { + if (raw < minimum || raw > maximum) + { + value = default; + failure = MemoryAccessFailure.InvalidResult; + return false; + } + + value = (int) raw; + failure = MemoryAccessFailure.None; + return true; + } } diff --git a/libs/CheatEngine.SDK.Engine/Objects/CEObject.cs b/libs/CheatEngine.SDK.Engine/Objects/CEObject.cs index 8fcfaece..578d2c57 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/CEObject.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/CEObject.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Annotations.Threading; @@ -74,547 +75,573 @@ namespace CheatEngine.SDK.Engine.Objects; /// public readonly struct CEObject : IEquatable, ICEObject, ILuaMarshaller { - /// The name of the method every Cheat Engine object has to free itself. - private static ReadOnlySpan DestroyMethodName => "destroy"u8; - - /// Wraps a native object pointer. - /// The pointer as Cheat Engine's Lua API knows it; zero gives . - /// A pure value operation: nothing is checked, nothing is called. - public CEObject(nint value) - { - Value = value; - } - - /// Gets the handle that names no object. - public static CEObject Null => default; - - /// Gets the native object pointer: the identity of the object, never dereferenced by managed code. - public nint Value { get; } - - /// Gets a value indicating whether the handle names no object. - public bool IsNull => Value == 0; - - /// - public CEObject Handle => this; - - /// - public static CEObject FromHandle(CEObject handle) - { - return handle; - } - - /// Compares two handles for identity of the native object. - /// First handle. - /// Second handle. - public static bool operator ==(CEObject left, CEObject right) - { - return left.Value == right.Value; - } - - /// Compares two handles for identity of the native object. - /// First handle. - /// Second handle. - public static bool operator !=(CEObject left, CEObject right) - { - return left.Value != right.Value; - } - - /// - public bool Equals(CEObject other) - { - return Value == other.Value; - } - - /// - public override bool Equals(object? obj) - { - return obj is CEObject other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Value.GetHashCode(); - } - - /// Formats the pointer, for diagnostics. - /// CEObject@0x..., or CEObject(null). - public override string ToString() - { - return IsNull ? "CEObject(null)" : "CEObject@0x" + Value.ToString("X", CultureInfo.InvariantCulture); - } - - /// - /// Pushes the object's userdata through the host's pusher (), which is - /// the only way a Cheat Engine object gets onto the Lua stack. A handle pushes nil. - /// - /// The calling thread's state. - /// The plugin is not enabled, or the host binding has no pusher. - /// - /// What the host allocates for the userdata (and whether it caches one per object) is the host's business. - /// - [RequiresPluginEnabled] - [LuaStackEffect(1)] - public void Push(LuaState state) - { - if (IsNull) - { - state.PushNil(); - return; - } - - LuaRuntime.PushHostObject(state, Value); - } - - /// The form of . - /// The calling thread's state. - /// The handle to push; pushes nil. - [RequiresPluginEnabled] - [LuaStackEffect(1)] - public static void Push(LuaState state, CEObject value) - { - value.Push(state); - } - - /// - /// Reads the handle of the Cheat Engine object at : the value must be a full userdata - /// whose block is at least pointer-sized and whose first pointer-sized field is not null. The stack is not - /// modified and nothing is allocated. - /// - /// The state to read from. - /// An acceptable index. - /// The handle, or when the value is not a host object. - /// when holds a handle. - /// - /// - /// This is the one place that decodes the userdata layout, and the layout is an assumption: Cheat - /// Engine's LuaPushClassInstance creates a full userdata and stores the object pointer in its first - /// pointer-sized field. Three checks stand in for the knowledge this SDK does not have: the type tag must - /// be (a light userdata is a bare pointer with no block to read; a table or - /// a number is not an object), the block must be at least a pointer long (lua_rawlen, so the read - /// cannot overrun a smaller block), and the field must not be zero. A userdata of some other library that - /// happens to start with a non-null pointer is indistinguishable from an object here; the metatable is not - /// inspected because Cheat Engine's metatables carry no known marker. The layout can be cross-checked on a - /// live Cheat Engine against its own userDataToInteger(obj), which returns the same pointer. - /// - /// Three C API calls (type, block, length) and one memory read; never raises. - /// - [LuaStackEffect(0)] - public static unsafe bool TryRead(LuaState state, int index, out CEObject value) - { - if (state.TypeOf(index) == LuaType.Userdata) - { - var block = state.ToUserdata(index); - if (block != 0 && state.RawLength(index) >= (nuint)sizeof(nint)) - { - var pointer = *(nint*)block; - if (pointer != 0) - { - value = new CEObject(pointer); - return true; - } - } - } - - value = default; - return false; - } - - /// - /// Pushes the property of the object under protection (obj[name]; the metatable's - /// __index runs inside the call). Stack after success: the value (nil for a member the object does - /// not have, if the host does not raise for it); after failure: one error value. - /// - /// The calling thread's state. - /// The property name as Cheat Engine spells it, UTF-8; a "..."u8 literal. - /// The status. - /// The plugin is not enabled. - /// - /// One push, one protected field access, one lua_rotate and one lua_settop to remove the object - /// again. - /// - [RequiresPluginEnabled] - public LuaStatus TryGetProperty(LuaState state, ReadOnlySpan name) - { - // [..] -> [.. obj] -> [.. obj v | .. obj err] -> [.. v | .. err] - Push(state); - var status = state.TryGetField(-1, name); - state.Remove(-2); - return status; - } - - /// - /// Pops the value on top and assigns it to the property of the object under protection - /// (obj[name] = v; the metatable's __newindex runs inside the call). Stack after success: the value - /// is gone; after failure: one error value in its place. - /// - /// The calling thread's state, with the value on top. - /// The property name, UTF-8. - /// The status. - /// The plugin is not enabled. - [RequiresPluginEnabled] - public LuaStatus TrySetProperty(LuaState state, ReadOnlySpan name) - { - // [.. v] -> [.. v obj] -> [.. obj v] -> [.. obj | .. obj err] -> [.. | .. err] - Push(state); - state.Insert(-2); - var status = state.TrySetField(-2, name); - RemoveObjectAfterSet(state, status); - return status; - } - - /// - /// Pushes element of the object under protection (obj[i], Cheat - /// Engine's own zero-based index: the found list's addresses, a string list's lines). Stack after success: the - /// value; after failure: one error value. - /// - /// The calling thread's state. - /// The index as Cheat Engine counts it, from 0; passed through unchanged. - /// The status. - /// The plugin is not enabled. - [RequiresPluginEnabled] - public LuaStatus TryGetIndex(LuaState state, int zeroBasedIndex) - { - Push(state); - var status = state.TryGetIndex(-1, zeroBasedIndex); - state.Remove(-2); - return status; - } - - /// - /// Pops the value on top and assigns it to element of the object under - /// protection (obj[i] = v, zero-based). Stack after success: the value is gone; after failure: one error - /// value in its place. - /// - /// The calling thread's state, with the value on top. - /// The index as Cheat Engine counts it, from 0; passed through unchanged. - /// The status. - /// The plugin is not enabled. - [RequiresPluginEnabled] - public LuaStatus TrySetIndex(LuaState state, int zeroBasedIndex) - { - Push(state); - state.Insert(-2); - var status = state.TrySetIndex(-2, zeroBasedIndex); - RemoveObjectAfterSet(state, status); - return status; - } - - /// - /// Pushes the method of the object as the instance-bound function Cheat Engine returns - /// for obj.name, ready to be called with the declared arguments only (no self). Stack after - /// success: the function; after failure: one error value, also when the member exists but is not a function. - /// - /// The calling thread's state. - /// The method name as Cheat Engine spells it, UTF-8. - /// - /// The status; with a message naming the member and the type found when it - /// is not a function. - /// - /// The plugin is not enabled. - /// - /// The straight-line shape of a generated method call is this member, the argument pushes, - /// , the result reads and a stack restore. - /// - [RequiresPluginEnabled] - public LuaStatus TryPushMethod(LuaState state, ReadOnlySpan name) - { - var status = TryGetProperty(state, name); - if (status.IsOk && !state.IsFunction(-1)) return ReplaceWithNotAFunctionError(state, name); - - return status; - } - - /// - /// Calls the method with the values on top of the stack - /// as its arguments, under protection. Stack after success: the arguments are replaced by the results - /// ( of them, or all with ); after failure: - /// the arguments are replaced by one error value. - /// - /// The calling thread's state, with the arguments on top. - /// The method name, UTF-8. - /// Number of arguments already pushed. - /// Number of results to keep, or . - /// The status of the lookup or of the call. - /// The plugin is not enabled. - /// is negative. - /// - /// Costs one lua_rotate more than followed by the pushes and the call, - /// because the function has to move below the arguments. - /// - [RequiresPluginEnabled] - public LuaStatus TryCallMethod(LuaState state, ReadOnlySpan name, int argumentCount, int resultCount) - { - ArgumentOutOfRangeException.ThrowIfNegative(argumentCount); - - // [.. a1..aN] -> [.. a1..aN f | .. a1..aN err] -> [.. f a1..aN | .. err] - var status = TryPushMethod(state, name); - if (argumentCount <= 0) return status.IsOk ? state.TryCall(argumentCount, resultCount) : status; - state.Insert(-argumentCount - 1); - if (!status.IsOk) state.Pop(argumentCount); - - return status.IsOk ? state.TryCall(argumentCount, resultCount) : status; - } - - /// - /// Calls destroy() on the object under protection. Stack after success: unchanged; after failure: one - /// error value. - /// - /// The calling thread's state. - /// The status. - /// - /// Internal on purpose: destruction is 's privilege, so that a borrowed handle cannot free - /// what Cheat Engine still uses. - /// - internal LuaStatus TryDestroy(LuaState state) - { - return TryCallMethod(state, DestroyMethodName, 0, 0); - } - - /// - /// The property read of a straight-line body: pushes the object, then obj[name] under protection, and - /// leaves the object on the stack below the result, for the body's single to - /// remove together with everything else. Stack after success: the object, then the value; after failure: the - /// object, then one error value. - /// - /// The calling thread's state. - /// The property name, UTF-8. - /// The status. - /// The plugin is not enabled. - /// - /// Generator-facing (the shape the typed members and generated wrappers use): the body records the top, calls - /// this, reads at -1 and restores the top, which is two C API calls fewer per access than - /// (no lua_rotate + lua_settop to - /// remove the object, since the final restore removes it anyway). Not for hand-written frame code, whose - /// protocol expects the object gone. Five C API calls: the push, rawgetp, pushvalue, - /// pushlstring, pcallk. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - [RequiresPluginEnabled] - public LuaStatus TryGetPropertyLeavingObject(LuaState state, ReadOnlySpan name) - { - // [..] -> [.. obj] -> [.. obj v | .. obj err] - Push(state); - return state.TryGetField(-1, name); - } - - /// - /// The method push of a straight-line body: plus the not-a-function - /// check of . Stack after success: the object, then the bound function, ready for the - /// argument pushes and ; after failure: the object, then one error value. - /// The object stays below for the body's single . - /// - /// The calling thread's state. - /// The method name, UTF-8. - /// - /// The status; with the same message as when - /// the member is not a function. - /// - /// The plugin is not enabled. - /// Generator-facing; see . Six C API calls on the success path. - [EditorBrowsable(EditorBrowsableState.Never)] - [RequiresPluginEnabled] - public LuaStatus TryPushMethodLeavingObject(LuaState state, ReadOnlySpan name) - { - var status = TryGetPropertyLeavingObject(state, name); - if (status.IsOk && !state.IsFunction(-1)) return ReplaceWithNotAFunctionError(state, name); - - return status; - } - - /// - /// Reads the property as a through - /// : acquires the state, reads under protection, restores the stack. Allocates - /// nothing beyond what allocates (a read - /// allocates the string; value marshallers allocate nothing). - /// - /// - /// The marshaller of the property's type (, - /// , , , ...). - /// - /// - /// The property's managed type; never a span into Lua's memory, because the stack is restored - /// before the method returns. - /// - /// The property name, UTF-8. - /// The value, or on failure. - /// - /// when the access raised or the value is not of the expected kind (a nil - /// property reads as ). - /// - /// The plugin is not enabled. - /// - /// Nine transitions with a one-call marshaller: the provider, gettop, the five of - /// , the read, settop. - /// - [RequiresPluginEnabled] - public bool TryGetProperty(ReadOnlySpan name, [MaybeNullWhen(false)] out TValue value) - where TMarshaller : struct, ILuaMarshaller - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - value = default!; - try - { - // [..] -> [.. obj v]: the object stays under the value; the one SetTop below removes both. - if (!TryGetPropertyLeavingObject(state, name).IsOk) return false; - - return TMarshaller.TryRead(state, -1, out value); - } - finally - { - // A consumer-supplied marshaller is allowed to throw. It is never allowed to strand the object, result, - // or any partial value it pushed on this thread's CE Lua stack. - state.SetTop(top); - } - } - - /// - /// Writes the property from a through - /// : acquires the state, pushes, assigns under protection, restores the stack. - /// Allocates nothing on the managed side beyond what allocates (the shipped - /// marshallers allocate nothing on push; a push transcodes through a stack buffer). - /// - /// The marshaller of the property's type. - /// - /// The property's managed type; a of UTF-8 bytes is allowed ( - /// ). - /// - /// The property name, UTF-8. - /// The value to assign. - /// when the assignment raised. - /// The plugin is not enabled. - /// - /// Ten transitions with a one-call marshaller: the provider, gettop, the push of the object, the push of - /// the value, the five of , settop. The object is pushed before the value so - /// that no lua_rotate is needed to order them. - /// - [RequiresPluginEnabled] - public bool TrySetProperty(ReadOnlySpan name, TValue value) - where TMarshaller : struct, ILuaMarshaller - where TValue : allows ref struct - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - // [..] -> [.. obj] -> [.. obj v] -> [.. obj | .. obj err] - Push(state); - TMarshaller.Push(state, value); - return state.TrySetField(-2, name).IsOk; - } - finally - { - // See TryGetProperty: push implementations are consumer code too. - state.SetTop(top); - } - } - - /// - /// Calls the method with no arguments and discards its results: acquires the state, - /// calls under protection, restores the stack. Allocates nothing. - /// - /// The method name, UTF-8. - /// when the member is not a function or the call raised. - /// The plugin is not enabled. - /// - /// Ten transitions: the provider, gettop, the six of , - /// pcallk, settop. - /// - [RequiresPluginEnabled] - public bool TryCallMethod(ReadOnlySpan name) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - // [..] -> [.. obj f] -> [.. obj | .. obj err] - return TryPushMethodLeavingObject(state, name).IsOk && state.TryCall(0, 0).IsOk; - } - finally - { - state.SetTop(top); - } - } - - /// - /// Calls the method with no arguments and reads its first result as a - /// through : acquires the state, calls under - /// protection, restores the stack. Allocates nothing beyond what allocates (a - /// read allocates the string; value marshallers allocate nothing). - /// - /// The marshaller of the result's type. - /// The result's managed type; never a span into Lua's memory. - /// The method name, UTF-8. - /// The result, or on failure. - /// - /// when the member is not a function, the call raised, or the result is not of the - /// expected kind (Cheat Engine's nil for "failed"). - /// - /// The plugin is not enabled. - /// - /// Eleven transitions with a one-call marshaller: the ten of - /// plus the read. - /// - [RequiresPluginEnabled] - public bool TryCallMethod(ReadOnlySpan name, [MaybeNullWhen(false)] out TResult result) - where TMarshaller : struct, ILuaMarshaller - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - result = default!; - try - { - // [..] -> [.. obj f] -> [.. obj r | .. obj err] - if (!TryPushMethodLeavingObject(state, name).IsOk || !state.TryCall(0, 1).IsOk) return false; - - return TMarshaller.TryRead(state, -1, out result); - } - finally - { - // A result marshaller can run arbitrary managed code. The stack postcondition does not rely on it returning. - state.SetTop(top); - } - } - - // After a protected set the object sits under nothing (success) or under the error value (failure). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void RemoveObjectAfterSet(LuaState state, LuaStatus status) - { - if (status.IsOk) - state.Pop(1); - else - state.Remove(-2); - } - - // Replaces the non-function member on top with an error message, in a stack buffer: "'name' is a , not a method". - [MethodImpl(MethodImplOptions.NoInlining)] - [SkipLocalsInit] // Every byte of the buffer that is pushed is written first. - private static LuaStatus ReplaceWithNotAFunctionError(LuaState state, ReadOnlySpan name) - { - const int maxNameBytes = 64; - var typeName = state.TypeName(-1); - Span message = stackalloc byte[maxNameBytes + 64]; - var length = 0; - Append(message, ref length, "'"u8); - Append(message, ref length, name.Length <= maxNameBytes ? name : name[..maxNameBytes]); - Append(message, ref length, "' is a "u8); - Append(message, ref length, typeName); - Append(message, ref length, ", not a method"u8); - - state.Pop(1); - state.PushString(message[..length]); - return LuaStatus.RuntimeError; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void Append(Span buffer, ref int length, ReadOnlySpan text) - { - text.CopyTo(buffer[length..]); - length += text.Length; - } + /// The name of the method every Cheat Engine object has to free itself. + private static ReadOnlySpan DestroyMethodName => "destroy"u8; + + /// Wraps a native object pointer. + /// The pointer as Cheat Engine's Lua API knows it; zero gives . + /// A pure value operation: nothing is checked, nothing is called. + public CEObject(nint value) + { + Value = value; + } + + /// Gets the handle that names no object. + public static CEObject Null => default; + + /// Gets the native object pointer: the identity of the object, never dereferenced by managed code. + public nint Value + { + get; + } + + /// Gets a value indicating whether the handle names no object. + public bool IsNull => Value == 0; + + /// + public CEObject Handle => this; + + /// + public static CEObject FromHandle(CEObject handle) + { + return handle; + } + + /// Compares two handles for identity of the native object. + /// First handle. + /// Second handle. + public static bool operator ==(CEObject left, CEObject right) + { + return left.Value == right.Value; + } + + /// Compares two handles for identity of the native object. + /// First handle. + /// Second handle. + public static bool operator !=(CEObject left, CEObject right) + { + return left.Value != right.Value; + } + + /// + public bool Equals(CEObject other) + { + return Value == other.Value; + } + + /// + public override bool Equals(object? obj) + { + return obj is CEObject other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + /// Formats the pointer, for diagnostics. + /// CEObject@0x..., or CEObject(null). + public override string ToString() + { + return IsNull ? "CEObject(null)" : "CEObject@0x" + Value.ToString("X", CultureInfo.InvariantCulture); + } + + /// + /// Pushes the object's userdata through the host's pusher (), which is + /// the only way a Cheat Engine object gets onto the Lua stack. A handle pushes nil. + /// + /// The calling thread's state. + /// The plugin is not enabled, or the host binding has no pusher. + /// + /// What the host allocates for the userdata (and whether it caches one per object) is the host's business. + /// + [RequiresPluginEnabled] + [LuaStackEffect(1)] + public void Push(LuaState state) + { + if (IsNull) + { + state.PushNil(); + return; + } + + LuaRuntime.PushHostObject(state, Value); + } + + /// The form of . + /// The calling thread's state. + /// The handle to push; pushes nil. + [RequiresPluginEnabled] + [LuaStackEffect(1)] + public static void Push(LuaState state, CEObject value) + { + value.Push(state); + } + + /// + /// Reads the handle of the Cheat Engine object at : the value must be a full userdata + /// whose block is at least pointer-sized and whose first pointer-sized field is not null. The stack is not + /// modified and nothing is allocated. + /// + /// The state to read from. + /// An acceptable index. + /// The handle, or when the value is not a host object. + /// when holds a handle. + /// + /// + /// This is the one place that decodes the userdata layout, and the layout is an assumption: Cheat + /// Engine's LuaPushClassInstance creates a full userdata and stores the object pointer in its first + /// pointer-sized field. Three checks stand in for the knowledge this SDK does not have: the type tag must + /// be (a light userdata is a bare pointer with no block to read; a table or + /// a number is not an object), the block must be at least a pointer long (lua_rawlen, so the read + /// cannot overrun a smaller block), and the field must not be zero. A userdata of some other library that + /// happens to start with a non-null pointer is indistinguishable from an object here; the metatable is not + /// inspected because Cheat Engine's metatables carry no known marker. The layout can be cross-checked on a + /// live Cheat Engine against its own userDataToInteger(obj), which returns the same pointer. + /// + /// Three C API calls (type, block, length) and one memory read; never raises. + /// + [LuaStackEffect(0)] + public static unsafe bool TryRead(LuaState state, int index, out CEObject value) + { + if (state.TypeOf(index) == LuaType.Userdata) + { + IntPtr block = state.ToUserdata(index); + if (block != 0 && state.RawLength(index) >= (nuint) sizeof(nint)) + { + IntPtr pointer = *(nint*) block; + if (pointer != 0) + { + value = new CEObject(pointer); + return true; + } + } + } + + value = default; + return false; + } + + /// + /// Pushes the property of the object under protection (obj[name]; the metatable's + /// __index runs inside the call). Stack after success: the value (nil for a member the object does + /// not have, if the host does not raise for it); after failure: one error value. + /// + /// The calling thread's state. + /// The property name as Cheat Engine spells it, UTF-8; a "..."u8 literal. + /// The status. + /// The plugin is not enabled. + /// + /// One push, one protected field access, one lua_rotate and one lua_settop to remove the object + /// again. + /// + [RequiresPluginEnabled] + public LuaStatus TryGetProperty(LuaState state, ReadOnlySpan name) + { + // [..] -> [.. obj] -> [.. obj v | .. obj err] -> [.. v | .. err] + Push(state); + LuaStatus status = state.TryGetField(-1, name); + state.Remove(-2); + return status; + } + + /// + /// Pops the value on top and assigns it to the property of the object under protection + /// (obj[name] = v; the metatable's __newindex runs inside the call). Stack after success: the value + /// is gone; after failure: one error value in its place. + /// + /// The calling thread's state, with the value on top. + /// The property name, UTF-8. + /// The status. + /// The plugin is not enabled. + [RequiresPluginEnabled] + public LuaStatus TrySetProperty(LuaState state, ReadOnlySpan name) + { + // [.. v] -> [.. v obj] -> [.. obj v] -> [.. obj | .. obj err] -> [.. | .. err] + Push(state); + state.Insert(-2); + LuaStatus status = state.TrySetField(-2, name); + RemoveObjectAfterSet(state, status); + return status; + } + + /// + /// Pushes element of the object under protection (obj[i], Cheat + /// Engine's own zero-based index: the found list's addresses, a string list's lines). Stack after success: the + /// value; after failure: one error value. + /// + /// The calling thread's state. + /// The index as Cheat Engine counts it, from 0; passed through unchanged. + /// The status. + /// The plugin is not enabled. + [RequiresPluginEnabled] + public LuaStatus TryGetIndex(LuaState state, int zeroBasedIndex) + { + Push(state); + LuaStatus status = state.TryGetIndex(-1, zeroBasedIndex); + state.Remove(-2); + return status; + } + + /// + /// Pops the value on top and assigns it to element of the object under + /// protection (obj[i] = v, zero-based). Stack after success: the value is gone; after failure: one error + /// value in its place. + /// + /// The calling thread's state, with the value on top. + /// The index as Cheat Engine counts it, from 0; passed through unchanged. + /// The status. + /// The plugin is not enabled. + [RequiresPluginEnabled] + public LuaStatus TrySetIndex(LuaState state, int zeroBasedIndex) + { + Push(state); + state.Insert(-2); + LuaStatus status = state.TrySetIndex(-2, zeroBasedIndex); + RemoveObjectAfterSet(state, status); + return status; + } + + /// + /// Pushes the method of the object as the instance-bound function Cheat Engine returns + /// for obj.name, ready to be called with the declared arguments only (no self). Stack after + /// success: the function; after failure: one error value, also when the member exists but is not a function. + /// + /// The calling thread's state. + /// The method name as Cheat Engine spells it, UTF-8. + /// + /// The status; with a message naming the member and the type found when it + /// is not a function. + /// + /// The plugin is not enabled. + /// + /// The straight-line shape of a generated method call is this member, the argument pushes, + /// , the result reads and a stack restore. + /// + [RequiresPluginEnabled] + public LuaStatus TryPushMethod(LuaState state, ReadOnlySpan name) + { + LuaStatus status = TryGetProperty(state, name); + if (status.IsOk && !state.IsFunction(-1)) + { + return ReplaceWithNotAFunctionError(state, name); + } + + return status; + } + + /// + /// Calls the method with the values on top of the stack + /// as its arguments, under protection. Stack after success: the arguments are replaced by the results + /// ( of them, or all with ); after failure: + /// the arguments are replaced by one error value. + /// + /// The calling thread's state, with the arguments on top. + /// The method name, UTF-8. + /// Number of arguments already pushed. + /// Number of results to keep, or . + /// The status of the lookup or of the call. + /// The plugin is not enabled. + /// is negative. + /// + /// Costs one lua_rotate more than followed by the pushes and the call, + /// because the function has to move below the arguments. + /// + [RequiresPluginEnabled] + public LuaStatus TryCallMethod(LuaState state, ReadOnlySpan name, int argumentCount, int resultCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(argumentCount); + + // [.. a1..aN] -> [.. a1..aN f | .. a1..aN err] -> [.. f a1..aN | .. err] + LuaStatus status = TryPushMethod(state, name); + if (argumentCount <= 0) + { + return status.IsOk ? state.TryCall(argumentCount, resultCount) : status; + } + + state.Insert(-argumentCount - 1); + if (!status.IsOk) + { + state.Pop(argumentCount); + } + + return status.IsOk ? state.TryCall(argumentCount, resultCount) : status; + } + + /// + /// Calls destroy() on the object under protection. Stack after success: unchanged; after failure: one + /// error value. + /// + /// The calling thread's state. + /// The status. + /// + /// Internal on purpose: destruction is 's privilege, so that a borrowed handle cannot free + /// what Cheat Engine still uses. + /// + internal LuaStatus TryDestroy(LuaState state) + { + return TryCallMethod(state, DestroyMethodName, 0, 0); + } + + /// + /// The property read of a straight-line body: pushes the object, then obj[name] under protection, and + /// leaves the object on the stack below the result, for the body's single to + /// remove together with everything else. Stack after success: the object, then the value; after failure: the + /// object, then one error value. + /// + /// The calling thread's state. + /// The property name, UTF-8. + /// The status. + /// The plugin is not enabled. + /// + /// Generator-facing (the shape the typed members and generated wrappers use): the body records the top, calls + /// this, reads at -1 and restores the top, which is two C API calls fewer per access than + /// (no lua_rotate + lua_settop to + /// remove the object, since the final restore removes it anyway). Not for hand-written frame code, whose + /// protocol expects the object gone. Five C API calls: the push, rawgetp, pushvalue, + /// pushlstring, pcallk. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [RequiresPluginEnabled] + public LuaStatus TryGetPropertyLeavingObject(LuaState state, ReadOnlySpan name) + { + // [..] -> [.. obj] -> [.. obj v | .. obj err] + Push(state); + return state.TryGetField(-1, name); + } + + /// + /// The method push of a straight-line body: plus the not-a-function + /// check of . Stack after success: the object, then the bound function, ready for the + /// argument pushes and ; after failure: the object, then one error value. + /// The object stays below for the body's single . + /// + /// The calling thread's state. + /// The method name, UTF-8. + /// + /// The status; with the same message as when + /// the member is not a function. + /// + /// The plugin is not enabled. + /// Generator-facing; see . Six C API calls on the success path. + [EditorBrowsable(EditorBrowsableState.Never)] + [RequiresPluginEnabled] + public LuaStatus TryPushMethodLeavingObject(LuaState state, ReadOnlySpan name) + { + LuaStatus status = TryGetPropertyLeavingObject(state, name); + if (status.IsOk && !state.IsFunction(-1)) + { + return ReplaceWithNotAFunctionError(state, name); + } + + return status; + } + + /// + /// Reads the property as a through + /// : acquires the state, reads under protection, restores the stack. Allocates + /// nothing beyond what allocates (a read + /// allocates the string; value marshallers allocate nothing). + /// + /// + /// The marshaller of the property's type (, + /// , , , ...). + /// + /// + /// The property's managed type; never a span into Lua's memory, because the stack is restored + /// before the method returns. + /// + /// The property name, UTF-8. + /// The value, or on failure. + /// + /// when the access raised or the value is not of the expected kind (a nil + /// property reads as ). + /// + /// The plugin is not enabled. + /// + /// Nine transitions with a one-call marshaller: the provider, gettop, the five of + /// , the read, settop. + /// + [RequiresPluginEnabled] + public bool TryGetProperty(ReadOnlySpan name, [MaybeNullWhen(false)] out TValue value) + where TMarshaller : struct, ILuaMarshaller + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + value = default!; + try + { + // [..] -> [.. obj v]: the object stays under the value; the one SetTop below removes both. + if (!TryGetPropertyLeavingObject(state, name).IsOk) + { + return false; + } + + return TMarshaller.TryRead(state, -1, out value); + } + finally + { + // A consumer-supplied marshaller is allowed to throw. It is never allowed to strand the object, result, + // or any partial value it pushed on this thread's CE Lua stack. + state.SetTop(top); + } + } + + /// + /// Writes the property from a through + /// : acquires the state, pushes, assigns under protection, restores the stack. + /// Allocates nothing on the managed side beyond what allocates (the shipped + /// marshallers allocate nothing on push; a push transcodes through a stack buffer). + /// + /// The marshaller of the property's type. + /// + /// The property's managed type; a of UTF-8 bytes is allowed ( + /// ). + /// + /// The property name, UTF-8. + /// The value to assign. + /// when the assignment raised. + /// The plugin is not enabled. + /// + /// Ten transitions with a one-call marshaller: the provider, gettop, the push of the object, the push of + /// the value, the five of , settop. The object is pushed before the value so + /// that no lua_rotate is needed to order them. + /// + [RequiresPluginEnabled] + public bool TrySetProperty(ReadOnlySpan name, TValue value) + where TMarshaller : struct, ILuaMarshaller + where TValue : allows ref struct + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + // [..] -> [.. obj] -> [.. obj v] -> [.. obj | .. obj err] + Push(state); + TMarshaller.Push(state, value); + return state.TrySetField(-2, name).IsOk; + } + finally + { + // See TryGetProperty: push implementations are consumer code too. + state.SetTop(top); + } + } + + /// + /// Calls the method with no arguments and discards its results: acquires the state, + /// calls under protection, restores the stack. Allocates nothing. + /// + /// The method name, UTF-8. + /// when the member is not a function or the call raised. + /// The plugin is not enabled. + /// + /// Ten transitions: the provider, gettop, the six of , + /// pcallk, settop. + /// + [RequiresPluginEnabled] + public bool TryCallMethod(ReadOnlySpan name) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + // [..] -> [.. obj f] -> [.. obj | .. obj err] + return TryPushMethodLeavingObject(state, name).IsOk && state.TryCall(0, 0).IsOk; + } + finally + { + state.SetTop(top); + } + } + + /// + /// Calls the method with no arguments and reads its first result as a + /// through : acquires the state, calls under + /// protection, restores the stack. Allocates nothing beyond what allocates (a + /// read allocates the string; value marshallers allocate nothing). + /// + /// The marshaller of the result's type. + /// The result's managed type; never a span into Lua's memory. + /// The method name, UTF-8. + /// The result, or on failure. + /// + /// when the member is not a function, the call raised, or the result is not of the + /// expected kind (Cheat Engine's nil for "failed"). + /// + /// The plugin is not enabled. + /// + /// Eleven transitions with a one-call marshaller: the ten of + /// plus the read. + /// + [RequiresPluginEnabled] + public bool TryCallMethod(ReadOnlySpan name, [MaybeNullWhen(false)] out TResult result) + where TMarshaller : struct, ILuaMarshaller + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + result = default!; + try + { + // [..] -> [.. obj f] -> [.. obj r | .. obj err] + if (!TryPushMethodLeavingObject(state, name).IsOk || !state.TryCall(0, 1).IsOk) + { + return false; + } + + return TMarshaller.TryRead(state, -1, out result); + } + finally + { + // A result marshaller can run arbitrary managed code. The stack postcondition does not rely on it returning. + state.SetTop(top); + } + } + + // After a protected set the object sits under nothing (success) or under the error value (failure). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void RemoveObjectAfterSet(LuaState state, LuaStatus status) + { + if (status.IsOk) + { + state.Pop(1); + } + else + { + state.Remove(-2); + } + } + + // Replaces the non-function member on top with an error message, in a stack buffer: "'name' is a , not a method". + [MethodImpl(MethodImplOptions.NoInlining)] + [SkipLocalsInit] // Every byte of the buffer that is pushed is written first. + private static LuaStatus ReplaceWithNotAFunctionError(LuaState state, ReadOnlySpan name) + { + const int maxNameBytes = 64; + ReadOnlySpan typeName = state.TypeName(-1); + Span message = stackalloc byte[maxNameBytes + 64]; + int length = 0; + Append(message, ref length, "'"u8); + Append(message, ref length, name.Length <= maxNameBytes ? name : name[..maxNameBytes]); + Append(message, ref length, "' is a "u8); + Append(message, ref length, typeName); + Append(message, ref length, ", not a method"u8); + + state.Pop(1); + state.PushString(message[..length]); + return LuaStatus.RuntimeError; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Append(Span buffer, ref int length, ReadOnlySpan text) + { + text.CopyTo(buffer[length..]); + length += text.Length; + } } diff --git a/libs/CheatEngine.SDK.Engine/Objects/ICEObject.cs b/libs/CheatEngine.SDK.Engine/Objects/ICEObject.cs index 1fa4bf0e..c42f6a95 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/ICEObject.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/ICEObject.cs @@ -21,13 +21,16 @@ namespace CheatEngine.SDK.Engine.Objects; /// /// public interface ICEObject - where TSelf : struct, ICEObject + where TSelf : struct, ICEObject { - /// Gets the untyped handle: the native object pointer with the property and method primitives. - public CEObject Handle { get; } + /// Gets the untyped handle: the native object pointer with the property and method primitives. + public CEObject Handle + { + get; + } - /// Wraps an untyped handle as without any check. - /// The handle; may be , which gives the default value. - /// The typed handle. - public static abstract TSelf FromHandle(CEObject handle); + /// Wraps an untyped handle as without any check. + /// The handle; may be , which gives the default value. + /// The typed handle. + public static abstract TSelf FromHandle(CEObject handle); } diff --git a/libs/CheatEngine.SDK.Engine/Objects/Owned.cs b/libs/CheatEngine.SDK.Engine/Objects/Owned.cs index ec7b8d57..283b702c 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/Owned.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/Owned.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; @@ -60,183 +61,205 @@ namespace CheatEngine.SDK.Engine.Objects; /// /// public sealed class Owned : IDisposable - where T : struct, ICEObject + where T : struct, ICEObject { - private T _value; - - /// Takes ownership of from an SDK factory with an established ownership contract. - /// - /// A handle to an object nobody else owns, obtained from a documented SDK creation binding or from - /// . - /// - /// is a null handle. - internal Owned(T value) - { - if (value.Handle.IsNull) throw new ArgumentException("A null handle cannot be owned.", nameof(value)); - - _value = value; - } - - /// - /// Gets the typed handle, for calling the object's members. A borrowed view: do not keep it beyond the wrapper's - /// life. - /// - /// The wrapper was disposed, transferred or abandoned. - public T Value - { - get - { - if (IsDisposed) ThrowDisposed(); - - return _value; - } - } - - /// Gets the untyped handle of the owned object. - /// The wrapper was disposed, transferred or abandoned. - public CEObject Handle => Value.Handle; - - /// - /// Gets a value indicating whether the wrapper no longer owns anything, after , - /// , or . - /// - public bool IsDisposed => _value.Handle.IsNull; - - /// - /// Destroys the object through on the ambient state, discarding a protected Lua - /// failure after an invocation began. It is idempotent. If the runtime is detached, the current thread cannot - /// obtain a state, or the binding has no host-object pusher, it throws and retains ownership so the caller can - /// retry or explicitly abandon the object; it never reports a no-op as a completed destruction. - /// - public void Dispose() - { - if (IsDisposed) return; - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - _ = TryDestroyCore(state); - } - - /// - /// The handle as a borrowed value, for passing the object to an API that does not take ownership. Same as - /// , named for the intent. - /// - /// The typed handle. - /// The wrapper was disposed, transferred or abandoned. - public T ToBorrowed() - { - return Value; - } - - /// - /// Moves this ownership capability into a new wrapper without calling CE. The source wrapper becomes empty. - /// - /// The new sole owner. - /// The wrapper was disposed, transferred or abandoned. - public Owned Transfer() - { - var destination = PrepareTransfer(); - CompleteTransfer(destination); - return destination; - } - - // A multi-owner handoff prepares every destination before any source is made empty. These members are internal so - // that a consumer cannot ever observe the brief, private preparation state as a second ownership capability. - internal Owned PrepareTransfer() - { - var value = Value; - return new Owned(value); - } - - internal void CompleteTransfer(Owned destination) - { - ArgumentNullException.ThrowIfNull(destination); - if (destination.Value.Handle != Value.Handle) - throw new ArgumentException("The destination does not represent this owned object.", nameof(destination)); - - _value = default; - } - - /// - /// Explicitly stops managed cleanup without calling CE and returns a borrowed handle. This is abandonment, not - /// an ownership transfer: the returned value cannot be wrapped in by consumer code. - /// - /// The still-live object as a borrowed handle. - /// The wrapper was already empty. - /// - /// Use only when ownership has moved into a CE operation whose contract is already documented, or when an - /// unavoidable shutdown path has been recorded. Prefer for a managed hand-off. - /// - public T Abandon() - { - var value = Value; - _value = default; - return value; - } - - /// - /// Destroys the object now, through a protected destroy() call on , and marks the - /// wrapper empty after a protected call began, whatever its status. Stack after success: unchanged; after - /// failure: one error value, for the caller's frame to read or discard. Already empty: returns - /// and pushes nothing. - /// - /// The calling thread's state. - /// The status of the destroy call. - /// - /// The plugin is not enabled, the caller has no Lua state, or the attached host binding has no object pusher - /// (an embedding without LuaPushClassInstance). Nothing was called, the stack is untouched, and this - /// wrapper retains ownership for an explicit retry or . - /// - [RequiresPluginEnabled] - public LuaStatus TryDestroy(LuaState state) - { - if (IsDisposed) return LuaStatus.Ok; - - using var operation = LuaRuntime.AcquireOperation(); - if (operation.State != state) - throw new InvalidOperationException( - "The supplied Lua state is not the state currently assigned to this thread by the attached host."); - - return TryDestroyCore(operation.State); - } - - private LuaStatus TryDestroyCore(LuaState state) - { - EnsureDestructionCanStart(state); - var handle = _value.Handle; - var status = handle.TryDestroy(state); - _value = default; - return status; - } - - /// Owned(CEObject@0x...), or Owned(disposed). - public override string ToString() - { - return IsDisposed ? "Owned(disposed)" : "Owned(" + _value.Handle + ")"; - } - - private static void EnsureDestructionCanStart(LuaState state) - { - var binding = LuaRuntime.CurrentBinding; - if (!binding.IsValid) - throw new InvalidOperationException( - "The plugin is not enabled, so the owned Cheat Engine object cannot be destroyed yet."); - - if (binding.HostObjectPusher == 0) - throw new InvalidOperationException( - "The attached host binding has no host-object pusher, so the owned Cheat Engine object cannot be destroyed."); - - if (state.IsNull) - throw new InvalidOperationException( - "The current thread has no Lua state, so the owned Cheat Engine object cannot be destroyed."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowDisposed() - { - throw new ObjectDisposedException(typeof(Owned).Name, - "The wrapper no longer owns an object: it was disposed, transferred or abandoned."); - } + private T _value; + + /// Takes ownership of from an SDK factory with an established ownership contract. + /// + /// A handle to an object nobody else owns, obtained from a documented SDK creation binding or from + /// . + /// + /// is a null handle. + internal Owned(T value) + { + if (value.Handle.IsNull) + { + throw new ArgumentException("A null handle cannot be owned.", nameof(value)); + } + + _value = value; + } + + /// + /// Gets the typed handle, for calling the object's members. A borrowed view: do not keep it beyond the wrapper's + /// life. + /// + /// The wrapper was disposed, transferred or abandoned. + public T Value + { + get + { + if (IsDisposed) + { + ThrowDisposed(); + } + + return _value; + } + } + + /// Gets the untyped handle of the owned object. + /// The wrapper was disposed, transferred or abandoned. + public CEObject Handle => Value.Handle; + + /// + /// Gets a value indicating whether the wrapper no longer owns anything, after , + /// , or . + /// + public bool IsDisposed => _value.Handle.IsNull; + + /// + /// Destroys the object through on the ambient state, discarding a protected Lua + /// failure after an invocation began. It is idempotent. If the runtime is detached, the current thread cannot + /// obtain a state, or the binding has no host-object pusher, it throws and retains ownership so the caller can + /// retry or explicitly abandon the object; it never reports a no-op as a completed destruction. + /// + public void Dispose() + { + if (IsDisposed) + { + return; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + _ = TryDestroyCore(state); + } + + /// + /// The handle as a borrowed value, for passing the object to an API that does not take ownership. Same as + /// , named for the intent. + /// + /// The typed handle. + /// The wrapper was disposed, transferred or abandoned. + public T ToBorrowed() + { + return Value; + } + + /// + /// Moves this ownership capability into a new wrapper without calling CE. The source wrapper becomes empty. + /// + /// The new sole owner. + /// The wrapper was disposed, transferred or abandoned. + public Owned Transfer() + { + Owned destination = PrepareTransfer(); + CompleteTransfer(destination); + return destination; + } + + // A multi-owner handoff prepares every destination before any source is made empty. These members are internal so + // that a consumer cannot ever observe the brief, private preparation state as a second ownership capability. + internal Owned PrepareTransfer() + { + T value = Value; + return new Owned(value); + } + + internal void CompleteTransfer(Owned destination) + { + ArgumentNullException.ThrowIfNull(destination); + if (destination.Value.Handle != Value.Handle) + { + throw new ArgumentException("The destination does not represent this owned object.", nameof(destination)); + } + + _value = default; + } + + /// + /// Explicitly stops managed cleanup without calling CE and returns a borrowed handle. This is abandonment, not + /// an ownership transfer: the returned value cannot be wrapped in by consumer code. + /// + /// The still-live object as a borrowed handle. + /// The wrapper was already empty. + /// + /// Use only when ownership has moved into a CE operation whose contract is already documented, or when an + /// unavoidable shutdown path has been recorded. Prefer for a managed hand-off. + /// + public T Abandon() + { + T value = Value; + _value = default; + return value; + } + + /// + /// Destroys the object now, through a protected destroy() call on , and marks the + /// wrapper empty after a protected call began, whatever its status. Stack after success: unchanged; after + /// failure: one error value, for the caller's frame to read or discard. Already empty: returns + /// and pushes nothing. + /// + /// The calling thread's state. + /// The status of the destroy call. + /// + /// The plugin is not enabled, the caller has no Lua state, or the attached host binding has no object pusher + /// (an embedding without LuaPushClassInstance). Nothing was called, the stack is untouched, and this + /// wrapper retains ownership for an explicit retry or . + /// + [RequiresPluginEnabled] + public LuaStatus TryDestroy(LuaState state) + { + if (IsDisposed) + { + return LuaStatus.Ok; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + if (operation.State != state) + { + throw new InvalidOperationException( + "The supplied Lua state is not the state currently assigned to this thread by the attached host."); + } + + return TryDestroyCore(operation.State); + } + + private LuaStatus TryDestroyCore(LuaState state) + { + EnsureDestructionCanStart(state); + CEObject handle = _value.Handle; + LuaStatus status = handle.TryDestroy(state); + _value = default; + return status; + } + + /// Owned(CEObject@0x...), or Owned(disposed). + public override string ToString() + { + return IsDisposed ? "Owned(disposed)" : "Owned(" + _value.Handle + ")"; + } + + private static void EnsureDestructionCanStart(LuaState state) + { + LuaHostBinding binding = LuaRuntime.CurrentBinding; + if (!binding.IsValid) + { + throw new InvalidOperationException( + "The plugin is not enabled, so the owned Cheat Engine object cannot be destroyed yet."); + } + + if (binding.HostObjectPusher == 0) + { + throw new InvalidOperationException( + "The attached host binding has no host-object pusher, so the owned Cheat Engine object cannot be destroyed."); + } + + if (state.IsNull) + { + throw new InvalidOperationException( + "The current thread has no Lua state, so the owned Cheat Engine object cannot be destroyed."); + } + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowDisposed() + { + throw new ObjectDisposedException(typeof(Owned).Name, + "The wrapper no longer owns an object: it was disposed, transferred or abandoned."); + } } diff --git a/libs/CheatEngine.SDK.Engine/Objects/StringList.cs b/libs/CheatEngine.SDK.Engine/Objects/StringList.cs index 63e17a26..7351b1cf 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/StringList.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/StringList.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Lua.Marshalling; @@ -34,322 +35,327 @@ namespace CheatEngine.SDK.Engine.Objects; /// public readonly struct StringList : IEquatable, ICEObject, ILuaMarshaller { - private static ReadOnlySpan CountPropertyName => "Count"u8; - - private static ReadOnlySpan SortedPropertyName => "Sorted"u8; - - private static ReadOnlySpan DuplicatesPropertyName => "Duplicates"u8; - - private static ReadOnlySpan CaseSensitivePropertyName => "CaseSensitive"u8; - - private static ReadOnlySpan ClearMethodName => "clear"u8; - - private static ReadOnlySpan AddMethodName => "add"u8; - - private static ReadOnlySpan DeleteMethodName => "delete"u8; - - private static ReadOnlySpan GetTextMethodName => "getText"u8; - - private static ReadOnlySpan SetTextMethodName => "setText"u8; - - private static ReadOnlySpan IndexOfMethodName => "indexOf"u8; - - /// Wraps an untyped Cheat Engine object handle as a StringList handle. - /// The native host-object handle; its runtime class is not checked. - /// - /// This is a pure value operation. Only a factory or API whose CE contract says it returned a StringList should - /// call it. - /// - public StringList(CEObject handle) - { - Handle = handle; - } - - /// Gets the handle that names no StringList. - public static StringList Null => default; - - /// - public CEObject Handle { get; } - - /// Gets a value indicating whether this handle names no object. - public bool IsNull => Handle.IsNull; - - /// - public static StringList FromHandle(CEObject handle) - { - return new StringList(handle); - } - - /// Compares two StringList handles by native-object identity. - /// The first handle. - /// The second handle. - public static bool operator ==(StringList left, StringList right) - { - return left.Handle == right.Handle; - } - - /// Compares two StringList handles by native-object identity. - /// The first handle. - /// The second handle. - public static bool operator !=(StringList left, StringList right) - { - return left.Handle != right.Handle; - } - - /// - public bool Equals(StringList other) - { - return Handle == other.Handle; - } - - /// - public override bool Equals(object? obj) - { - return obj is StringList other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Handle.GetHashCode(); - } - - /// Formats the native-object identity for diagnostics. - /// The underlying representation. - public override string ToString() - { - return Handle.ToString(); - } - - /// - [RequiresPluginEnabled] - public static void Push(LuaState state, StringList value) - { - value.Handle.Push(state); - } - - /// - public static bool TryRead(LuaState state, int index, out StringList value) - { - if (CEObject.TryRead(state, index, out var handle)) - { - value = FromHandle(handle); - return true; - } - - value = default; - return false; - } - - /// Reads the number of strings currently in the list. - /// The count on success; zero on failure. - /// when CE returned an integer count. - [RequiresPluginEnabled] - public bool TryGetCount(out int count) - { - return Handle.TryGetProperty(CountPropertyName, out count); - } - - /// Reads a string at a zero-based StringList index. - /// The StringList index, starting at zero. - /// The string on success; on failure. - /// when the index produced a Lua string. - /// is negative. - [RequiresPluginEnabled] - public bool TryGetItem(int zeroBasedIndex, [MaybeNullWhen(false)] out string value) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - if (!Handle.TryGetIndex(state, zeroBasedIndex).IsOk) - { - value = default!; - return false; - } - - return StringMarshaller.TryRead(state, -1, out value); - } - - /// Replaces the string at a zero-based StringList index. - /// The StringList index, starting at zero. - /// The replacement text. - /// when CE accepted the assignment. - /// is negative. - /// is . - [RequiresPluginEnabled] - public bool TrySetItem(int zeroBasedIndex, string value) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - ArgumentNullException.ThrowIfNull(value); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - StringMarshaller.Push(state, value); - return Handle.TrySetIndex(state, zeroBasedIndex).IsOk; - } - - /// Removes every string from this list. - /// when CE completed clear(). - [RequiresPluginEnabled] - public bool TryClear() - { - return Handle.TryCallMethod(ClearMethodName); - } - - /// Adds a string and returns its zero-based StringList index. - /// The string to add. - /// The index returned by CE, or zero on failure. - /// when CE returned an integer index. - /// is . - [RequiresPluginEnabled] - public bool TryAdd(string value, out int zeroBasedIndex) - { - ArgumentNullException.ThrowIfNull(value); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - StringMarshaller.Push(state, value); - if (!Handle.TryCallMethod(state, AddMethodName, 1, 1).IsOk) - { - zeroBasedIndex = 0; - return false; - } - - return Int32Marshaller.TryRead(state, -1, out zeroBasedIndex); - } - - /// Deletes the string at a zero-based StringList index. - /// The StringList index, starting at zero. - /// when CE completed delete(index). - /// is negative. - [RequiresPluginEnabled] - public bool TryDelete(int zeroBasedIndex) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - Int32Marshaller.Push(state, zeroBasedIndex); - return Handle.TryCallMethod(state, DeleteMethodName, 1, 0).IsOk; - } - - /// Reads all lines as the one string returned by CE's getText(). - /// The text on success; on failure. - /// when CE returned a Lua string. - [RequiresPluginEnabled] - public bool TryGetText([MaybeNullWhen(false)] out string text) - { - return Handle.TryCallMethod(GetTextMethodName, out text); - } - - /// Sets all lines from the text accepted by CE's setText(string). - /// The text to assign. - /// when CE completed the assignment. - /// is . - [RequiresPluginEnabled] - public bool TrySetText(string text) - { - ArgumentNullException.ThrowIfNull(text); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - StringMarshaller.Push(state, text); - return Handle.TryCallMethod(state, SetTextMethodName, 1, 0).IsOk; - } - - /// Finds a string through CE's indexOf(string) method. - /// The string to find. - /// CE's index, or -1 when no equal string exists; zero on operation failure. - /// when CE returned an integer result. - /// is . - [RequiresPluginEnabled] - public bool TryIndexOf(string value, out int zeroBasedIndex) - { - ArgumentNullException.ThrowIfNull(value); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - StringMarshaller.Push(state, value); - if (!Handle.TryCallMethod(state, IndexOfMethodName, 1, 1).IsOk) - { - zeroBasedIndex = 0; - return false; - } - - return Int32Marshaller.TryRead(state, -1, out zeroBasedIndex); - } - - /// Reads the CE Sorted property. - /// The property value, or on failure. - /// when CE returned a boolean. - [RequiresPluginEnabled] - public bool TryGetSorted(out bool value) - { - return Handle.TryGetProperty(SortedPropertyName, out value); - } - - /// Sets the CE Sorted property. - /// Whether CE should keep the list sorted. - /// when CE accepted the property assignment. - [RequiresPluginEnabled] - public bool TrySetSorted(bool value) - { - return Handle.TrySetProperty(SortedPropertyName, value); - } - - /// Reads the CE Duplicates property. - /// The enum value, or on failure. - /// when CE returned an integer that fits the enum's underlying type. - [RequiresPluginEnabled] - public bool TryGetDuplicates(out DuplicateHandling value) - { - return Handle.TryGetProperty, DuplicateHandling>(DuplicatesPropertyName, - out value); - } - - /// Sets the CE Duplicates property with its numeric TDuplicates value. - /// The duplicate handling value. - /// when CE accepted the property assignment. - /// is not a defined member. - [RequiresPluginEnabled] - public bool TrySetDuplicates(DuplicateHandling value) - { - if (value is not (DuplicateHandling.Ignore or DuplicateHandling.Accept or DuplicateHandling.Error)) - ThrowUndefinedDuplicateHandling(value); - - return Handle.TrySetProperty, DuplicateHandling>(DuplicatesPropertyName, - value); - } - - /// Reads the CE CaseSensitive property. - /// The property value, or on failure. - /// when CE returned a boolean. - [RequiresPluginEnabled] - public bool TryGetCaseSensitive(out bool value) - { - return Handle.TryGetProperty(CaseSensitivePropertyName, out value); - } - - /// Sets the CE CaseSensitive property. - /// Whether CE should compare list strings with case sensitivity. - /// when CE accepted the property assignment. - [RequiresPluginEnabled] - public bool TrySetCaseSensitive(bool value) - { - return Handle.TrySetProperty(CaseSensitivePropertyName, value); - } - - [DoesNotReturn] - private static void ThrowUndefinedDuplicateHandling(DuplicateHandling value) - { - throw new ArgumentOutOfRangeException(nameof(value), value, - "A StringList duplicate policy must be a defined DuplicateHandling value."); - } + private static ReadOnlySpan CountPropertyName => "Count"u8; + + private static ReadOnlySpan SortedPropertyName => "Sorted"u8; + + private static ReadOnlySpan DuplicatesPropertyName => "Duplicates"u8; + + private static ReadOnlySpan CaseSensitivePropertyName => "CaseSensitive"u8; + + private static ReadOnlySpan ClearMethodName => "clear"u8; + + private static ReadOnlySpan AddMethodName => "add"u8; + + private static ReadOnlySpan DeleteMethodName => "delete"u8; + + private static ReadOnlySpan GetTextMethodName => "getText"u8; + + private static ReadOnlySpan SetTextMethodName => "setText"u8; + + private static ReadOnlySpan IndexOfMethodName => "indexOf"u8; + + /// Wraps an untyped Cheat Engine object handle as a StringList handle. + /// The native host-object handle; its runtime class is not checked. + /// + /// This is a pure value operation. Only a factory or API whose CE contract says it returned a StringList should + /// call it. + /// + public StringList(CEObject handle) + { + Handle = handle; + } + + /// Gets the handle that names no StringList. + public static StringList Null => default; + + /// + public CEObject Handle + { + get; + } + + /// Gets a value indicating whether this handle names no object. + public bool IsNull => Handle.IsNull; + + /// + public static StringList FromHandle(CEObject handle) + { + return new StringList(handle); + } + + /// Compares two StringList handles by native-object identity. + /// The first handle. + /// The second handle. + public static bool operator ==(StringList left, StringList right) + { + return left.Handle == right.Handle; + } + + /// Compares two StringList handles by native-object identity. + /// The first handle. + /// The second handle. + public static bool operator !=(StringList left, StringList right) + { + return left.Handle != right.Handle; + } + + /// + public bool Equals(StringList other) + { + return Handle == other.Handle; + } + + /// + public override bool Equals(object? obj) + { + return obj is StringList other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Handle.GetHashCode(); + } + + /// Formats the native-object identity for diagnostics. + /// The underlying representation. + public override string ToString() + { + return Handle.ToString(); + } + + /// + [RequiresPluginEnabled] + public static void Push(LuaState state, StringList value) + { + value.Handle.Push(state); + } + + /// + public static bool TryRead(LuaState state, int index, out StringList value) + { + if (CEObject.TryRead(state, index, out CEObject handle)) + { + value = FromHandle(handle); + return true; + } + + value = default; + return false; + } + + /// Reads the number of strings currently in the list. + /// The count on success; zero on failure. + /// when CE returned an integer count. + [RequiresPluginEnabled] + public bool TryGetCount(out int count) + { + return Handle.TryGetProperty(CountPropertyName, out count); + } + + /// Reads a string at a zero-based StringList index. + /// The StringList index, starting at zero. + /// The string on success; on failure. + /// when the index produced a Lua string. + /// is negative. + [RequiresPluginEnabled] + public bool TryGetItem(int zeroBasedIndex, [MaybeNullWhen(false)] out string value) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + if (!Handle.TryGetIndex(state, zeroBasedIndex).IsOk) + { + value = default!; + return false; + } + + return StringMarshaller.TryRead(state, -1, out value); + } + + /// Replaces the string at a zero-based StringList index. + /// The StringList index, starting at zero. + /// The replacement text. + /// when CE accepted the assignment. + /// is negative. + /// is . + [RequiresPluginEnabled] + public bool TrySetItem(int zeroBasedIndex, string value) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + ArgumentNullException.ThrowIfNull(value); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + StringMarshaller.Push(state, value); + return Handle.TrySetIndex(state, zeroBasedIndex).IsOk; + } + + /// Removes every string from this list. + /// when CE completed clear(). + [RequiresPluginEnabled] + public bool TryClear() + { + return Handle.TryCallMethod(ClearMethodName); + } + + /// Adds a string and returns its zero-based StringList index. + /// The string to add. + /// The index returned by CE, or zero on failure. + /// when CE returned an integer index. + /// is . + [RequiresPluginEnabled] + public bool TryAdd(string value, out int zeroBasedIndex) + { + ArgumentNullException.ThrowIfNull(value); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + StringMarshaller.Push(state, value); + if (!Handle.TryCallMethod(state, AddMethodName, 1, 1).IsOk) + { + zeroBasedIndex = 0; + return false; + } + + return Int32Marshaller.TryRead(state, -1, out zeroBasedIndex); + } + + /// Deletes the string at a zero-based StringList index. + /// The StringList index, starting at zero. + /// when CE completed delete(index). + /// is negative. + [RequiresPluginEnabled] + public bool TryDelete(int zeroBasedIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + Int32Marshaller.Push(state, zeroBasedIndex); + return Handle.TryCallMethod(state, DeleteMethodName, 1, 0).IsOk; + } + + /// Reads all lines as the one string returned by CE's getText(). + /// The text on success; on failure. + /// when CE returned a Lua string. + [RequiresPluginEnabled] + public bool TryGetText([MaybeNullWhen(false)] out string text) + { + return Handle.TryCallMethod(GetTextMethodName, out text); + } + + /// Sets all lines from the text accepted by CE's setText(string). + /// The text to assign. + /// when CE completed the assignment. + /// is . + [RequiresPluginEnabled] + public bool TrySetText(string text) + { + ArgumentNullException.ThrowIfNull(text); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + StringMarshaller.Push(state, text); + return Handle.TryCallMethod(state, SetTextMethodName, 1, 0).IsOk; + } + + /// Finds a string through CE's indexOf(string) method. + /// The string to find. + /// CE's index, or -1 when no equal string exists; zero on operation failure. + /// when CE returned an integer result. + /// is . + [RequiresPluginEnabled] + public bool TryIndexOf(string value, out int zeroBasedIndex) + { + ArgumentNullException.ThrowIfNull(value); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + StringMarshaller.Push(state, value); + if (!Handle.TryCallMethod(state, IndexOfMethodName, 1, 1).IsOk) + { + zeroBasedIndex = 0; + return false; + } + + return Int32Marshaller.TryRead(state, -1, out zeroBasedIndex); + } + + /// Reads the CE Sorted property. + /// The property value, or on failure. + /// when CE returned a boolean. + [RequiresPluginEnabled] + public bool TryGetSorted(out bool value) + { + return Handle.TryGetProperty(SortedPropertyName, out value); + } + + /// Sets the CE Sorted property. + /// Whether CE should keep the list sorted. + /// when CE accepted the property assignment. + [RequiresPluginEnabled] + public bool TrySetSorted(bool value) + { + return Handle.TrySetProperty(SortedPropertyName, value); + } + + /// Reads the CE Duplicates property. + /// The enum value, or on failure. + /// when CE returned an integer that fits the enum's underlying type. + [RequiresPluginEnabled] + public bool TryGetDuplicates(out DuplicateHandling value) + { + return Handle.TryGetProperty, DuplicateHandling>(DuplicatesPropertyName, + out value); + } + + /// Sets the CE Duplicates property with its numeric TDuplicates value. + /// The duplicate handling value. + /// when CE accepted the property assignment. + /// is not a defined member. + [RequiresPluginEnabled] + public bool TrySetDuplicates(DuplicateHandling value) + { + if (value is not (DuplicateHandling.Ignore or DuplicateHandling.Accept or DuplicateHandling.Error)) + { + ThrowUndefinedDuplicateHandling(value); + } + + return Handle.TrySetProperty, DuplicateHandling>(DuplicatesPropertyName, + value); + } + + /// Reads the CE CaseSensitive property. + /// The property value, or on failure. + /// when CE returned a boolean. + [RequiresPluginEnabled] + public bool TryGetCaseSensitive(out bool value) + { + return Handle.TryGetProperty(CaseSensitivePropertyName, out value); + } + + /// Sets the CE CaseSensitive property. + /// Whether CE should compare list strings with case sensitivity. + /// when CE accepted the property assignment. + [RequiresPluginEnabled] + public bool TrySetCaseSensitive(bool value) + { + return Handle.TrySetProperty(CaseSensitivePropertyName, value); + } + + [DoesNotReturn] + private static void ThrowUndefinedDuplicateHandling(DuplicateHandling value) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "A StringList duplicate policy must be a defined DuplicateHandling value."); + } } diff --git a/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs b/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs index 97159710..ae9d4cdd 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; @@ -18,26 +19,26 @@ namespace CheatEngine.SDK.Engine.Objects; /// public static class StringLists { - private static readonly LuaRef SCreateStringList = new(); + private static readonly LuaRef SCreateStringList = new(); - /// Creates one plugin-owned StringList. - /// The new owner on success; on failure. - /// when CE returned a non-null host object. - [RequiresPluginEnabled] - public static bool TryCreate([NotNullWhen(true)] out Owned? list) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - if (!LuaGlobalFunctions.TryPush(state, SCreateStringList, "createStringlist"u8) || - !state.TryCall(0, 1).IsOk || - !CEObject.TryRead(state, -1, out var handle)) - { - list = null; - return false; - } + /// Creates one plugin-owned StringList. + /// The new owner on success; on failure. + /// when CE returned a non-null host object. + [RequiresPluginEnabled] + public static bool TryCreate([NotNullWhen(true)] out Owned? list) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + if (!LuaGlobalFunctions.TryPush(state, SCreateStringList, "createStringlist"u8) || + !state.TryCall(0, 1).IsOk || + !CEObject.TryRead(state, -1, out CEObject handle)) + { + list = null; + return false; + } - list = new Owned(StringList.FromHandle(handle)); - return true; - } + list = new Owned(StringList.FromHandle(handle)); + return true; + } } diff --git a/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs b/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs index 4173dfd4..9f711cd1 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; diff --git a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs index 30255ef4..7c38c75e 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Engine.Processes; @@ -19,16 +20,23 @@ private ProcessOperationStatus(ProcessOperationStatusKind kind, LuaStatus luaSta } /// Gets the factual result category. - public ProcessOperationStatusKind Kind { get; } + public ProcessOperationStatusKind Kind + { + get; + } /// Gets the protected Lua status for a Lua failure; otherwise . - public LuaStatus LuaStatus { get; } + public LuaStatus LuaStatus + { + get; + } /// Gets a successful status. public static ProcessOperationStatus Success => default; /// Gets a status for a target that is not currently selected. - public static ProcessOperationStatus TargetNotAttached => new(ProcessOperationStatusKind.TargetNotAttached, LuaStatus.Ok); + public static ProcessOperationStatus TargetNotAttached => + new(ProcessOperationStatusKind.TargetNotAttached, LuaStatus.Ok); /// Gets a status for an explicit selection that could not be confirmed. public static ProcessOperationStatus SelectionNotConfirmed => @@ -73,7 +81,7 @@ public override bool Equals(object? obj) /// public override int GetHashCode() { - return HashCode.Combine((int)Kind, LuaStatus); + return HashCode.Combine((int) Kind, LuaStatus); } /// Tests two process operation statuses for equality. diff --git a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs index 4bbbeee8..3349342c 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs @@ -22,5 +22,5 @@ public enum ProcessOperationStatusKind ProtectedLuaFailure, /// The host returned a value outside the documented process-observation shape. - InvalidResult, + InvalidResult } diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs index 13d814a6..8bda10ca 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Lua.Calls; @@ -33,7 +34,7 @@ public static class RuntimeHostOperations [RequiresPluginEnabled] public static LuaOperationStatus TryGetCheatEngineVersion(out double version) { - using var operation = LuaRuntime.AcquireOperation(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); return TryGetCheatEngineVersion(operation.State, out version); } @@ -44,12 +45,13 @@ public static LuaOperationStatus TryGetCheatEngineVersion(out double version) [RequiresPluginEnabled] public static LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchitecture architecture) { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; try { - var status = TryCallInteger(state, SGetSystemArchitecture, "getSystemArchitecture"u8, out var code); + LuaOperationStatus status = + TryCallInteger(state, SGetSystemArchitecture, "getSystemArchitecture"u8, out int code); if (!status.IsSuccess) { architecture = CheatEngineArchitecture.Unknown; @@ -73,12 +75,12 @@ public static LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchite [RequiresPluginEnabled] public static LuaOperationStatus TryGetTargetAbi(out TargetAbi abi) { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; try { - var status = TryCallInteger(state, SGetTargetAbi, "getABI"u8, out var code); + LuaOperationStatus status = TryCallInteger(state, SGetTargetAbi, "getABI"u8, out int code); if (!status.IsSuccess) { abi = TargetAbi.Unknown; @@ -98,44 +100,45 @@ public static LuaOperationStatus TryGetTargetAbi(out TargetAbi abi) internal static LuaOperationStatus TryCallInteger(LuaState state, LuaRef cache, ReadOnlySpan globalName, out int value) { - var resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, globalName); + LuaGlobalPushOutcome resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, globalName); if (!resolution.IsSuccess) { value = default; return resolution.ToOperationStatus(); } - var status = state.TryCall(0, 1); + LuaStatus status = state.TryCall(0, 1); if (!status.IsOk) { value = default; return LuaOperationStatus.LuaFailure(status); } - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out var raw) || + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long raw) || raw is < int.MinValue or > int.MaxValue) { value = default; return state.IsNil(-1) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; } - value = (int)raw; + value = (int) raw; return LuaOperationStatus.Success; } private static LuaOperationStatus TryGetCheatEngineVersion(LuaState state, out double version) { - var top = state.Top; + int top = state.Top; try { - var resolution = LuaGlobalFunctions.TryPushWithOutcome(state, SGetCheatEngineVersion, "getCEVersion"u8); + LuaGlobalPushOutcome resolution = + LuaGlobalFunctions.TryPushWithOutcome(state, SGetCheatEngineVersion, "getCEVersion"u8); if (!resolution.IsSuccess) { version = default; return resolution.ToOperationStatus(); } - var status = state.TryCall(0, 1); + LuaStatus status = state.TryCall(0, 1); if (!status.IsOk) { version = default; diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs index 3a0b9d0e..cd1559ea 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs @@ -1,7 +1,9 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -20,207 +22,210 @@ namespace CheatEngine.SDK.Engine.Processes; /// public static class RuntimeProcessOperations { - private static readonly LuaRef SGetOpenedProcessId = new(); - private static readonly LuaRef SOpenProcess = new(); - private static readonly LuaRef STargetIs64Bit = new(); - - /// Observes the current CE target process and its pointer width. - /// The copied target observation only when the returned status is successful. - /// The factual protected process-observation status. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) - { - using var operation = LuaRuntime.AcquireOperation(); - return ObserveCurrent(operation.State, out observation); - } - - /// Selects an explicit process identifier and immediately verifies CE's resulting selection. - /// The positive process identifier to select. - /// The copied matching target observation only when the returned status is successful. - /// - /// The factual protected selection status. A normal openProcess return is not success by itself: success - /// requires the next getOpenedProcessID observation to equal . - /// - /// is default or otherwise non-positive. - /// The plugin is not enabled or the calling thread has no Lua state. - [RequiresPluginEnabled] - public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, - out CurrentProcessObservation observation) - { - ValidateProcessId(processId); - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - var status = TryOpenProcess(state, processId); - if (!status.IsSuccess) - { - observation = default; - return status; - } - - status = TryGetOpenedProcessId(state, out TargetProcessId? observedProcessId); - if (status.Kind == ProcessOperationStatusKind.TargetNotAttached || - (status.IsSuccess && observedProcessId != processId)) - { - observation = default; - return ProcessOperationStatus.SelectionNotConfirmed; - } - - if (!status.IsSuccess) - { - observation = default; - return status; - } - - status = TryGetTargetPointerSize(state, out PointerSize pointerSize); - if (!status.IsSuccess) - { - observation = default; - return status; - } - - observation = new CurrentProcessObservation(observedProcessId!.Value, pointerSize); - return ProcessOperationStatus.Success; - } - finally - { - state.SetTop(top); - } - } - - private static ProcessOperationStatus ObserveCurrent(LuaState state, out CurrentProcessObservation observation) - { - var top = state.Top; - try - { - var status = TryGetOpenedProcessId(state, out TargetProcessId? processId); - if (!status.IsSuccess || !processId.HasValue) - { - observation = default; - return status; - } - - status = TryGetTargetPointerSize(state, out PointerSize pointerSize); - if (!status.IsSuccess) - { - observation = default; - return status; - } - - observation = new CurrentProcessObservation(processId.Value, pointerSize); - return ProcessOperationStatus.Success; - } - finally - { - state.SetTop(top); - } - } - - private static ProcessOperationStatus TryGetOpenedProcessId(LuaState state, out TargetProcessId? processId) - { - var resolution = LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); - if (!resolution.IsSuccess) - { - processId = default; - return FromResolution(resolution); - } - - var luaStatus = state.TryCall(0, 1); - if (!luaStatus.IsOk) - { - processId = default; - return ProcessOperationStatus.ProtectedLuaFailure(luaStatus); - } - - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out var value) || - value is < 0 or > int.MaxValue) - { - processId = default; - return ProcessOperationStatus.InvalidResult; - } - - if (value == 0) - { - processId = default; - return ProcessOperationStatus.TargetNotAttached; - } - - processId = new TargetProcessId((int)value); - return ProcessOperationStatus.Success; - } - - private static ProcessOperationStatus TryGetTargetPointerSize(LuaState state, out PointerSize pointerSize) - { - var status = TryCallBoolean(state, STargetIs64Bit, "targetIs64Bit"u8, out var is64Bit); - if (!status.IsSuccess) - { - pointerSize = PointerSize.Unknown; - return status; - } - - pointerSize = is64Bit ? PointerSize.Bit64 : PointerSize.Bit32; - return ProcessOperationStatus.Success; - } - - private static ProcessOperationStatus TryOpenProcess(LuaState state, TargetProcessId processId) - { - var resolution = LuaGlobalFunctions.TryPushWithOutcome(state, SOpenProcess, "openProcess"u8); - if (!resolution.IsSuccess) - { - return FromResolution(resolution); - } - - state.PushInteger(processId.Value); - var luaStatus = state.TryCall(1, 0); - return luaStatus.IsOk - ? ProcessOperationStatus.Success - : ProcessOperationStatus.ProtectedLuaFailure(luaStatus); - } - - private static ProcessOperationStatus TryCallBoolean(LuaState state, LuaRef cache, ReadOnlySpan globalName, - out bool value) - { - var resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, globalName); - if (!resolution.IsSuccess) - { - value = default; - return FromResolution(resolution); - } - - var luaStatus = state.TryCall(0, 1); - if (!luaStatus.IsOk) - { - value = default; - return ProcessOperationStatus.ProtectedLuaFailure(luaStatus); - } - - if (state.TypeOf(-1) != LuaType.Boolean) - { - value = default; - return ProcessOperationStatus.InvalidResult; - } - - value = state.ToBoolean(-1); - return ProcessOperationStatus.Success; - } - - private static ProcessOperationStatus FromResolution(LuaGlobalPushOutcome resolution) - { - return resolution.Status switch - { - LuaGlobalPushStatus.Unavailable => ProcessOperationStatus.GlobalUnavailable, - LuaGlobalPushStatus.LuaFailure => ProcessOperationStatus.ProtectedLuaFailure(resolution.LuaStatus), - _ => ProcessOperationStatus.InvalidResult, - }; - } - - private static void ValidateProcessId(TargetProcessId processId) - { - if (processId.Value <= 0) - throw new ArgumentOutOfRangeException(nameof(processId), processId.Value, - "A target process identifier must be positive."); - } + private static readonly LuaRef SGetOpenedProcessId = new(); + private static readonly LuaRef SOpenProcess = new(); + private static readonly LuaRef STargetIs64Bit = new(); + + /// Observes the current CE target process and its pointer width. + /// The copied target observation only when the returned status is successful. + /// The factual protected process-observation status. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + return ObserveCurrent(operation.State, out observation); + } + + /// Selects an explicit process identifier and immediately verifies CE's resulting selection. + /// The positive process identifier to select. + /// The copied matching target observation only when the returned status is successful. + /// + /// The factual protected selection status. A normal openProcess return is not success by itself: success + /// requires the next getOpenedProcessID observation to equal . + /// + /// is default or otherwise non-positive. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, + out CurrentProcessObservation observation) + { + ValidateProcessId(processId); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + ProcessOperationStatus status = TryOpenProcess(state, processId); + if (!status.IsSuccess) + { + observation = default; + return status; + } + + status = TryGetOpenedProcessId(state, out TargetProcessId? observedProcessId); + if (status.Kind == ProcessOperationStatusKind.TargetNotAttached || + (status.IsSuccess && observedProcessId != processId)) + { + observation = default; + return ProcessOperationStatus.SelectionNotConfirmed; + } + + if (!status.IsSuccess) + { + observation = default; + return status; + } + + status = TryGetTargetPointerSize(state, out PointerSize pointerSize); + if (!status.IsSuccess) + { + observation = default; + return status; + } + + observation = new CurrentProcessObservation(observedProcessId!.Value, pointerSize); + return ProcessOperationStatus.Success; + } + finally + { + state.SetTop(top); + } + } + + private static ProcessOperationStatus ObserveCurrent(LuaState state, out CurrentProcessObservation observation) + { + int top = state.Top; + try + { + ProcessOperationStatus status = TryGetOpenedProcessId(state, out TargetProcessId? processId); + if (!status.IsSuccess || !processId.HasValue) + { + observation = default; + return status; + } + + status = TryGetTargetPointerSize(state, out PointerSize pointerSize); + if (!status.IsSuccess) + { + observation = default; + return status; + } + + observation = new CurrentProcessObservation(processId.Value, pointerSize); + return ProcessOperationStatus.Success; + } + finally + { + state.SetTop(top); + } + } + + private static ProcessOperationStatus TryGetOpenedProcessId(LuaState state, out TargetProcessId? processId) + { + LuaGlobalPushOutcome resolution = + LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); + if (!resolution.IsSuccess) + { + processId = default; + return FromResolution(resolution); + } + + LuaStatus luaStatus = state.TryCall(0, 1); + if (!luaStatus.IsOk) + { + processId = default; + return ProcessOperationStatus.ProtectedLuaFailure(luaStatus); + } + + if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || + value is < 0 or > int.MaxValue) + { + processId = default; + return ProcessOperationStatus.InvalidResult; + } + + if (value == 0) + { + processId = default; + return ProcessOperationStatus.TargetNotAttached; + } + + processId = new TargetProcessId((int) value); + return ProcessOperationStatus.Success; + } + + private static ProcessOperationStatus TryGetTargetPointerSize(LuaState state, out PointerSize pointerSize) + { + ProcessOperationStatus status = TryCallBoolean(state, STargetIs64Bit, "targetIs64Bit"u8, out bool is64Bit); + if (!status.IsSuccess) + { + pointerSize = PointerSize.Unknown; + return status; + } + + pointerSize = is64Bit ? PointerSize.Bit64 : PointerSize.Bit32; + return ProcessOperationStatus.Success; + } + + private static ProcessOperationStatus TryOpenProcess(LuaState state, TargetProcessId processId) + { + LuaGlobalPushOutcome resolution = LuaGlobalFunctions.TryPushWithOutcome(state, SOpenProcess, "openProcess"u8); + if (!resolution.IsSuccess) + { + return FromResolution(resolution); + } + + state.PushInteger(processId.Value); + LuaStatus luaStatus = state.TryCall(1, 0); + return luaStatus.IsOk + ? ProcessOperationStatus.Success + : ProcessOperationStatus.ProtectedLuaFailure(luaStatus); + } + + private static ProcessOperationStatus TryCallBoolean(LuaState state, LuaRef cache, ReadOnlySpan globalName, + out bool value) + { + LuaGlobalPushOutcome resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, globalName); + if (!resolution.IsSuccess) + { + value = default; + return FromResolution(resolution); + } + + LuaStatus luaStatus = state.TryCall(0, 1); + if (!luaStatus.IsOk) + { + value = default; + return ProcessOperationStatus.ProtectedLuaFailure(luaStatus); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + value = default; + return ProcessOperationStatus.InvalidResult; + } + + value = state.ToBoolean(-1); + return ProcessOperationStatus.Success; + } + + private static ProcessOperationStatus FromResolution(LuaGlobalPushOutcome resolution) + { + return resolution.Status switch + { + LuaGlobalPushStatus.Unavailable => ProcessOperationStatus.GlobalUnavailable, + LuaGlobalPushStatus.LuaFailure => ProcessOperationStatus.ProtectedLuaFailure(resolution.LuaStatus), + _ => ProcessOperationStatus.InvalidResult + }; + } + + private static void ValidateProcessId(TargetProcessId processId) + { + if (processId.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(processId), processId.Value, + "A target process identifier must be positive."); + } + } } diff --git a/libs/CheatEngine.SDK.Engine/README.md b/libs/CheatEngine.SDK.Engine/README.md index 255bc513..2760c175 100644 --- a/libs/CheatEngine.SDK.Engine/README.md +++ b/libs/CheatEngine.SDK.Engine/README.md @@ -17,38 +17,38 @@ Cheat Engine. This library encodes each rule once, in a type. ## How it works -| Namespace | Types | Role | -|---------------------------------------|-------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `CheatEngine.SDK.Engine.Objects` | `CEObject`, `ICEObject` | Borrowed handle: the native object pointer, equal by identity, with property, index and method access | -| `CheatEngine.SDK.Engine.Objects` | `Owned` | Ownership of an object the plugin created; `Dispose` destroys it | -| `CheatEngine.SDK.Engine.Values` | `Address` | An address read from a Lua integer or hexadecimal text; its own Lua marshaller | -| `CheatEngine.SDK.Engine.Values` | `IndexBase`, `LuaSequence` | Zero-based indices over Cheat Engine objects and Lua sequences | -| `CheatEngine.SDK.Engine.Enums` | Enums, `CEEnumNames`, `EnumMarshaller` | Numeric constants, their Cheat Engine names, and Lua integer marshalling | -| `CheatEngine.SDK.Engine.Runtime` | `RuntimeInfo`, `RuntimeCapabilities` | Explicit runtime observations and evidence metadata; never inferred host facts | -| `CheatEngine.SDK.Engine.Memory` | `TargetMemory`, `HostMemory`, `HostAddress` | Separate target/CE-host scalar, bounded span, target-width pointer, string and byte-table access | -| `CheatEngine.SDK.Engine.Inspection` | `EngineInspection` | Copied modules, sections, symbols, address resolution and memory-region snapshots | -| `CheatEngine.SDK.Engine.Allocation` | `TargetMemoryAllocator`, `AllocatedRegion` | Explicit ownership for target allocation, via a reviewed binding seam | -| `CheatEngine.SDK.Engine.Assembly` | `AutoAssemblerPatcher`, `AutoAssemblerPatch`, `InstructionProfiles`, `InstructionAssembler`, `InstructionDisassembler`, `InstructionNavigator` | Auto Assembler owns a single `disableInfo`; separately, bounded profile-qualified Lua instruction operations return copied values and structured outcomes | -| `CheatEngine.SDK.Engine.Scanning` | `AobScanner`, `StringList`, `MemoryScanSession` | AOB result ownership and conservative MemScan/FoundList state transitions | -| `CheatEngine.SDK.Engine.AddressList` | `AddressListMutations`, `MemoryRecordId` | ID-addressed record commands; borrowed GUI views never become managed owners | -| `CheatEngine.SDK.Engine.Errors` | `EngineException` hierarchy, `EngineResourceHandoffException` | Stable distinction between expected CE, unavailable global, Lua, binding and marshalling failures; post-effect ownership publication reports its one cleanup attempt | -| `CheatEngine.SDK.Engine.Generated` | `MemoryScalars` | Existing generated scalar wrappers for the earlier memory contract | +| Namespace | Types | Role | +|--------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `CheatEngine.SDK.Engine.Objects` | `CEObject`, `ICEObject` | Borrowed handle: the native object pointer, equal by identity, with property, index and method access | +| `CheatEngine.SDK.Engine.Objects` | `Owned` | Ownership of an object the plugin created; `Dispose` destroys it | +| `CheatEngine.SDK.Engine.Values` | `Address` | An address read from a Lua integer or hexadecimal text; its own Lua marshaller | +| `CheatEngine.SDK.Engine.Values` | `IndexBase`, `LuaSequence` | Zero-based indices over Cheat Engine objects and Lua sequences | +| `CheatEngine.SDK.Engine.Enums` | Enums, `CEEnumNames`, `EnumMarshaller` | Numeric constants, their Cheat Engine names, and Lua integer marshalling | +| `CheatEngine.SDK.Engine.Runtime` | `RuntimeInfo`, `RuntimeCapabilities` | Explicit runtime observations and evidence metadata; never inferred host facts | +| `CheatEngine.SDK.Engine.Memory` | `TargetMemory`, `HostMemory`, `HostAddress` | Separate target/CE-host scalar, bounded span, target-width pointer, string and byte-table access | +| `CheatEngine.SDK.Engine.Inspection` | `EngineInspection` | Copied modules, sections, symbols, address resolution and memory-region snapshots | +| `CheatEngine.SDK.Engine.Allocation` | `TargetMemoryAllocator`, `AllocatedRegion` | Explicit ownership for target allocation, via a reviewed binding seam | +| `CheatEngine.SDK.Engine.Assembly` | `AutoAssemblerPatcher`, `AutoAssemblerPatch`, `InstructionProfiles`, `InstructionAssembler`, `InstructionDisassembler`, `InstructionNavigator` | Auto Assembler owns a single `disableInfo`; separately, bounded profile-qualified Lua instruction operations return copied values and structured outcomes | +| `CheatEngine.SDK.Engine.Scanning` | `AobScanner`, `StringList`, `MemoryScanSession` | AOB result ownership and conservative MemScan/FoundList state transitions | +| `CheatEngine.SDK.Engine.AddressList` | `AddressListMutations`, `MemoryRecordId` | ID-addressed record commands; borrowed GUI views never become managed owners | +| `CheatEngine.SDK.Engine.Errors` | `EngineException` hierarchy, `EngineResourceHandoffException` | Stable distinction between expected CE, unavailable global, Lua, binding and marshalling failures; post-effect ownership publication reports its one cleanup attempt | +| `CheatEngine.SDK.Engine.Generated` | `MemoryScalars` | Existing generated scalar wrappers for the earlier memory contract | The CE 7.7 vertical slices add the following public domains. They use the same protected Lua boundary, but their evidence and availability are intentionally separate: a catalogued Lua name is not a guarantee that every later CE host has the same contract. -| Namespace | Public surface | Boundary and result contract | -|----------------------------|-------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Runtime` | `RuntimeInfo`, `RuntimeCapabilities`, and `RuntimeCapabilityContract` | An immutable snapshot of explicitly observed version, architecture, pointer-width and availability facts. Unknown remains unknown; an available global does not fill an unobserved ownership, thread or return field. | -| `Memory` | `TargetMemory`, `HostMemory`, `Address`, `HostAddress`, `PointerSize`, `MemoryAccessFailure` | Keeps attached-target addresses distinct from CE-host addresses. Target-width pointer, scalar, bounded-span, and string calls report expected CE/binding/Lua/result failures through `Try*` results; they do not claim a universal GUI-thread rule. | -| `Inspection` | `EngineInspection` and module, section, symbol and region value types | Returns copied managed snapshots. `NotFound` is used only where the CE 7.7 Lua contract documents `nil`; malformed data and Lua failures remain distinct status values. | -| `Allocation` | `TargetMemoryAllocator`, `AllocatedRegion` | Models one target allocation as an explicit, single-use owner. It does not infer a GUI-thread requirement from an unspecific CE global, and reports a failed post-effect owner handoff with its one compensation outcome. | -| `Objects` / `Scanning.Aob` | `StringList`, `StringLists`, `AobScanner` | `StringLists.TryCreate` and a successful `AobScanner.TryScan` out value yield `Owned` only after a host object is returned. A list borrowed from CE must never be wrapped or destroyed by plugin code. | -| `Scanning.Values` | `MemScan`, `FoundList`, `MemoryScanSessions`, `MemoryScanSession`, scan requests and states | The factory creates and owns the scanner/child pair, retains rollback authority through publication, and the session serializes documented state transitions and releases the child before the parent. It is explicitly main-thread-only; the generic `Owned` wrapper is not. | -| `AddressList` | `AddressListAccess`, `AddressList`, `MemoryRecord`, `MemoryRecordId`, `AddressListMutations` | The current GUI list and records are borrowed CE-owned handles. Mutations resolve IDs inside one protected command and report completed, not-started, or indeterminate effect; they do not promise historic record identity or a runtime-enforceable GUI-thread guard. | +| Namespace | Public surface | Boundary and result contract | +|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Runtime` | `RuntimeInfo`, `RuntimeCapabilities`, and `RuntimeCapabilityContract` | An immutable snapshot of explicitly observed version, architecture, pointer-width and availability facts. Unknown remains unknown; an available global does not fill an unobserved ownership, thread or return field. | +| `Memory` | `TargetMemory`, `HostMemory`, `Address`, `HostAddress`, `PointerSize`, `MemoryAccessFailure` | Keeps attached-target addresses distinct from CE-host addresses. Target-width pointer, scalar, bounded-span, and string calls report expected CE/binding/Lua/result failures through `Try*` results; they do not claim a universal GUI-thread rule. | +| `Inspection` | `EngineInspection` and module, section, symbol and region value types | Returns copied managed snapshots. `NotFound` is used only where the CE 7.7 Lua contract documents `nil`; malformed data and Lua failures remain distinct status values. | +| `Allocation` | `TargetMemoryAllocator`, `AllocatedRegion` | Models one target allocation as an explicit, single-use owner. It does not infer a GUI-thread requirement from an unspecific CE global, and reports a failed post-effect owner handoff with its one compensation outcome. | +| `Objects` / `Scanning.Aob` | `StringList`, `StringLists`, `AobScanner` | `StringLists.TryCreate` and a successful `AobScanner.TryScan` out value yield `Owned` only after a host object is returned. A list borrowed from CE must never be wrapped or destroyed by plugin code. | +| `Scanning.Values` | `MemScan`, `FoundList`, `MemoryScanSessions`, `MemoryScanSession`, scan requests and states | The factory creates and owns the scanner/child pair, retains rollback authority through publication, and the session serializes documented state transitions and releases the child before the parent. It is explicitly main-thread-only; the generic `Owned` wrapper is not. | +| `AddressList` | `AddressListAccess`, `AddressList`, `MemoryRecord`, `MemoryRecordId`, `AddressListMutations` | The current GUI list and records are borrowed CE-owned handles. Mutations resolve IDs inside one protected command and report completed, not-started, or indeterminate effect; they do not promise historic record identity or a runtime-enforceable GUI-thread guard. | | `Assembly` | `InstructionTargetProfile`, `InstructionAssembler`, `InstructionDisassembler`, `InstructionNavigator`, `InstructionDisassembly`, `InstructionOperationStatus` | `InstructionProfiles` observes PID/probe/PID under one Lua admission. Each instruction call validates target width and rechecks that PID before and after CE's ambient operation; its result is copied and bounded, but that coherence check is not a target lock or a live-host qualification. | -| `Errors` | `EngineException` and stable subclasses | Separates expected operation failure, global absence, Lua failure, binding violation and marshalling violation instead of exposing a raw Lua stack error as the public Engine contract. | +| `Errors` | `EngineException` and stable subclasses | Separates expected operation failure, global absence, Lua failure, binding violation and marshalling violation instead of exposing a raw Lua stack error as the public Engine contract. | The per-capability provenance, minimum CE version, architecture, thread, ownership and return semantics belong to the versioned [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md). Fixture tests validate diff --git a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineArchitecture.cs b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineArchitecture.cs index cbc83cac..b6b09ebb 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineArchitecture.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineArchitecture.cs @@ -7,18 +7,18 @@ namespace CheatEngine.SDK.Engine.Runtime; /// public enum CheatEngineArchitecture : byte { - /// No architecture fact is available. - Unknown = 0, + /// No architecture fact is available. + Unknown = 0, - /// An Intel-compatible 32-bit process. - X86 = 1, + /// An Intel-compatible 32-bit process. + X86 = 1, - /// An Intel-compatible 64-bit process. - X64 = 2, + /// An Intel-compatible 64-bit process. + X64 = 2, - /// A 32-bit ARM process. - Arm32 = 3, + /// A 32-bit ARM process. + Arm32 = 3, - /// A 64-bit ARM process. - Arm64 = 4, + /// A 64-bit ARM process. + Arm64 = 4 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs index a97550db..f98e87d3 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs @@ -17,134 +17,154 @@ namespace CheatEngine.SDK.Engine.Runtime; [StructLayout(LayoutKind.Sequential)] public readonly struct CheatEngineVersion : IEquatable, IComparable { - /// The CE 7.7.0.10621 build that defines this SDK's current compatibility reference. - public static CheatEngineVersion Ce77010621 => new(7, 7, 0, 10621); - - /// Initializes a complete Cheat Engine file version. - /// The non-negative major component. - /// The non-negative minor component. - /// The non-negative release component. - /// The non-negative build component. - /// At least one component is negative. - public CheatEngineVersion(int major, int minor, int release, int build) - { - ArgumentOutOfRangeException.ThrowIfNegative(major); - ArgumentOutOfRangeException.ThrowIfNegative(minor); - ArgumentOutOfRangeException.ThrowIfNegative(release); - ArgumentOutOfRangeException.ThrowIfNegative(build); - Major = major; - Minor = minor; - Release = release; - Build = build; - } - - /// Gets the major file-version component. - public int Major { get; } - - /// Gets the minor file-version component. - public int Minor { get; } - - /// Gets the release file-version component. - public int Release { get; } - - /// Gets the build file-version component. - public int Build { get; } - - /// Compares two complete file versions component by component. - /// The version to compare with this value. - /// - /// A negative value, zero, or a positive value when this version is older than, equal to, or newer than - /// . - /// - public int CompareTo(CheatEngineVersion other) - { - var result = Major.CompareTo(other.Major); - if (result != 0) return result; - result = Minor.CompareTo(other.Minor); - if (result != 0) return result; - result = Release.CompareTo(other.Release); - return result != 0 ? result : Build.CompareTo(other.Build); - } - - /// - public bool Equals(CheatEngineVersion other) - { - return Major == other.Major && Minor == other.Minor && Release == other.Release && Build == other.Build; - } - - /// - public override bool Equals(object? obj) - { - return obj is CheatEngineVersion other && Equals(other); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine(Major, Minor, Release, Build); - } - - /// Formats all four file-version components using invariant decimal digits. - /// The major.minor.release.build representation. - public override string ToString() - { - return Major.ToString(CultureInfo.InvariantCulture) + "." + - Minor.ToString(CultureInfo.InvariantCulture) + "." + - Release.ToString(CultureInfo.InvariantCulture) + "." + - Build.ToString(CultureInfo.InvariantCulture); - } - - /// Tests two versions for equality. - /// The first version. - /// The second version. - /// when every component is equal. - public static bool operator ==(CheatEngineVersion left, CheatEngineVersion right) - { - return left.Equals(right); - } - - /// Tests two versions for inequality. - /// The first version. - /// The second version. - /// when at least one component differs. - public static bool operator !=(CheatEngineVersion left, CheatEngineVersion right) - { - return !left.Equals(right); - } - - /// Tests whether the first version is older than the second. - /// The first version. - /// The second version. - /// when is older. - public static bool operator <(CheatEngineVersion left, CheatEngineVersion right) - { - return left.CompareTo(right) < 0; - } - - /// Tests whether the first version is newer than the second. - /// The first version. - /// The second version. - /// when is newer. - public static bool operator >(CheatEngineVersion left, CheatEngineVersion right) - { - return left.CompareTo(right) > 0; - } - - /// Tests whether the first version is not newer than the second. - /// The first version. - /// The second version. - /// when is equal to or older. - public static bool operator <=(CheatEngineVersion left, CheatEngineVersion right) - { - return left.CompareTo(right) <= 0; - } - - /// Tests whether the first version is not older than the second. - /// The first version. - /// The second version. - /// when is equal to or newer. - public static bool operator >=(CheatEngineVersion left, CheatEngineVersion right) - { - return left.CompareTo(right) >= 0; - } + /// The CE 7.7.0.10621 build that defines this SDK's current compatibility reference. + public static CheatEngineVersion Ce77010621 => new(7, 7, 0, 10621); + + /// Initializes a complete Cheat Engine file version. + /// The non-negative major component. + /// The non-negative minor component. + /// The non-negative release component. + /// The non-negative build component. + /// At least one component is negative. + public CheatEngineVersion(int major, int minor, int release, int build) + { + ArgumentOutOfRangeException.ThrowIfNegative(major); + ArgumentOutOfRangeException.ThrowIfNegative(minor); + ArgumentOutOfRangeException.ThrowIfNegative(release); + ArgumentOutOfRangeException.ThrowIfNegative(build); + Major = major; + Minor = minor; + Release = release; + Build = build; + } + + /// Gets the major file-version component. + public int Major + { + get; + } + + /// Gets the minor file-version component. + public int Minor + { + get; + } + + /// Gets the release file-version component. + public int Release + { + get; + } + + /// Gets the build file-version component. + public int Build + { + get; + } + + /// Compares two complete file versions component by component. + /// The version to compare with this value. + /// + /// A negative value, zero, or a positive value when this version is older than, equal to, or newer than + /// . + /// + public int CompareTo(CheatEngineVersion other) + { + int result = Major.CompareTo(other.Major); + if (result != 0) + { + return result; + } + + result = Minor.CompareTo(other.Minor); + if (result != 0) + { + return result; + } + + result = Release.CompareTo(other.Release); + return result != 0 ? result : Build.CompareTo(other.Build); + } + + /// + public bool Equals(CheatEngineVersion other) + { + return Major == other.Major && Minor == other.Minor && Release == other.Release && Build == other.Build; + } + + /// + public override bool Equals(object? obj) + { + return obj is CheatEngineVersion other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Major, Minor, Release, Build); + } + + /// Formats all four file-version components using invariant decimal digits. + /// The major.minor.release.build representation. + public override string ToString() + { + return Major.ToString(CultureInfo.InvariantCulture) + "." + + Minor.ToString(CultureInfo.InvariantCulture) + "." + + Release.ToString(CultureInfo.InvariantCulture) + "." + + Build.ToString(CultureInfo.InvariantCulture); + } + + /// Tests two versions for equality. + /// The first version. + /// The second version. + /// when every component is equal. + public static bool operator ==(CheatEngineVersion left, CheatEngineVersion right) + { + return left.Equals(right); + } + + /// Tests two versions for inequality. + /// The first version. + /// The second version. + /// when at least one component differs. + public static bool operator !=(CheatEngineVersion left, CheatEngineVersion right) + { + return !left.Equals(right); + } + + /// Tests whether the first version is older than the second. + /// The first version. + /// The second version. + /// when is older. + public static bool operator <(CheatEngineVersion left, CheatEngineVersion right) + { + return left.CompareTo(right) < 0; + } + + /// Tests whether the first version is newer than the second. + /// The first version. + /// The second version. + /// when is newer. + public static bool operator >(CheatEngineVersion left, CheatEngineVersion right) + { + return left.CompareTo(right) > 0; + } + + /// Tests whether the first version is not newer than the second. + /// The first version. + /// The second version. + /// when is equal to or older. + public static bool operator <=(CheatEngineVersion left, CheatEngineVersion right) + { + return left.CompareTo(right) <= 0; + } + + /// Tests whether the first version is not older than the second. + /// The first version. + /// The second version. + /// when is equal to or newer. + public static bool operator >=(CheatEngineVersion left, CheatEngineVersion right) + { + return left.CompareTo(right) >= 0; + } } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs b/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs index b20ddeb1..e68665ba 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs @@ -6,162 +6,174 @@ namespace CheatEngine.SDK.Engine.Runtime; /// A pointer width that is valid for the supported 32-bit or 64-bit Cheat Engine process models. public readonly struct PointerSize : IEquatable { - private readonly byte _bytes; - - /// Gets an unavailable or not-yet-observed pointer width. - public static PointerSize Unknown => default; - - /// Gets the 32-bit pointer width. - public static PointerSize Bit32 => new(4); - - /// Gets the 64-bit pointer width. - public static PointerSize Bit64 => new(8); - - /// Initializes a pointer width measured in bytes. - /// Either 4 or 8. - /// is not 4 or 8. - public PointerSize(int bytes) - { - if (bytes is not 4 and not 8) - throw new ArgumentOutOfRangeException(nameof(bytes), bytes, - "A Cheat Engine pointer size must be 4 or 8 bytes."); - _bytes = (byte)bytes; - } - - /// Gets the width in bytes, or zero when it is unknown. - public int Bytes => _bytes; - - /// Gets the width in bits, or zero when it is unknown. - public int Bits => _bytes * 8; - - /// Gets a value indicating whether the width has been established. - public bool IsKnown => _bytes != 0; - - /// Derives the width implied by a known process architecture. - /// The process architecture. - /// The corresponding width, or when is unknown. - public static PointerSize FromArchitecture(CheatEngineArchitecture architecture) - { - return architecture switch - { - CheatEngineArchitecture.X86 or CheatEngineArchitecture.Arm32 => Bit32, - CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64 => Bit64, - _ => Unknown, - }; - } - - /// - /// Reads one little-endian target pointer from the beginning of without consulting - /// the managed host pointer width. - /// - /// The bytes supplied by a target-specific primitive. - /// The unsigned pointer bits, or zero when this method returns . - /// - /// when this instance is known and contains its exact - /// number of bytes; otherwise, . - /// - /// - /// This is explicit primitive marshalling, not an unmanaged-struct projection. A 32-bit target still consumes - /// four bytes when this SDK runs in CE's supported 64-bit host process. - /// - public bool TryReadLittleEndian(ReadOnlySpan source, out ulong value) - { - if (source.Length != _bytes) - { - value = default; - return false; - } - - switch (_bytes) - { - case 4: - if (BinaryPrimitives.TryReadUInt32LittleEndian(source, out var narrow)) - { - value = narrow; - return true; - } - - break; - - case 8: - if (BinaryPrimitives.TryReadUInt64LittleEndian(source, out value)) return true; - - break; - } - - value = default; - return false; - } - - /// - /// Writes one little-endian target pointer to without consulting the managed - /// host pointer width. - /// - /// The unsigned target pointer bits. - /// The exact target-pointer-sized destination. - /// - /// when this instance is known, fits it, and - /// has its exact number of bytes; otherwise, and - /// is unchanged. - /// - /// - /// This is explicit primitive marshalling, not an unmanaged-struct projection. A 32-bit target rejects high - /// bits instead of silently truncating them through the x64 host process. - /// - public bool TryWriteLittleEndian(ulong value, Span destination) - { - if (destination.Length != _bytes) return false; - - switch (_bytes) - { - case 4: - if (value > uint.MaxValue) return false; - - BinaryPrimitives.WriteUInt32LittleEndian(destination, (uint)value); - return true; - - case 8: - BinaryPrimitives.WriteUInt64LittleEndian(destination, value); - return true; - - default: - return false; - } - } - - /// - public bool Equals(PointerSize other) - { - return _bytes == other._bytes; - } - - /// - public override bool Equals(object? obj) - { - return obj is PointerSize other && Equals(other); - } - - /// - public override int GetHashCode() - { - return _bytes.GetHashCode(); - } - - /// Tests two pointer widths for equality. - /// The first width. - /// The second width. - /// when the widths are equal. - public static bool operator ==(PointerSize left, PointerSize right) - { - return left.Equals(right); - } - - /// Tests two pointer widths for inequality. - /// The first width. - /// The second width. - /// when the widths differ. - public static bool operator !=(PointerSize left, PointerSize right) - { - return !left.Equals(right); - } + private readonly byte _bytes; + + /// Gets an unavailable or not-yet-observed pointer width. + public static PointerSize Unknown => default; + + /// Gets the 32-bit pointer width. + public static PointerSize Bit32 => new(4); + + /// Gets the 64-bit pointer width. + public static PointerSize Bit64 => new(8); + + /// Initializes a pointer width measured in bytes. + /// Either 4 or 8. + /// is not 4 or 8. + public PointerSize(int bytes) + { + if (bytes is not 4 and not 8) + { + throw new ArgumentOutOfRangeException(nameof(bytes), bytes, + "A Cheat Engine pointer size must be 4 or 8 bytes."); + } + + _bytes = (byte) bytes; + } + + /// Gets the width in bytes, or zero when it is unknown. + public int Bytes => _bytes; + + /// Gets the width in bits, or zero when it is unknown. + public int Bits => _bytes * 8; + + /// Gets a value indicating whether the width has been established. + public bool IsKnown => _bytes != 0; + + /// Derives the width implied by a known process architecture. + /// The process architecture. + /// The corresponding width, or when is unknown. + public static PointerSize FromArchitecture(CheatEngineArchitecture architecture) + { + return architecture switch + { + CheatEngineArchitecture.X86 or CheatEngineArchitecture.Arm32 => Bit32, + CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64 => Bit64, + _ => Unknown + }; + } + + /// + /// Reads one little-endian target pointer from the beginning of without consulting + /// the managed host pointer width. + /// + /// The bytes supplied by a target-specific primitive. + /// The unsigned pointer bits, or zero when this method returns . + /// + /// when this instance is known and contains its exact + /// number of bytes; otherwise, . + /// + /// + /// This is explicit primitive marshalling, not an unmanaged-struct projection. A 32-bit target still consumes + /// four bytes when this SDK runs in CE's supported 64-bit host process. + /// + public bool TryReadLittleEndian(ReadOnlySpan source, out ulong value) + { + if (source.Length != _bytes) + { + value = default; + return false; + } + + switch (_bytes) + { + case 4: + if (BinaryPrimitives.TryReadUInt32LittleEndian(source, out uint narrow)) + { + value = narrow; + return true; + } + + break; + + case 8: + if (BinaryPrimitives.TryReadUInt64LittleEndian(source, out value)) + { + return true; + } + + break; + } + + value = default; + return false; + } + + /// + /// Writes one little-endian target pointer to without consulting the managed + /// host pointer width. + /// + /// The unsigned target pointer bits. + /// The exact target-pointer-sized destination. + /// + /// when this instance is known, fits it, and + /// has its exact number of bytes; otherwise, and + /// is unchanged. + /// + /// + /// This is explicit primitive marshalling, not an unmanaged-struct projection. A 32-bit target rejects high + /// bits instead of silently truncating them through the x64 host process. + /// + public bool TryWriteLittleEndian(ulong value, Span destination) + { + if (destination.Length != _bytes) + { + return false; + } + + switch (_bytes) + { + case 4: + if (value > uint.MaxValue) + { + return false; + } + + BinaryPrimitives.WriteUInt32LittleEndian(destination, (uint) value); + return true; + + case 8: + BinaryPrimitives.WriteUInt64LittleEndian(destination, value); + return true; + + default: + return false; + } + } + + /// + public bool Equals(PointerSize other) + { + return _bytes == other._bytes; + } + + /// + public override bool Equals(object? obj) + { + return obj is PointerSize other && Equals(other); + } + + /// + public override int GetHashCode() + { + return _bytes.GetHashCode(); + } + + /// Tests two pointer widths for equality. + /// The first width. + /// The second width. + /// when the widths are equal. + public static bool operator ==(PointerSize left, PointerSize right) + { + return left.Equals(right); + } + + /// Tests two pointer widths for inequality. + /// The first width. + /// The second width. + /// when the widths differ. + public static bool operator !=(PointerSize left, PointerSize right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureRequirement.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureRequirement.cs index be7085ef..a62a3f1d 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureRequirement.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureRequirement.cs @@ -3,21 +3,21 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The architecture constraint attached to a capability contract. public enum RuntimeArchitectureRequirement : byte { - /// The contract does not establish an architecture requirement. - Unknown = 0, + /// The contract does not establish an architecture requirement. + Unknown = 0, - /// The capability applies to every architecture in the current contract's scope. - Any = 1, + /// The capability applies to every architecture in the current contract's scope. + Any = 1, - /// The capability requires an x86 process. - X86 = 2, + /// The capability requires an x86 process. + X86 = 2, - /// The capability requires an x64 process. - X64 = 3, + /// The capability requires an x64 process. + X64 = 3, - /// The capability requires a 32-bit ARM process. - Arm32 = 4, + /// The capability requires a 32-bit ARM process. + Arm32 = 4, - /// The capability requires a 64-bit ARM process. - Arm64 = 5, + /// The capability requires a 64-bit ARM process. + Arm64 = 5 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureScope.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureScope.cs index 4046fb70..860f2908 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureScope.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeArchitectureScope.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The process side to which an architecture constraint applies. public enum RuntimeArchitectureScope : byte { - /// The contract does not state an architecture scope. - Unknown = 0, + /// The contract does not state an architecture scope. + Unknown = 0, - /// The constraint applies to the Cheat Engine host process. - CheatEngine = 1, + /// The constraint applies to the Cheat Engine host process. + CheatEngine = 1, - /// The constraint applies to the currently selected target process. - Target = 2, + /// The constraint applies to the currently selected target process. + Target = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilities.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilities.cs index d3a7b402..f222fb1d 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilities.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilities.cs @@ -5,94 +5,125 @@ namespace CheatEngine.SDK.Engine.Runtime; /// An immutable set of optional capability observations for one Cheat Engine runtime snapshot. public sealed class RuntimeCapabilities : IEquatable { - private readonly RuntimeCapabilityAvailability[] _entries; - - private RuntimeCapabilities(RuntimeCapabilityAvailability[] entries) - { - _entries = entries; - } - - /// Gets an empty capability set. - public static RuntimeCapabilities Empty { get; } = new(Array.Empty()); - - /// Gets the number of explicit capability observations. - public int Count => _entries.Length; - - /// Gets the ordered observations as a read-only span. - public ReadOnlySpan Entries => _entries; - - /// - public bool Equals(RuntimeCapabilities? other) - { - if (ReferenceEquals(this, other)) return true; - if (other is null || _entries.Length != other._entries.Length) return false; - for (var i = 0; i < _entries.Length; i++) - if (_entries[i] != other._entries[i]) - return false; - return true; - } - - /// Creates an immutable capability set by copying the supplied observations. - /// The observations to copy. Each capability identifier must be non-empty and unique. - /// The independent, immutable capability set. - /// An identifier is empty or occurs more than once. - public static RuntimeCapabilities Create(ReadOnlySpan entries) - { - if (entries.IsEmpty) return Empty; - - var copy = entries.ToArray(); - for (var i = 0; i < copy.Length; i++) - { - if (copy[i].Capability.IsEmpty) - throw new ArgumentException("A runtime capability identifier cannot be empty.", nameof(entries)); - - for (var previous = 0; previous < i; previous++) - if (copy[previous].Capability == copy[i].Capability) - throw new ArgumentException("A runtime capability identifier occurs more than once.", - nameof(entries)); - } - - return new RuntimeCapabilities(copy); - } - - /// Looks up one explicit capability observation. - /// The capability to look up. - /// The matching observation, or the default value when absent. - /// when the set contains . - public bool TryGet(RuntimeCapabilityId capability, out RuntimeCapabilityAvailability availability) - { - for (var i = 0; i < _entries.Length; i++) - if (_entries[i].Capability == capability) - { - availability = _entries[i]; - return true; - } - - availability = default; - return false; - } - - /// Gets an observed availability state, or unknown when absent. - /// The capability to look up. - /// The explicit state, or when absent. - public RuntimeCapabilityAvailabilityState GetState(RuntimeCapabilityId capability) - { - return TryGet(capability, out var availability) - ? availability.State - : RuntimeCapabilityAvailabilityState.Unknown; - } - - /// - public override bool Equals(object? obj) - { - return obj is RuntimeCapabilities other && Equals(other); - } - - /// - public override int GetHashCode() - { - var hash = new HashCode(); - for (var i = 0; i < _entries.Length; i++) hash.Add(_entries[i]); - return hash.ToHashCode(); - } + private readonly RuntimeCapabilityAvailability[] _entries; + + private RuntimeCapabilities(RuntimeCapabilityAvailability[] entries) + { + _entries = entries; + } + + /// Gets an empty capability set. + public static RuntimeCapabilities Empty + { + get; + } = new(Array.Empty()); + + /// Gets the number of explicit capability observations. + public int Count => _entries.Length; + + /// Gets the ordered observations as a read-only span. + public ReadOnlySpan Entries => _entries; + + /// + public bool Equals(RuntimeCapabilities? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other is null || _entries.Length != other._entries.Length) + { + return false; + } + + for (int i = 0; i < _entries.Length; i++) + { + if (_entries[i] != other._entries[i]) + { + return false; + } + } + + return true; + } + + /// Creates an immutable capability set by copying the supplied observations. + /// The observations to copy. Each capability identifier must be non-empty and unique. + /// The independent, immutable capability set. + /// An identifier is empty or occurs more than once. + public static RuntimeCapabilities Create(ReadOnlySpan entries) + { + if (entries.IsEmpty) + { + return Empty; + } + + RuntimeCapabilityAvailability[] copy = entries.ToArray(); + for (int i = 0; i < copy.Length; i++) + { + if (copy[i].Capability.IsEmpty) + { + throw new ArgumentException("A runtime capability identifier cannot be empty.", nameof(entries)); + } + + for (int previous = 0; previous < i; previous++) + { + if (copy[previous].Capability == copy[i].Capability) + { + throw new ArgumentException("A runtime capability identifier occurs more than once.", + nameof(entries)); + } + } + } + + return new RuntimeCapabilities(copy); + } + + /// Looks up one explicit capability observation. + /// The capability to look up. + /// The matching observation, or the default value when absent. + /// when the set contains . + public bool TryGet(RuntimeCapabilityId capability, out RuntimeCapabilityAvailability availability) + { + for (int i = 0; i < _entries.Length; i++) + { + if (_entries[i].Capability == capability) + { + availability = _entries[i]; + return true; + } + } + + availability = default; + return false; + } + + /// Gets an observed availability state, or unknown when absent. + /// The capability to look up. + /// The explicit state, or when absent. + public RuntimeCapabilityAvailabilityState GetState(RuntimeCapabilityId capability) + { + return TryGet(capability, out RuntimeCapabilityAvailability availability) + ? availability.State + : RuntimeCapabilityAvailabilityState.Unknown; + } + + /// + public override bool Equals(object? obj) + { + return obj is RuntimeCapabilities other && Equals(other); + } + + /// + public override int GetHashCode() + { + HashCode hash = new(); + for (int i = 0; i < _entries.Length; i++) + { + hash.Add(_entries[i]); + } + + return hash.ToHashCode(); + } } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailability.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailability.cs index 94ed87e9..2e4fb60c 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailability.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailability.cs @@ -8,13 +8,13 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The evidence-backed constraints and normal return semantics. [StructLayout(LayoutKind.Sequential)] public readonly record struct RuntimeCapabilityAvailability( - RuntimeCapabilityId Capability, - RuntimeCapabilityAvailabilityState State, - RuntimeCapabilityContract Contract) + RuntimeCapabilityId Capability, + RuntimeCapabilityAvailabilityState State, + RuntimeCapabilityContract Contract) { - /// Gets a value indicating whether the capability was explicitly observed as available. - public bool IsAvailable => State == RuntimeCapabilityAvailabilityState.Available; + /// Gets a value indicating whether the capability was explicitly observed as available. + public bool IsAvailable => State == RuntimeCapabilityAvailabilityState.Available; - /// Gets a value indicating whether the capability was explicitly observed as either available or unavailable. - public bool IsKnown => State != RuntimeCapabilityAvailabilityState.Unknown; + /// Gets a value indicating whether the capability was explicitly observed as either available or unavailable. + public bool IsKnown => State != RuntimeCapabilityAvailabilityState.Unknown; } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailabilityState.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailabilityState.cs index 2544048c..7ca4226f 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailabilityState.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityAvailabilityState.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The observed state of an optional capability. public enum RuntimeCapabilityAvailabilityState : byte { - /// The capability was not probed or cannot be determined by this binding. - Unknown = 0, + /// The capability was not probed or cannot be determined by this binding. + Unknown = 0, - /// The capability was explicitly observed as callable for this runtime snapshot. - Available = 1, + /// The capability was explicitly observed as callable for this runtime snapshot. + Available = 1, - /// The capability was explicitly observed as unavailable for this runtime snapshot. - Unavailable = 2, + /// The capability was explicitly observed as unavailable for this runtime snapshot. + Unavailable = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityContract.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityContract.cs index a9be4744..9086d6b1 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityContract.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityContract.cs @@ -21,13 +21,13 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The normal return shape, or unknown. [StructLayout(LayoutKind.Sequential)] public readonly record struct RuntimeCapabilityContract( - CheatEngineVersion? MinimumCheatEngineVersion, - RuntimeArchitectureScope ArchitectureScope, - RuntimeArchitectureRequirement ArchitectureRequirement, - RuntimeThreadRequirement ThreadRequirement, - RuntimeOwnership Ownership, - RuntimeReturnSemantics ReturnSemantics) + CheatEngineVersion? MinimumCheatEngineVersion, + RuntimeArchitectureScope ArchitectureScope, + RuntimeArchitectureRequirement ArchitectureRequirement, + RuntimeThreadRequirement ThreadRequirement, + RuntimeOwnership Ownership, + RuntimeReturnSemantics ReturnSemantics) { - /// Gets a contract whose evidence fields are all unknown. - public static RuntimeCapabilityContract Unknown => default; + /// Gets a contract whose evidence fields are all unknown. + public static RuntimeCapabilityContract Unknown => default; } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs index d0536447..57f15f61 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs @@ -9,80 +9,80 @@ namespace CheatEngine.SDK.Engine.Runtime; /// public readonly struct RuntimeCapabilityId : IEquatable { - private readonly string? _value; - - /// Initializes a non-empty SDK capability identifier. - /// The stable, non-white-space identifier. - /// is null, empty, or white-space only. - public RuntimeCapabilityId(string value) - { - ArgumentException.ThrowIfNullOrWhiteSpace(value); - _value = value; - } - - /// Gets the capability identifier, or an empty string for the default value. - public string Value => _value ?? string.Empty; - - /// Gets a value indicating whether this is the default, unusable identifier. - public bool IsEmpty => _value is null; - - /// Gets the SDK capability for the CE file-version query. - public static RuntimeCapabilityId CheatEngineVersion => new("Runtime.CheatEngineVersion"); - - /// Gets the SDK capability for the CE host architecture query. - public static RuntimeCapabilityId SystemArchitecture => new("Runtime.SystemArchitecture"); - - /// Gets the SDK capability for the target architecture probes. - public static RuntimeCapabilityId TargetArchitecture => new("Runtime.TargetArchitecture"); - - /// Gets the SDK capability for observing Cheat Engine's currently selected target process. - public static RuntimeCapabilityId CurrentProcess => new("Process.Current"); - - /// Gets the SDK capability for explicit process selection and immediate verification. - public static RuntimeCapabilityId ProcessSelection => new("Process.Selection"); - - /// Gets the SDK capability for the target ABI query. - public static RuntimeCapabilityId TargetAbi => new("Runtime.TargetAbi"); - - /// - public bool Equals(RuntimeCapabilityId other) - { - return string.Equals(_value, other._value, StringComparison.Ordinal); - } - - /// - public override bool Equals(object? obj) - { - return obj is RuntimeCapabilityId other && Equals(other); - } - - /// - public override int GetHashCode() - { - return _value is null ? 0 : StringComparer.Ordinal.GetHashCode(_value); - } - - /// - public override string ToString() - { - return Value; - } - - /// Tests two capability identifiers for ordinal equality. - /// The first identifier. - /// The second identifier. - /// when the identifiers have the same ordinal value. - public static bool operator ==(RuntimeCapabilityId left, RuntimeCapabilityId right) - { - return left.Equals(right); - } - - /// Tests two capability identifiers for ordinal inequality. - /// The first identifier. - /// The second identifier. - /// when the identifiers differ. - public static bool operator !=(RuntimeCapabilityId left, RuntimeCapabilityId right) - { - return !left.Equals(right); - } + private readonly string? _value; + + /// Initializes a non-empty SDK capability identifier. + /// The stable, non-white-space identifier. + /// is null, empty, or white-space only. + public RuntimeCapabilityId(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the capability identifier, or an empty string for the default value. + public string Value => _value ?? string.Empty; + + /// Gets a value indicating whether this is the default, unusable identifier. + public bool IsEmpty => _value is null; + + /// Gets the SDK capability for the CE file-version query. + public static RuntimeCapabilityId CheatEngineVersion => new("Runtime.CheatEngineVersion"); + + /// Gets the SDK capability for the CE host architecture query. + public static RuntimeCapabilityId SystemArchitecture => new("Runtime.SystemArchitecture"); + + /// Gets the SDK capability for the target architecture probes. + public static RuntimeCapabilityId TargetArchitecture => new("Runtime.TargetArchitecture"); + + /// Gets the SDK capability for observing Cheat Engine's currently selected target process. + public static RuntimeCapabilityId CurrentProcess => new("Process.Current"); + + /// Gets the SDK capability for explicit process selection and immediate verification. + public static RuntimeCapabilityId ProcessSelection => new("Process.Selection"); + + /// Gets the SDK capability for the target ABI query. + public static RuntimeCapabilityId TargetAbi => new("Runtime.TargetAbi"); + + /// + public bool Equals(RuntimeCapabilityId other) + { + return string.Equals(_value, other._value, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) + { + return obj is RuntimeCapabilityId other && Equals(other); + } + + /// + public override int GetHashCode() + { + return _value is null ? 0 : StringComparer.Ordinal.GetHashCode(_value); + } + + /// + public override string ToString() + { + return Value; + } + + /// Tests two capability identifiers for ordinal equality. + /// The first identifier. + /// The second identifier. + /// when the identifiers have the same ordinal value. + public static bool operator ==(RuntimeCapabilityId left, RuntimeCapabilityId right) + { + return left.Equals(right); + } + + /// Tests two capability identifiers for ordinal inequality. + /// The first identifier. + /// The second identifier. + /// when the identifiers differ. + public static bool operator !=(RuntimeCapabilityId left, RuntimeCapabilityId right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs index 2774e7f9..36fc5136 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs @@ -13,91 +13,109 @@ namespace CheatEngine.SDK.Engine.Runtime; /// public sealed class RuntimeInfo { - /// Initializes a runtime snapshot from explicit observations. - /// The complete Cheat Engine file version. - /// The CE host architecture reported by getSystemArchitecture. - /// The architecture established by target probes, or unknown. - /// The observed pointer width for the applicable process context, or unknown. - /// The ABI family reported by getABI, or unknown. - /// The immutable optional-capability observations for this snapshot. - /// is . - public RuntimeInfo( - CheatEngineVersion version, - CheatEngineArchitecture systemArchitecture, - CheatEngineArchitecture targetArchitecture, - PointerSize pointerSize, - TargetAbi targetAbi, - RuntimeCapabilities capabilities) - { - Version = version; - SystemArchitecture = systemArchitecture; - TargetArchitecture = targetArchitecture; - PointerSize = pointerSize; - TargetAbi = targetAbi; - Capabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); - } + /// Initializes a runtime snapshot from explicit observations. + /// The complete Cheat Engine file version. + /// The CE host architecture reported by getSystemArchitecture. + /// The architecture established by target probes, or unknown. + /// The observed pointer width for the applicable process context, or unknown. + /// The ABI family reported by getABI, or unknown. + /// The immutable optional-capability observations for this snapshot. + /// is . + public RuntimeInfo( + CheatEngineVersion version, + CheatEngineArchitecture systemArchitecture, + CheatEngineArchitecture targetArchitecture, + PointerSize pointerSize, + TargetAbi targetAbi, + RuntimeCapabilities capabilities) + { + Version = version; + SystemArchitecture = systemArchitecture; + TargetArchitecture = targetArchitecture; + PointerSize = pointerSize; + TargetAbi = targetAbi; + Capabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); + } - /// Gets the complete CE file version supplied for this snapshot. - public CheatEngineVersion Version { get; } + /// Gets the complete CE file version supplied for this snapshot. + public CheatEngineVersion Version + { + get; + } - /// Gets the CE host architecture reported by CE's getSystemArchitecture global. - public CheatEngineArchitecture SystemArchitecture { get; } + /// Gets the CE host architecture reported by CE's getSystemArchitecture global. + public CheatEngineArchitecture SystemArchitecture + { + get; + } - /// Gets the target architecture established by target probes, or unknown. - public CheatEngineArchitecture TargetArchitecture { get; } + /// Gets the target architecture established by target probes, or unknown. + public CheatEngineArchitecture TargetArchitecture + { + get; + } - /// Gets the observed pointer width for the applicable process context, or unknown. - public PointerSize PointerSize { get; } + /// Gets the observed pointer width for the applicable process context, or unknown. + public PointerSize PointerSize + { + get; + } - /// Gets the target ABI family reported by CE's getABI global, or unknown. - public TargetAbi TargetAbi { get; } + /// Gets the target ABI family reported by CE's getABI global, or unknown. + public TargetAbi TargetAbi + { + get; + } - /// Gets the immutable optional-capability observations for this snapshot. - public RuntimeCapabilities Capabilities { get; } + /// Gets the immutable optional-capability observations for this snapshot. + public RuntimeCapabilities Capabilities + { + get; + } - /// Decodes a CE 7.7 getSystemArchitecture result: 0=i386, 1=x86_64, 2=arm32, 3=arm64. - /// The raw CE Lua integer. - /// The decoded architecture, or . - /// only for a documented CE 7.7 discriminant. - public static bool TryDecodeSystemArchitecture(int code, out CheatEngineArchitecture architecture) - { - switch (code) - { - case 0: - architecture = CheatEngineArchitecture.X86; - return true; - case 1: - architecture = CheatEngineArchitecture.X64; - return true; - case 2: - architecture = CheatEngineArchitecture.Arm32; - return true; - case 3: - architecture = CheatEngineArchitecture.Arm64; - return true; - default: - architecture = CheatEngineArchitecture.Unknown; - return false; - } - } + /// Decodes a CE 7.7 getSystemArchitecture result: 0=i386, 1=x86_64, 2=arm32, 3=arm64. + /// The raw CE Lua integer. + /// The decoded architecture, or . + /// only for a documented CE 7.7 discriminant. + public static bool TryDecodeSystemArchitecture(int code, out CheatEngineArchitecture architecture) + { + switch (code) + { + case 0: + architecture = CheatEngineArchitecture.X86; + return true; + case 1: + architecture = CheatEngineArchitecture.X64; + return true; + case 2: + architecture = CheatEngineArchitecture.Arm32; + return true; + case 3: + architecture = CheatEngineArchitecture.Arm64; + return true; + default: + architecture = CheatEngineArchitecture.Unknown; + return false; + } + } - /// Decodes a CE 7.7 getABI result: 0 for Windows and 1 for Unix/Linux. - /// The raw CE Lua integer. - /// The decoded ABI family, or . - /// only for a documented CE 7.7 discriminant. - public static bool TryDecodeTargetAbi(int code, out TargetAbi abi) - { - switch (code) - { - case 0: - abi = TargetAbi.Windows; - return true; - case 1: - abi = TargetAbi.Unix; - return true; - default: - abi = TargetAbi.Unknown; - return false; - } - } + /// Decodes a CE 7.7 getABI result: 0 for Windows and 1 for Unix/Linux. + /// The raw CE Lua integer. + /// The decoded ABI family, or . + /// only for a documented CE 7.7 discriminant. + public static bool TryDecodeTargetAbi(int code, out TargetAbi abi) + { + switch (code) + { + case 0: + abi = TargetAbi.Windows; + return true; + case 1: + abi = TargetAbi.Unix; + return true; + default: + abi = TargetAbi.Unknown; + return false; + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeOwnership.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeOwnership.cs index 89066954..0569a193 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeOwnership.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeOwnership.cs @@ -3,15 +3,15 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The ownership fact attached to a capability contract. public enum RuntimeOwnership : byte { - /// The contract does not establish ownership. - Unknown = 0, + /// The contract does not establish ownership. + Unknown = 0, - /// The capability transfers no native or Lua-owned resource. - None = 1, + /// The capability transfers no native or Lua-owned resource. + None = 1, - /// The capability exposes a resource that remains owned by Cheat Engine or Lua. - Borrowed = 2, + /// The capability exposes a resource that remains owned by Cheat Engine or Lua. + Borrowed = 2, - /// The capability transfers a resource that the caller must release through its documented owner. - Owned = 3, + /// The capability transfers a resource that the caller must release through its documented owner. + Owned = 3 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeReturnSemantics.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeReturnSemantics.cs index d76f4fad..dc643f53 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeReturnSemantics.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeReturnSemantics.cs @@ -3,18 +3,18 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The normal return shape attached to a capability contract. public enum RuntimeReturnSemantics : byte { - /// The contract does not establish a return shape. - Unknown = 0, + /// The contract does not establish a return shape. + Unknown = 0, - /// The capability returns one value on success. - Value = 1, + /// The capability returns one value on success. + Value = 1, - /// The capability may have no value without that absence being a Lua call failure. - OptionalValue = 2, + /// The capability may have no value without that absence being a Lua call failure. + OptionalValue = 2, - /// The capability returns a Boolean status. - BooleanStatus = 3, + /// The capability returns a Boolean status. + BooleanStatus = 3, - /// The capability has no normal return value. - Void = 4, + /// The capability has no normal return value. + Void = 4 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeThreadRequirement.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeThreadRequirement.cs index a334e0e3..82371f92 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeThreadRequirement.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeThreadRequirement.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The thread-affinity fact attached to a capability contract. public enum RuntimeThreadRequirement : byte { - /// The contract does not establish a thread requirement. - Unknown = 0, + /// The contract does not establish a thread requirement. + Unknown = 0, - /// The capability may be used on any thread subject to other runtime serialization rules. - AnyThread = 1, + /// The capability may be used on any thread subject to other runtime serialization rules. + AnyThread = 1, - /// The capability must execute on Cheat Engine's main GUI thread. - MainThread = 2, + /// The capability must execute on Cheat Engine's main GUI thread. + MainThread = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Runtime/TargetAbi.cs b/libs/CheatEngine.SDK.Engine/Runtime/TargetAbi.cs index a9e6efa5..961f6e2f 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/TargetAbi.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/TargetAbi.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Engine.Runtime; /// The target-process ABI family documented by CE 7.7's getABI global. public enum TargetAbi : byte { - /// No target ABI fact is available. - Unknown = 0, + /// No target ABI fact is available. + Unknown = 0, - /// The Windows calling-convention family. - Windows = 1, + /// The Windows calling-convention family. + Windows = 1, - /// The Unix/Linux calling-convention family. - Unix = 2, + /// The Unix/Linux calling-convention family. + Unix = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs index a9e0d09e..90dbe341 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Engine.Enums; namespace CheatEngine.SDK.Engine.Scanning.Aob; @@ -23,90 +24,105 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// public readonly struct AobScanOptions : IEquatable { - /// Initializes options that leave every CE optional argument absent. - public AobScanOptions() - : this(protectionFlags: null, alignmentMethod: FastScanMethod.NotAligned, alignmentParameter: null) - { - } - - /// Initializes AOB scan options. - /// CE's optional protection flag string, or to omit it. - /// The CE alignment rule. - /// - /// The divisor for or hexadecimal trailing digits for - /// ; omitted for . - /// - /// is not a defined CE value. - /// - /// A non-default alignment has no parameter, or an alignment parameter was supplied with no alignment rule. - /// - public AobScanOptions(string? protectionFlags, FastScanMethod alignmentMethod, string? alignmentParameter) - { - if (alignmentMethod is < FastScanMethod.NotAligned or > FastScanMethod.LastDigits) - throw new ArgumentOutOfRangeException(nameof(alignmentMethod), alignmentMethod, - "AOBScan accepts only the CE fsmNotAligned, fsmAligned, or fsmLastDigits alignment values."); - - if (alignmentMethod == FastScanMethod.NotAligned && alignmentParameter is not null) - throw new ArgumentException("An alignment parameter requires an alignment method.", - nameof(alignmentParameter)); - - if (alignmentMethod != FastScanMethod.NotAligned && string.IsNullOrEmpty(alignmentParameter)) - throw new ArgumentException("A non-default AOB alignment method requires a non-empty parameter.", - nameof(alignmentParameter)); - - ProtectionFlags = protectionFlags; - AlignmentMethod = alignmentMethod; - AlignmentParameter = alignmentParameter; - } - - /// Gets options that pass only the AOB pattern. - public static AobScanOptions Default => default; - - /// Gets CE's protection string, or when it is omitted. - public string? ProtectionFlags { get; } - - /// Gets CE's alignment rule. - public FastScanMethod AlignmentMethod { get; } - - /// Gets CE's alignment parameter, or for no alignment. - public string? AlignmentParameter { get; } - - /// Gets a value indicating whether the binding must pass CE's alignment argument positions. - internal bool HasAlignment => AlignmentMethod != FastScanMethod.NotAligned; - - /// - public bool Equals(AobScanOptions other) - { - return string.Equals(ProtectionFlags, other.ProtectionFlags, StringComparison.Ordinal) && - AlignmentMethod == other.AlignmentMethod && - string.Equals(AlignmentParameter, other.AlignmentParameter, StringComparison.Ordinal); - } - - /// - public override bool Equals(object? obj) - { - return obj is AobScanOptions other && Equals(other); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine(ProtectionFlags, AlignmentMethod, AlignmentParameter); - } - - /// Compares AOB scan options by their exact CE argument values. - /// The first value. - /// The second value. - public static bool operator ==(AobScanOptions left, AobScanOptions right) - { - return left.Equals(right); - } - - /// Compares AOB scan options by their exact CE argument values. - /// The first value. - /// The second value. - public static bool operator !=(AobScanOptions left, AobScanOptions right) - { - return !left.Equals(right); - } + /// Initializes options that leave every CE optional argument absent. + public AobScanOptions() + : this(null, FastScanMethod.NotAligned, null) + { + } + + /// Initializes AOB scan options. + /// CE's optional protection flag string, or to omit it. + /// The CE alignment rule. + /// + /// The divisor for or hexadecimal trailing digits for + /// ; omitted for . + /// + /// is not a defined CE value. + /// + /// A non-default alignment has no parameter, or an alignment parameter was supplied with no alignment rule. + /// + public AobScanOptions(string? protectionFlags, FastScanMethod alignmentMethod, string? alignmentParameter) + { + if (alignmentMethod is < FastScanMethod.NotAligned or > FastScanMethod.LastDigits) + { + throw new ArgumentOutOfRangeException(nameof(alignmentMethod), alignmentMethod, + "AOBScan accepts only the CE fsmNotAligned, fsmAligned, or fsmLastDigits alignment values."); + } + + if (alignmentMethod == FastScanMethod.NotAligned && alignmentParameter is not null) + { + throw new ArgumentException("An alignment parameter requires an alignment method.", + nameof(alignmentParameter)); + } + + if (alignmentMethod != FastScanMethod.NotAligned && string.IsNullOrEmpty(alignmentParameter)) + { + throw new ArgumentException("A non-default AOB alignment method requires a non-empty parameter.", + nameof(alignmentParameter)); + } + + ProtectionFlags = protectionFlags; + AlignmentMethod = alignmentMethod; + AlignmentParameter = alignmentParameter; + } + + /// Gets options that pass only the AOB pattern. + public static AobScanOptions Default => default; + + /// Gets CE's protection string, or when it is omitted. + public string? ProtectionFlags + { + get; + } + + /// Gets CE's alignment rule. + public FastScanMethod AlignmentMethod + { + get; + } + + /// Gets CE's alignment parameter, or for no alignment. + public string? AlignmentParameter + { + get; + } + + /// Gets a value indicating whether the binding must pass CE's alignment argument positions. + internal bool HasAlignment => AlignmentMethod != FastScanMethod.NotAligned; + + /// + public bool Equals(AobScanOptions other) + { + return string.Equals(ProtectionFlags, other.ProtectionFlags, StringComparison.Ordinal) && + AlignmentMethod == other.AlignmentMethod && + string.Equals(AlignmentParameter, other.AlignmentParameter, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) + { + return obj is AobScanOptions other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(ProtectionFlags, AlignmentMethod, AlignmentParameter); + } + + /// Compares AOB scan options by their exact CE argument values. + /// The first value. + /// The second value. + public static bool operator ==(AobScanOptions left, AobScanOptions right) + { + return left.Equals(right); + } + + /// Compares AOB scan options by their exact CE argument values. + /// The first value. + /// The second value. + public static bool operator !=(AobScanOptions left, AobScanOptions right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcome.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcome.cs index fb4ee103..5c3ee2b4 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcome.cs @@ -1,10 +1,14 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Engine.Scanning.Aob; -/// A compact, allocation-free outcome for . +/// +/// A compact, allocation-free outcome for +/// . +/// /// /// The result count is meaningful only for and /// . It is the count of the caller-owned host list observed immediately @@ -15,100 +19,119 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; [StructLayout(LayoutKind.Sequential)] public readonly struct AobScanOutcome : IEquatable { - private AobScanOutcome(AobScanOutcomeKind kind, LuaStatus luaStatus, int resultCount) - { - Kind = kind; - LuaStatus = luaStatus; - ResultCount = resultCount; - } - - /// Gets the factual protected scan category. - public AobScanOutcomeKind Kind { get; } - - /// Gets the protected Lua status for a Lua failure; otherwise . - public LuaStatus LuaStatus { get; } - - /// Gets the verified host-list count when is . - public int ResultCount { get; } - - /// Gets whether was read from a valid host StringList and satisfies its outcome invariant. - public bool HasResultCount => Kind switch - { - AobScanOutcomeKind.Matches => ResultCount > 0, - AobScanOutcomeKind.NoMatches => ResultCount == 0, - _ => false, - }; - - /// Gets whether CE returned a valid caller-owned StringList with a verified count. - public bool IsSuccess => HasResultCount; - - /// Creates a positive-match outcome. - /// The verified positive StringList count. - /// is not positive. - public static AobScanOutcome Matches(int resultCount) - { - if (resultCount <= 0) - throw new ArgumentOutOfRangeException(nameof(resultCount), resultCount, - "A positive-match AOB outcome requires a positive StringList count."); - - return new AobScanOutcome(AobScanOutcomeKind.Matches, LuaStatus.Ok, resultCount); - } - - /// Gets the outcome for a valid empty StringList. - public static AobScanOutcome NoMatches => new(AobScanOutcomeKind.NoMatches, LuaStatus.Ok, 0); - - /// Gets the outcome for a missing or non-callable required global. - public static AobScanOutcome GlobalUnavailable => new(AobScanOutcomeKind.GlobalUnavailable, LuaStatus.Ok, 0); - - /// Creates the outcome for a protected Lua failure. - /// The failed protected Lua status. - /// is successful. - public static AobScanOutcome ProtectedLuaFailure(LuaStatus luaStatus) - { - if (luaStatus.IsOk) - throw new ArgumentException("A successful Lua status cannot describe a protected Lua failure.", - nameof(luaStatus)); - - return new AobScanOutcome(AobScanOutcomeKind.ProtectedLuaFailure, luaStatus, 0); - } - - /// Gets the outcome for a raw Lua nil result. - public static AobScanOutcome NoResult => new(AobScanOutcomeKind.NoResult, LuaStatus.Ok, 0); - - /// Gets the outcome for a non-nil value that was not a valid host object. - public static AobScanOutcome InvalidResult => new(AobScanOutcomeKind.InvalidResult, LuaStatus.Ok, 0); - - /// Gets the outcome for a valid host object whose StringList count could not be read. - public static AobScanOutcome ResultListCountUnavailable => - new(AobScanOutcomeKind.ResultListCountUnavailable, LuaStatus.Ok, 0); - - /// - public bool Equals(AobScanOutcome other) - { - return Kind == other.Kind && LuaStatus == other.LuaStatus && ResultCount == other.ResultCount; - } - - /// - public override bool Equals(object? obj) - { - return obj is AobScanOutcome other && Equals(other); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine((int)Kind, LuaStatus, ResultCount); - } - - /// Tests two AOB scan outcomes for equality. - public static bool operator ==(AobScanOutcome left, AobScanOutcome right) - { - return left.Equals(right); - } - - /// Tests two AOB scan outcomes for inequality. - public static bool operator !=(AobScanOutcome left, AobScanOutcome right) - { - return !left.Equals(right); - } + private AobScanOutcome(AobScanOutcomeKind kind, LuaStatus luaStatus, int resultCount) + { + Kind = kind; + LuaStatus = luaStatus; + ResultCount = resultCount; + } + + /// Gets the factual protected scan category. + public AobScanOutcomeKind Kind + { + get; + } + + /// + /// Gets the protected Lua status for a Lua failure; otherwise + /// . + /// + public LuaStatus LuaStatus + { + get; + } + + /// Gets the verified host-list count when is . + public int ResultCount + { + get; + } + + /// + /// Gets whether was read from a valid host StringList and satisfies its outcome + /// invariant. + /// + public bool HasResultCount => Kind switch + { + AobScanOutcomeKind.Matches => ResultCount > 0, + AobScanOutcomeKind.NoMatches => ResultCount == 0, + _ => false + }; + + /// Gets whether CE returned a valid caller-owned StringList with a verified count. + public bool IsSuccess => HasResultCount; + + /// Creates a positive-match outcome. + /// The verified positive StringList count. + /// is not positive. + public static AobScanOutcome Matches(int resultCount) + { + if (resultCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(resultCount), resultCount, + "A positive-match AOB outcome requires a positive StringList count."); + } + + return new AobScanOutcome(AobScanOutcomeKind.Matches, LuaStatus.Ok, resultCount); + } + + /// Gets the outcome for a valid empty StringList. + public static AobScanOutcome NoMatches => new(AobScanOutcomeKind.NoMatches, LuaStatus.Ok, 0); + + /// Gets the outcome for a missing or non-callable required global. + public static AobScanOutcome GlobalUnavailable => new(AobScanOutcomeKind.GlobalUnavailable, LuaStatus.Ok, 0); + + /// Creates the outcome for a protected Lua failure. + /// The failed protected Lua status. + /// is successful. + public static AobScanOutcome ProtectedLuaFailure(LuaStatus luaStatus) + { + if (luaStatus.IsOk) + { + throw new ArgumentException("A successful Lua status cannot describe a protected Lua failure.", + nameof(luaStatus)); + } + + return new AobScanOutcome(AobScanOutcomeKind.ProtectedLuaFailure, luaStatus, 0); + } + + /// Gets the outcome for a raw Lua nil result. + public static AobScanOutcome NoResult => new(AobScanOutcomeKind.NoResult, LuaStatus.Ok, 0); + + /// Gets the outcome for a non-nil value that was not a valid host object. + public static AobScanOutcome InvalidResult => new(AobScanOutcomeKind.InvalidResult, LuaStatus.Ok, 0); + + /// Gets the outcome for a valid host object whose StringList count could not be read. + public static AobScanOutcome ResultListCountUnavailable => + new(AobScanOutcomeKind.ResultListCountUnavailable, LuaStatus.Ok, 0); + + /// + public bool Equals(AobScanOutcome other) + { + return Kind == other.Kind && LuaStatus == other.LuaStatus && ResultCount == other.ResultCount; + } + + /// + public override bool Equals(object? obj) + { + return obj is AobScanOutcome other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine((int) Kind, LuaStatus, ResultCount); + } + + /// Tests two AOB scan outcomes for equality. + public static bool operator ==(AobScanOutcome left, AobScanOutcome right) + { + return left.Equals(right); + } + + /// Tests two AOB scan outcomes for inequality. + public static bool operator !=(AobScanOutcome left, AobScanOutcome right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs index 647b0a3e..6e98b196 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs @@ -9,27 +9,27 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// public enum AobScanOutcomeKind { - /// No scan outcome has been observed. - Unknown, + /// No scan outcome has been observed. + Unknown, - /// CE returned a valid StringList whose verified count is positive. - Matches, + /// CE returned a valid StringList whose verified count is positive. + Matches, - /// CE returned a valid StringList whose verified count is zero. - NoMatches, + /// CE returned a valid StringList whose verified count is zero. + NoMatches, - /// The required AOBScan global was absent or was not callable. - GlobalUnavailable, + /// The required AOBScan global was absent or was not callable. + GlobalUnavailable, - /// A protected Lua global lookup or invocation failed. - ProtectedLuaFailure, + /// A protected Lua global lookup or invocation failed. + ProtectedLuaFailure, - /// CE returned Lua nil; this binding does not reinterpret it as no matches. - NoResult, + /// CE returned Lua nil; this binding does not reinterpret it as no matches. + NoResult, - /// CE returned a non-nil value that was not a valid host object. - InvalidResult, + /// CE returned a non-nil value that was not a valid host object. + InvalidResult, - /// The returned host object did not provide a non-negative StringList count. - ResultListCountUnavailable, + /// The returned host object did not provide a non-negative StringList count. + ResultListCountUnavailable } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs index b88175a3..41dca123 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs @@ -10,18 +10,18 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// public enum AobScanStatus { - /// Cheat Engine returned a caller-owned StringList host object. - Success, + /// Cheat Engine returned a caller-owned StringList host object. + Success, - /// The required AOBScan global was absent or was not callable. - GlobalUnavailable, + /// The required AOBScan global was absent or was not callable. + GlobalUnavailable, - /// The protected global lookup, argument push, or Lua call failed. - LuaFailure, + /// The protected global lookup, argument push, or Lua call failed. + LuaFailure, - /// Cheat Engine returned Lua nil. - NoResult, + /// Cheat Engine returned Lua nil. + NoResult, - /// Cheat Engine returned a non-nil value that was not a host object. - InvalidResult, + /// Cheat Engine returned a non-nil value that was not a host object. + InvalidResult } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs index 30954019..f96f65ee 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.Calls; @@ -41,203 +42,214 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// public static class AobScanner { - private static readonly LuaRef SAobScan = new(); - - /// Runs AOBScan with only its required pattern argument. - /// CE's AOB pattern string, passed without normalization. - /// The caller-owned result list, or on failure/no result. - /// when CE returned a non-null host object. - /// is . - [RequiresPluginEnabled] - public static bool TryScan(string pattern, [NotNullWhen(true)] out Owned? results) - { - return TryScanDetailed(pattern, out results) == AobScanStatus.Success; - } - - /// Runs AOBScan with explicit protection and alignment options. - /// CE's AOB pattern string, passed without normalization. - /// The optional CE arguments and their exact positions. - /// The caller-owned result list, or on failure/no result. - /// when CE returned a non-null host object. - /// is . - [RequiresPluginEnabled] - public static bool TryScan(string pattern, AobScanOptions options, - [NotNullWhen(true)] out Owned? results) - { - return TryScanDetailed(pattern, options, out results) == AobScanStatus.Success; - } - - /// Runs AOBScan with only its required pattern argument and reports its precise result category. - /// CE's AOB pattern string, passed without normalization. - /// The caller-owned result list only when the returned status is . - /// The protected AOBScan outcome without parsing a Lua error message. - /// is . - [RequiresPluginEnabled] - public static AobScanStatus TryScanDetailed(string pattern, out Owned? results) - { - return TryScanDetailed(pattern, AobScanOptions.Default, out results); - } - - /// Runs AOBScan with explicit protection and alignment options and reports its precise result category. - /// CE's AOB pattern string, passed without normalization. - /// The optional CE arguments and their exact positions. - /// The caller-owned result list only when the returned status is . - /// The protected AOBScan outcome without parsing a Lua error message. - /// is . - [RequiresPluginEnabled] - public static AobScanStatus TryScanDetailed(string pattern, AobScanOptions options, - out Owned? results) - { - ArgumentNullException.ThrowIfNull(pattern); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - return TryScanCore(state, pattern, options, out results, out _); - } - - /// Runs AOBScan and reports whether a valid returned StringList contains matches. - /// CE's AOB pattern string, passed without normalization. - /// - /// The caller-owned list when is ; otherwise - /// . Copy required entries before disposing the owner exactly once. - /// - /// - /// A factual outcome that classifies no matches only from a valid StringList with count zero. Raw Lua - /// nil, unavailable globals, protected Lua failures, malformed return values, and unreadable counts remain - /// distinct. - /// - /// is . - [RequiresPluginEnabled] - public static AobScanOutcome TryScanOutcome(string pattern, out Owned? results) - { - return TryScanOutcome(pattern, AobScanOptions.Default, out results); - } - - /// Runs AOBScan with explicit CE protection/alignment options and reports a structured result. - /// CE's AOB pattern string, passed without normalization. - /// The optional CE arguments and their exact positions. - /// - /// The caller-owned list when is ; otherwise - /// . Copy required entries before disposing the owner exactly once. - /// - /// The factual protected AOB result, including a valid empty-list no-match classification. - /// is . - [RequiresPluginEnabled] - public static AobScanOutcome TryScanOutcome(string pattern, AobScanOptions options, - out Owned? results) - { - ArgumentNullException.ThrowIfNull(pattern); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - var status = TryScanCore(state, pattern, options, out results, out var luaStatus); - if (status != AobScanStatus.Success) - return FromStatus(status, luaStatus); - - var owned = results!; - try - { - if (!owned.Value.TryGetCount(out var resultCount) || resultCount < 0) - { - owned.Dispose(); - results = null; - return AobScanOutcome.ResultListCountUnavailable; - } - - return resultCount == 0 ? AobScanOutcome.NoMatches : AobScanOutcome.Matches(resultCount); - } - catch (LuaException exception) - { - owned.Dispose(); - results = null; - return AobScanOutcome.ProtectedLuaFailure(ToFailureStatus(exception.Status)); - } - } - - private static AobScanStatus TryScanCore(LuaState state, string pattern, AobScanOptions options, - out Owned? results, out LuaStatus luaStatus) - { - luaStatus = LuaStatus.Ok; - try - { - var global = LuaGlobalFunctions.TryPushWithOutcome(state, SAobScan, "AOBScan"u8); - if (global.Status == LuaGlobalPushStatus.Unavailable) - { - results = null; - return AobScanStatus.GlobalUnavailable; - } - - if (!global.IsSuccess) - { - results = null; - luaStatus = ToFailureStatus(global.LuaStatus); - return AobScanStatus.LuaFailure; - } - - var argumentCount = PushArguments(state, pattern, options); - luaStatus = state.TryCall(argumentCount, 1); - if (!luaStatus.IsOk) - { - results = null; - return AobScanStatus.LuaFailure; - } - - if (state.IsNil(-1)) - { - results = null; - return AobScanStatus.NoResult; - } - - if (!CEObject.TryRead(state, -1, out var handle)) - { - results = null; - return AobScanStatus.InvalidResult; - } - - results = new Owned(StringList.FromHandle(handle)); - return AobScanStatus.Success; - } - catch (LuaException exception) - { - results = null; - luaStatus = ToFailureStatus(exception.Status); - return AobScanStatus.LuaFailure; - } - } - - private static AobScanOutcome FromStatus(AobScanStatus status, LuaStatus luaStatus) - { - return status switch - { - AobScanStatus.GlobalUnavailable => AobScanOutcome.GlobalUnavailable, - AobScanStatus.LuaFailure => AobScanOutcome.ProtectedLuaFailure(ToFailureStatus(luaStatus)), - AobScanStatus.NoResult => AobScanOutcome.NoResult, - AobScanStatus.InvalidResult => AobScanOutcome.InvalidResult, - _ => AobScanOutcome.ResultListCountUnavailable, - }; - } - - private static LuaStatus ToFailureStatus(LuaStatus luaStatus) - { - return luaStatus.IsOk ? LuaStatus.RuntimeError : luaStatus; - } - - private static int PushArguments(LuaState state, string pattern, AobScanOptions options) - { - StringMarshaller.Push(state, pattern); - if (options.HasAlignment) - { - StringMarshaller.Push(state, options.ProtectionFlags); - Int32Marshaller.Push(state, (int)options.AlignmentMethod); - StringMarshaller.Push(state, options.AlignmentParameter); - return 4; - } - - if (options.ProtectionFlags is null) return 1; - - StringMarshaller.Push(state, options.ProtectionFlags); - return 2; - } + private static readonly LuaRef SAobScan = new(); + + /// Runs AOBScan with only its required pattern argument. + /// CE's AOB pattern string, passed without normalization. + /// The caller-owned result list, or on failure/no result. + /// when CE returned a non-null host object. + /// is . + [RequiresPluginEnabled] + public static bool TryScan(string pattern, [NotNullWhen(true)] out Owned? results) + { + return TryScanDetailed(pattern, out results) == AobScanStatus.Success; + } + + /// Runs AOBScan with explicit protection and alignment options. + /// CE's AOB pattern string, passed without normalization. + /// The optional CE arguments and their exact positions. + /// The caller-owned result list, or on failure/no result. + /// when CE returned a non-null host object. + /// is . + [RequiresPluginEnabled] + public static bool TryScan(string pattern, AobScanOptions options, + [NotNullWhen(true)] out Owned? results) + { + return TryScanDetailed(pattern, options, out results) == AobScanStatus.Success; + } + + /// Runs AOBScan with only its required pattern argument and reports its precise result category. + /// CE's AOB pattern string, passed without normalization. + /// + /// The caller-owned result list only when the returned status is + /// . + /// + /// The protected AOBScan outcome without parsing a Lua error message. + /// is . + [RequiresPluginEnabled] + public static AobScanStatus TryScanDetailed(string pattern, out Owned? results) + { + return TryScanDetailed(pattern, AobScanOptions.Default, out results); + } + + /// Runs AOBScan with explicit protection and alignment options and reports its precise result category. + /// CE's AOB pattern string, passed without normalization. + /// The optional CE arguments and their exact positions. + /// + /// The caller-owned result list only when the returned status is + /// . + /// + /// The protected AOBScan outcome without parsing a Lua error message. + /// is . + [RequiresPluginEnabled] + public static AobScanStatus TryScanDetailed(string pattern, AobScanOptions options, + out Owned? results) + { + ArgumentNullException.ThrowIfNull(pattern); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + return TryScanCore(state, pattern, options, out results, out _); + } + + /// Runs AOBScan and reports whether a valid returned StringList contains matches. + /// CE's AOB pattern string, passed without normalization. + /// + /// The caller-owned list when is ; otherwise + /// . Copy required entries before disposing the owner exactly once. + /// + /// + /// A factual outcome that classifies no matches only from a valid StringList with count zero. Raw Lua + /// nil, unavailable globals, protected Lua failures, malformed return values, and unreadable counts remain + /// distinct. + /// + /// is . + [RequiresPluginEnabled] + public static AobScanOutcome TryScanOutcome(string pattern, out Owned? results) + { + return TryScanOutcome(pattern, AobScanOptions.Default, out results); + } + + /// Runs AOBScan with explicit CE protection/alignment options and reports a structured result. + /// CE's AOB pattern string, passed without normalization. + /// The optional CE arguments and their exact positions. + /// + /// The caller-owned list when is ; otherwise + /// . Copy required entries before disposing the owner exactly once. + /// + /// The factual protected AOB result, including a valid empty-list no-match classification. + /// is . + [RequiresPluginEnabled] + public static AobScanOutcome TryScanOutcome(string pattern, AobScanOptions options, + out Owned? results) + { + ArgumentNullException.ThrowIfNull(pattern); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + AobScanStatus status = TryScanCore(state, pattern, options, out results, out LuaStatus luaStatus); + if (status != AobScanStatus.Success) + { + return FromStatus(status, luaStatus); + } + + Owned owned = results!; + try + { + if (!owned.Value.TryGetCount(out int resultCount) || resultCount < 0) + { + owned.Dispose(); + results = null; + return AobScanOutcome.ResultListCountUnavailable; + } + + return resultCount == 0 ? AobScanOutcome.NoMatches : AobScanOutcome.Matches(resultCount); + } + catch (LuaException exception) + { + owned.Dispose(); + results = null; + return AobScanOutcome.ProtectedLuaFailure(ToFailureStatus(exception.Status)); + } + } + + private static AobScanStatus TryScanCore(LuaState state, string pattern, AobScanOptions options, + out Owned? results, out LuaStatus luaStatus) + { + luaStatus = LuaStatus.Ok; + try + { + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, SAobScan, "AOBScan"u8); + if (global.Status == LuaGlobalPushStatus.Unavailable) + { + results = null; + return AobScanStatus.GlobalUnavailable; + } + + if (!global.IsSuccess) + { + results = null; + luaStatus = ToFailureStatus(global.LuaStatus); + return AobScanStatus.LuaFailure; + } + + int argumentCount = PushArguments(state, pattern, options); + luaStatus = state.TryCall(argumentCount, 1); + if (!luaStatus.IsOk) + { + results = null; + return AobScanStatus.LuaFailure; + } + + if (state.IsNil(-1)) + { + results = null; + return AobScanStatus.NoResult; + } + + if (!CEObject.TryRead(state, -1, out CEObject handle)) + { + results = null; + return AobScanStatus.InvalidResult; + } + + results = new Owned(StringList.FromHandle(handle)); + return AobScanStatus.Success; + } + catch (LuaException exception) + { + results = null; + luaStatus = ToFailureStatus(exception.Status); + return AobScanStatus.LuaFailure; + } + } + + private static AobScanOutcome FromStatus(AobScanStatus status, LuaStatus luaStatus) + { + return status switch + { + AobScanStatus.GlobalUnavailable => AobScanOutcome.GlobalUnavailable, + AobScanStatus.LuaFailure => AobScanOutcome.ProtectedLuaFailure(ToFailureStatus(luaStatus)), + AobScanStatus.NoResult => AobScanOutcome.NoResult, + AobScanStatus.InvalidResult => AobScanOutcome.InvalidResult, + _ => AobScanOutcome.ResultListCountUnavailable + }; + } + + private static LuaStatus ToFailureStatus(LuaStatus luaStatus) + { + return luaStatus.IsOk ? LuaStatus.RuntimeError : luaStatus; + } + + private static int PushArguments(LuaState state, string pattern, AobScanOptions options) + { + StringMarshaller.Push(state, pattern); + if (options.HasAlignment) + { + StringMarshaller.Push(state, options.ProtectionFlags); + Int32Marshaller.Push(state, (int) options.AlignmentMethod); + StringMarshaller.Push(state, options.AlignmentParameter); + return 4; + } + + if (options.ProtectionFlags is null) + { + return 1; + } + + StringMarshaller.Push(state, options.ProtectionFlags); + return 2; + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs index 41a91a05..4855a561 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs @@ -22,115 +22,157 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public readonly struct FirstScanRequest { - /// Initializes a complete first-scan request. - /// The initial comparison mode. - /// The value type to scan. - /// The floating-point comparison rule. - /// The primary scan text; use an empty string when the option does not need it. - /// The secondary scan text; use an empty string when the option does not need it. - /// The first target address to consider. - /// The last target address to consider. - /// The CE protection-flags text, such as +W-C. - /// The address-alignment rule. - /// The alignment rule's CE string parameter. - /// Whether CE interprets the inputs as hexadecimal text. - /// Whether a binary type's input is decimal rather than a bit string. - /// Whether a string scan uses UTF-16 rather than CE's normal UTF-8 mode. - /// Whether a string scan matches case. - public FirstScanRequest( - ScanOption scanOption, - VariableType variableType, - RoundingType roundingType, - string input1, - string input2, - Address startAddress, - Address stopAddress, - string protectionFlags, - FastScanMethod fastScanMethod, - string alignmentParameter, - bool isHexadecimalInput, - bool isNotBinaryString, - bool isUnicodeScan, - bool isCaseSensitive) - { - ScanOption = scanOption; - VariableType = variableType; - RoundingType = roundingType; - Input1 = input1; - Input2 = input2; - StartAddress = startAddress; - StopAddress = stopAddress; - ProtectionFlags = protectionFlags; - FastScanMethod = fastScanMethod; - AlignmentParameter = alignmentParameter; - IsHexadecimalInput = isHexadecimalInput; - IsNotBinaryString = isNotBinaryString; - IsUnicodeScan = isUnicodeScan; - IsCaseSensitive = isCaseSensitive; - } - - /// Gets the CE first-scan comparison mode. - public ScanOption ScanOption { get; } - - /// Gets the CE value type. - public VariableType VariableType { get; } - - /// Gets the CE floating-point rounding rule. - public RoundingType RoundingType { get; } - - /// Gets the primary scan text. - public string Input1 { get; } - - /// Gets the secondary scan text. - public string Input2 { get; } - - /// Gets the first target address to consider. - public Address StartAddress { get; } - - /// Gets the last target address to consider. - public Address StopAddress { get; } - - /// Gets CE's protection-flags text. - public string ProtectionFlags { get; } - - /// Gets CE's fast-scan alignment method. - public FastScanMethod FastScanMethod { get; } - - /// Gets CE's string alignment parameter. - public string AlignmentParameter { get; } - - /// Gets whether CE parses the input text as hexadecimal. - public bool IsHexadecimalInput { get; } - - /// Gets whether a binary input is decimal rather than a bit string. - public bool IsNotBinaryString { get; } - - /// Gets whether a string scan uses UTF-16. - public bool IsUnicodeScan { get; } - - /// Gets whether a string scan is case-sensitive. - public bool IsCaseSensitive { get; } - - /// Builds an exact-value request over the full 64-bit target address range. - /// The value type to scan. - /// The exact value text. - /// A request using CE's normal rounded, non-aligned and non-hexadecimal settings. - public static FirstScanRequest ExactValue(VariableType variableType, string input) - { - return new FirstScanRequest( - ScanOption.ExactValue, - variableType, - RoundingType.Rounded, - input, - string.Empty, - Address.Zero, - new Address(ulong.MaxValue), - string.Empty, - FastScanMethod.NotAligned, - string.Empty, - isHexadecimalInput: false, - isNotBinaryString: false, - isUnicodeScan: false, - isCaseSensitive: false); - } + /// Initializes a complete first-scan request. + /// The initial comparison mode. + /// The value type to scan. + /// The floating-point comparison rule. + /// The primary scan text; use an empty string when the option does not need it. + /// The secondary scan text; use an empty string when the option does not need it. + /// The first target address to consider. + /// The last target address to consider. + /// The CE protection-flags text, such as +W-C. + /// The address-alignment rule. + /// The alignment rule's CE string parameter. + /// Whether CE interprets the inputs as hexadecimal text. + /// Whether a binary type's input is decimal rather than a bit string. + /// Whether a string scan uses UTF-16 rather than CE's normal UTF-8 mode. + /// Whether a string scan matches case. + public FirstScanRequest( + ScanOption scanOption, + VariableType variableType, + RoundingType roundingType, + string input1, + string input2, + Address startAddress, + Address stopAddress, + string protectionFlags, + FastScanMethod fastScanMethod, + string alignmentParameter, + bool isHexadecimalInput, + bool isNotBinaryString, + bool isUnicodeScan, + bool isCaseSensitive) + { + ScanOption = scanOption; + VariableType = variableType; + RoundingType = roundingType; + Input1 = input1; + Input2 = input2; + StartAddress = startAddress; + StopAddress = stopAddress; + ProtectionFlags = protectionFlags; + FastScanMethod = fastScanMethod; + AlignmentParameter = alignmentParameter; + IsHexadecimalInput = isHexadecimalInput; + IsNotBinaryString = isNotBinaryString; + IsUnicodeScan = isUnicodeScan; + IsCaseSensitive = isCaseSensitive; + } + + /// Gets the CE first-scan comparison mode. + public ScanOption ScanOption + { + get; + } + + /// Gets the CE value type. + public VariableType VariableType + { + get; + } + + /// Gets the CE floating-point rounding rule. + public RoundingType RoundingType + { + get; + } + + /// Gets the primary scan text. + public string Input1 + { + get; + } + + /// Gets the secondary scan text. + public string Input2 + { + get; + } + + /// Gets the first target address to consider. + public Address StartAddress + { + get; + } + + /// Gets the last target address to consider. + public Address StopAddress + { + get; + } + + /// Gets CE's protection-flags text. + public string ProtectionFlags + { + get; + } + + /// Gets CE's fast-scan alignment method. + public FastScanMethod FastScanMethod + { + get; + } + + /// Gets CE's string alignment parameter. + public string AlignmentParameter + { + get; + } + + /// Gets whether CE parses the input text as hexadecimal. + public bool IsHexadecimalInput + { + get; + } + + /// Gets whether a binary input is decimal rather than a bit string. + public bool IsNotBinaryString + { + get; + } + + /// Gets whether a string scan uses UTF-16. + public bool IsUnicodeScan + { + get; + } + + /// Gets whether a string scan is case-sensitive. + public bool IsCaseSensitive + { + get; + } + + /// Builds an exact-value request over the full 64-bit target address range. + /// The value type to scan. + /// The exact value text. + /// A request using CE's normal rounded, non-aligned and non-hexadecimal settings. + public static FirstScanRequest ExactValue(VariableType variableType, string input) + { + return new FirstScanRequest( + ScanOption.ExactValue, + variableType, + RoundingType.Rounded, + input, + string.Empty, + Address.Zero, + new Address(ulong.MaxValue), + string.Empty, + FastScanMethod.NotAligned, + string.Empty, + false, + false, + false, + false); + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/FoundList.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/FoundList.cs index 5967df0b..1512ee4e 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/FoundList.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/FoundList.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Values; @@ -34,143 +35,146 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; [LuaClass("FoundList")] public readonly struct FoundList : ICEObject, IEquatable { - private readonly CEObject _handle; - - /// Wraps a borrowed FoundList handle without validating its native class. - /// The native handle; a null handle gives the default value. - public FoundList(CEObject handle) - { - _handle = handle; - } - - /// - public CEObject Handle => _handle; - - /// - public static FoundList FromHandle(CEObject handle) - { - return new FoundList(handle); - } - - /// Gets a value indicating whether this is the null handle. - public bool IsNull => _handle.IsNull; - - /// Attempts to read the CE Count property. - /// The number of results, when the method returns . - /// - /// when CE raised or did not return a non-negative 64-bit Lua integer. CE stores this - /// count as UInt64; values outside Lua's signed 64-bit integer range are rejected rather than wrapped. - /// - /// The plugin is not enabled or has no host object pusher. - public bool TryGetCount(out ulong count) - { - if (_handle.TryGetProperty("Count"u8, out var signedCount) && signedCount >= 0) - { - count = (ulong)signedCount; - return true; - } - - count = default; - return false; - } - - /// Attempts to read the exact address text at a zero-based result index. - /// The CE result index, beginning at zero. - /// The copied address text when the method returns . - /// when CE raised or did not return a string. - /// is negative. - /// The plugin is not enabled or has no host object pusher. - public bool TryGetAddressText(int zeroBasedIndex, [NotNullWhen(true)] out string? address) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - return TryCallString("getAddress"u8, zeroBasedIndex, out address); - } - - /// Attempts to read and parse the address at a zero-based result index. - /// The CE result index, beginning at zero. - /// The parsed target address when the method returns . - /// - /// when CE raised, did not return text, or returned text that is not a hexadecimal - /// target address. - /// - /// is negative. - /// The plugin is not enabled or has no host object pusher. - public bool TryGetAddress(int zeroBasedIndex, out Address address) - { - if (TryGetAddressText(zeroBasedIndex, out var text) && Address.TryParse(text, out address)) return true; - - address = default; - return false; - } - - /// Attempts to read the exact value text at a zero-based result index. - /// The CE result index, beginning at zero. - /// The copied value text when the method returns . - /// when CE raised or did not return a string. - /// is negative. - /// The plugin is not enabled or has no host object pusher. - public bool TryGetValueText(int zeroBasedIndex, [NotNullWhen(true)] out string? value) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - return TryCallString("getValue"u8, zeroBasedIndex, out value); - } - - /// Compares two borrowed handles by native pointer value. - /// The first handle. - /// The second handle. - /// when both handles carry the same pointer. - public static bool operator ==(FoundList left, FoundList right) - { - return left.Equals(right); - } - - /// Compares two borrowed handles by native pointer value. - /// The first handle. - /// The second handle. - /// when the pointers differ. - public static bool operator !=(FoundList left, FoundList right) - { - return !left.Equals(right); - } - - /// - public bool Equals(FoundList other) - { - return _handle.Equals(other._handle); - } - - /// - public override bool Equals(object? obj) - { - return obj is FoundList other && Equals(other); - } - - /// - public override int GetHashCode() - { - return _handle.GetHashCode(); - } - - /// Formats the underlying native handle. - /// The underlying representation. - public override string ToString() - { - return _handle.ToString(); - } - - private bool TryCallString(ReadOnlySpan method, int index, [NotNullWhen(true)] out string? value) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - state.PushInteger(index); - if (!_handle.TryCallMethod(state, method, 1, 1).IsOk || !StringMarshaller.TryRead(state, -1, out var text)) - { - value = null; - return false; - } - - value = text; - return true; - } + private readonly CEObject _handle; + + /// Wraps a borrowed FoundList handle without validating its native class. + /// The native handle; a null handle gives the default value. + public FoundList(CEObject handle) + { + _handle = handle; + } + + /// + public CEObject Handle => _handle; + + /// + public static FoundList FromHandle(CEObject handle) + { + return new FoundList(handle); + } + + /// Gets a value indicating whether this is the null handle. + public bool IsNull => _handle.IsNull; + + /// Attempts to read the CE Count property. + /// The number of results, when the method returns . + /// + /// when CE raised or did not return a non-negative 64-bit Lua integer. CE stores this + /// count as UInt64; values outside Lua's signed 64-bit integer range are rejected rather than wrapped. + /// + /// The plugin is not enabled or has no host object pusher. + public bool TryGetCount(out ulong count) + { + if (_handle.TryGetProperty("Count"u8, out long signedCount) && signedCount >= 0) + { + count = (ulong) signedCount; + return true; + } + + count = default; + return false; + } + + /// Attempts to read the exact address text at a zero-based result index. + /// The CE result index, beginning at zero. + /// The copied address text when the method returns . + /// when CE raised or did not return a string. + /// is negative. + /// The plugin is not enabled or has no host object pusher. + public bool TryGetAddressText(int zeroBasedIndex, [NotNullWhen(true)] out string? address) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + return TryCallString("getAddress"u8, zeroBasedIndex, out address); + } + + /// Attempts to read and parse the address at a zero-based result index. + /// The CE result index, beginning at zero. + /// The parsed target address when the method returns . + /// + /// when CE raised, did not return text, or returned text that is not a hexadecimal + /// target address. + /// + /// is negative. + /// The plugin is not enabled or has no host object pusher. + public bool TryGetAddress(int zeroBasedIndex, out Address address) + { + if (TryGetAddressText(zeroBasedIndex, out string? text) && Address.TryParse(text, out address)) + { + return true; + } + + address = default; + return false; + } + + /// Attempts to read the exact value text at a zero-based result index. + /// The CE result index, beginning at zero. + /// The copied value text when the method returns . + /// when CE raised or did not return a string. + /// is negative. + /// The plugin is not enabled or has no host object pusher. + public bool TryGetValueText(int zeroBasedIndex, [NotNullWhen(true)] out string? value) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + return TryCallString("getValue"u8, zeroBasedIndex, out value); + } + + /// Compares two borrowed handles by native pointer value. + /// The first handle. + /// The second handle. + /// when both handles carry the same pointer. + public static bool operator ==(FoundList left, FoundList right) + { + return left.Equals(right); + } + + /// Compares two borrowed handles by native pointer value. + /// The first handle. + /// The second handle. + /// when the pointers differ. + public static bool operator !=(FoundList left, FoundList right) + { + return !left.Equals(right); + } + + /// + public bool Equals(FoundList other) + { + return _handle.Equals(other._handle); + } + + /// + public override bool Equals(object? obj) + { + return obj is FoundList other && Equals(other); + } + + /// + public override int GetHashCode() + { + return _handle.GetHashCode(); + } + + /// Formats the underlying native handle. + /// The underlying representation. + public override string ToString() + { + return _handle.ToString(); + } + + private bool TryCallString(ReadOnlySpan method, int index, [NotNullWhen(true)] out string? value) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + state.PushInteger(index); + if (!_handle.TryCallMethod(state, method, 1, 1).IsOk || !StringMarshaller.TryRead(state, -1, out string? text)) + { + value = null; + return false; + } + + value = text; + return true; + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemScan.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemScan.cs index a119b8bd..a76099e9 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemScan.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemScan.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Objects; @@ -29,67 +30,67 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; [LuaClass("MemScan")] public readonly struct MemScan : ICEObject, IEquatable { - private readonly CEObject _handle; + private readonly CEObject _handle; - /// Wraps a borrowed MemScan handle without validating its native class. - /// The native handle; a null handle gives the default value. - public MemScan(CEObject handle) - { - _handle = handle; - } + /// Wraps a borrowed MemScan handle without validating its native class. + /// The native handle; a null handle gives the default value. + public MemScan(CEObject handle) + { + _handle = handle; + } - /// - public CEObject Handle => _handle; + /// + public CEObject Handle => _handle; - /// - public static MemScan FromHandle(CEObject handle) - { - return new MemScan(handle); - } + /// + public static MemScan FromHandle(CEObject handle) + { + return new MemScan(handle); + } - /// Gets a value indicating whether this is the null handle. - public bool IsNull => _handle.IsNull; + /// Gets a value indicating whether this is the null handle. + public bool IsNull => _handle.IsNull; - /// Compares two borrowed handles by native pointer value. - /// The first handle. - /// The second handle. - /// when both handles carry the same pointer. - public static bool operator ==(MemScan left, MemScan right) - { - return left.Equals(right); - } + /// Compares two borrowed handles by native pointer value. + /// The first handle. + /// The second handle. + /// when both handles carry the same pointer. + public static bool operator ==(MemScan left, MemScan right) + { + return left.Equals(right); + } - /// Compares two borrowed handles by native pointer value. - /// The first handle. - /// The second handle. - /// when the pointers differ. - public static bool operator !=(MemScan left, MemScan right) - { - return !left.Equals(right); - } + /// Compares two borrowed handles by native pointer value. + /// The first handle. + /// The second handle. + /// when the pointers differ. + public static bool operator !=(MemScan left, MemScan right) + { + return !left.Equals(right); + } - /// - public bool Equals(MemScan other) - { - return _handle.Equals(other._handle); - } + /// + public bool Equals(MemScan other) + { + return _handle.Equals(other._handle); + } - /// - public override bool Equals(object? obj) - { - return obj is MemScan other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is MemScan other && Equals(other); + } - /// - public override int GetHashCode() - { - return _handle.GetHashCode(); - } + /// + public override int GetHashCode() + { + return _handle.GetHashCode(); + } - /// Formats the underlying native handle. - /// The underlying representation. - public override string ToString() - { - return _handle.ToString(); - } + /// Formats the underlying native handle. + /// The underlying representation. + public override string ToString() + { + return _handle.ToString(); + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs index d78bc0ef..ad252902 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs @@ -7,12 +7,12 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public enum MemoryScanCancellationMilestone : byte { - /// No cancellation request was observed by the most recent cancellable session operation. - None = 0, + /// No cancellation request was observed by the most recent cancellable session operation. + None = 0, - /// Cancellation was observed before the SDK began its native CE call. - CancelledBeforeNativeCall = 1, + /// Cancellation was observed before the SDK began its native CE call. + CancelledBeforeNativeCall = 1, - /// Cancellation was observed only after the synchronous native CE call had returned. - ObservedAfterNativeCall = 2, + /// Cancellation was observed only after the synchronous native CE call had returned. + ObservedAfterNativeCall = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs index 2b084500..f4409bb2 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs @@ -7,30 +7,30 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public enum MemoryScanCreationStatus : byte { - /// The scanner and its distinct found-list child were created and adopted by a session. - Success = 0, + /// The scanner and its distinct found-list child were created and adopted by a session. + Success = 0, - /// The required CE factory global was absent or was not callable. - GlobalUnavailable = 1, + /// The required CE factory global was absent or was not callable. + GlobalUnavailable = 1, - /// A protected factory call failed before returning its documented result. - LuaFailure = 2, + /// A protected factory call failed before returning its documented result. + LuaFailure = 2, - /// The scanner factory returned its documented absent result, . - NoScannerResult = 3, + /// The scanner factory returned its documented absent result, . + NoScannerResult = 3, - /// The scanner factory returned a non-null value that was not a CE host object. - InvalidScannerResult = 4, + /// The scanner factory returned a non-null value that was not a CE host object. + InvalidScannerResult = 4, - /// The found-list factory returned its documented absent result, . - NoFoundListResult = 5, + /// The found-list factory returned its documented absent result, . + NoFoundListResult = 5, - /// The found-list factory returned a non-null value that was not a CE host object. - InvalidFoundListResult = 6, + /// The found-list factory returned a non-null value that was not a CE host object. + InvalidFoundListResult = 6, - /// The found-list factory returned the scanner object, so publishing a second owner was refused. - AliasedFoundList = 7, + /// The found-list factory returned the scanner object, so publishing a second owner was refused. + AliasedFoundList = 7, - /// A rollback destroy call began but Cheat Engine did not confirm all required cleanup operations. - RollbackUnconfirmed = 8, + /// A rollback destroy call began but Cheat Engine did not confirm all required cleanup operations. + RollbackUnconfirmed = 8 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanException.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanException.cs index 6b9764bf..f530ca4f 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanException.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanException.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Engine.Scanning.Values; @@ -14,17 +15,23 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public sealed class MemoryScanException : InvalidOperationException { - internal MemoryScanException(MemoryScanFailureKind kind, string operation, string message, - LuaException? innerException = null) - : base(message, innerException) - { - FailureKind = kind; - Operation = operation; - } + internal MemoryScanException(MemoryScanFailureKind kind, string operation, string message, + LuaException? innerException = null) + : base(message, innerException) + { + FailureKind = kind; + Operation = operation; + } - /// Gets the stable category of the failed operation. - public MemoryScanFailureKind FailureKind { get; } + /// Gets the stable category of the failed operation. + public MemoryScanFailureKind FailureKind + { + get; + } - /// Gets the stable SDK operation identifier that failed. - public string Operation { get; } + /// Gets the stable SDK operation identifier that failed. + public string Operation + { + get; + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanFailureKind.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanFailureKind.cs index 1f5ef4fa..e2658150 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanFailureKind.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanFailureKind.cs @@ -3,21 +3,21 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// Classifies a non-state failure from a operation. public enum MemoryScanFailureKind { - /// The required CE Lua global was absent or was not a function. - MissingCapability = 0, + /// The required CE Lua global was absent or was not a function. + MissingCapability = 0, - /// A protected CE Lua property or method operation raised an error. - LuaError = 1, + /// A protected CE Lua property or method operation raised an error. + LuaError = 1, - /// The host returned a value that does not satisfy the documented CE Lua result shape. - UnexpectedResult = 2, + /// The host returned a value that does not satisfy the documented CE Lua result shape. + UnexpectedResult = 2, - /// The session's persistent CE objects belong to an earlier Lua attachment or state generation. - RuntimeInvalidated = 3, + /// The session's persistent CE objects belong to an earlier Lua attachment or state generation. + RuntimeInvalidated = 3, - /// The target required by the session could not be qualified as its originally observed incarnation. - TargetIdentityUnavailable = 4, + /// The target required by the session could not be qualified as its originally observed incarnation. + TargetIdentityUnavailable = 4, - /// The currently qualified target differs from the session's originally observed incarnation. - TargetIdentityMismatch = 5, + /// The currently qualified target differs from the session's originally observed incarnation. + TargetIdentityMismatch = 5 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs index 85196df3..9289d454 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs @@ -3,18 +3,18 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// Records why a memory-scan session can no longer safely continue its previous lifecycle. public enum MemoryScanInvalidationReason : byte { - /// No invalidation reason has been recorded. - None = 0, + /// No invalidation reason has been recorded. + None = 0, - /// A protected CE operation began but did not complete with its declared result. - ProtectedLuaFailure = 1, + /// A protected CE operation began but did not complete with its declared result. + ProtectedLuaFailure = 1, - /// The runtime attach epoch or Lua-state generation no longer matches the owned CE objects. - RuntimeIdentityChanged = 2, + /// The runtime attach epoch or Lua-state generation no longer matches the owned CE objects. + RuntimeIdentityChanged = 2, - /// The selected target differs from the target incarnation observed when the session was created. - TargetChanged = 3, + /// The selected target differs from the target incarnation observed when the session was created. + TargetChanged = 3, - /// The observed target reused the original process identifier with a different start time. - TargetProcessReused = 4, + /// The observed target reused the original process identifier with a different start time. + TargetProcessReused = 4 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs index 463a960f..88371597 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs @@ -3,30 +3,30 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// Classifies a bounded, copied memory-scan result materialization attempt. public enum MemoryScanMaterializationStatus : byte { - /// Every result was copied into the caller-supplied destination. - Success = 0, + /// Every result was copied into the caller-supplied destination. + Success = 0, - /// The scan completed successfully but its initialized found list contains no rows. - NoResults = 1, + /// The scan completed successfully but its initialized found list contains no rows. + NoResults = 1, - /// The complete result set exceeds the caller-supplied bounded destination; no row was written. - DestinationTooSmall = 2, + /// The complete result set exceeds the caller-supplied bounded destination; no row was written. + DestinationTooSmall = 2, - /// Cancellation was observed before a row was copied; the destination remains unchanged. - Cancelled = 3, + /// Cancellation was observed before a row was copied; the destination remains unchanged. + Cancelled = 3, - /// The session belongs to a previous Lua runtime attachment or state generation. - RuntimeInvalidated = 4, + /// The session belongs to a previous Lua runtime attachment or state generation. + RuntimeInvalidated = 4, - /// The current target cannot be qualified as the session's original target. - TargetIdentityUnavailable = 5, + /// The current target cannot be qualified as the session's original target. + TargetIdentityUnavailable = 5, - /// The current target is not the session's original target incarnation. - TargetIdentityMismatch = 6, + /// The current target is not the session's original target incarnation. + TargetIdentityMismatch = 6, - /// A protected CE operation failed while reading the result set. - LuaFailure = 7, + /// A protected CE operation failed while reading the result set. + LuaFailure = 7, - /// CE returned a count, address, or value that does not satisfy the declared scan contract. - InvalidResult = 8, + /// CE returned a count, address, or value that does not satisfy the declared scan contract. + InvalidResult = 8 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanResult.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanResult.cs index 439f2eed..b488e0d8 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanResult.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanResult.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Values; namespace CheatEngine.SDK.Engine.Scanning.Values; diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs index 6d8167b6..88e3fb46 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Threading; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.Enums; @@ -41,816 +42,955 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public sealed class MemoryScanSession : IDisposable { - // These are public exception identifiers. Keep CE's exact member names at the private call sites instead. - private const string FirstScanOperation = "MemoryScan.FirstScan"; - private const string NextScanOperation = "MemoryScan.NextScan"; - private const string WaitForCompletionOperation = "MemoryScan.WaitForCompletion"; - private const string InitializeResultsOperation = "MemoryScan.InitializeResults"; - private const string DeinitializeResultsOperation = "MemoryScan.DeinitializeResults"; - private const string ResetOperation = "MemoryScan.Reset"; - private const string ResultCountOperation = "MemoryScan.ResultCount"; - private const string ResultAddressOperation = "MemoryScan.ResultAddress"; - private const string ResultValueOperation = "MemoryScan.ResultValue"; - private Owned? _foundList; - private Owned? _scanner; - private bool _isBound; - private LuaStateIdentity _runtimeIdentity; - private TargetSelectionObservation _targetObservation; - - private MemoryScanSession(Owned scanner, Owned foundList) - { - _scanner = scanner; - _foundList = foundList; - State = MemoryScanState.New; - } - - /// Gets the session's conservative, managed state. - public MemoryScanState State { get; private set; } - - /// Gets the attach epoch and state generation that own this session's CE objects. - /// - /// A differing current identity means that this session's raw CE handles must never be used through the new Lua - /// universe. The value is diagnostic data; callers cannot manufacture a matching identity. - /// - public LuaStateIdentity RuntimeIdentity => _runtimeIdentity; - - /// Gets the target observation captured before this session's factory/adoption publication. - /// - /// An unqualified observation is retained as evidence rather than replaced with a guessed target. Such a session - /// cannot begin scan or result operations until a factory/adoption path has captured a qualified incarnation. - /// - public TargetSelectionObservation TargetObservation => _targetObservation; - - /// Gets the most recent target-incarnation validation made for a session operation, if any. - public TargetIdentityCheck? LastTargetCheck { get; private set; } - - /// Gets why the session was conservatively invalidated, or . - public MemoryScanInvalidationReason InvalidationReason { get; private set; } - - /// Gets how cancellation intersected the most recent cancellable scan or materialization operation. - public MemoryScanCancellationMilestone LastCancellationMilestone { get; private set; } - - /// - /// Gets the scanner as a borrowed handle. Direct raw operations on this value bypass the session's state checks; - /// prefer the session members for the scan lifecycle. - /// - /// The session was disposed. - [RequiresPluginEnabled] - public MemScan Scanner - { - get - { - ThrowIfDisposed(); - RequireEnabledMainThread(); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, "MemoryScan.Scanner"); - return _scanner!.Value; - } - } - - /// - /// Gets the attached found list as a borrowed handle, only after it is initialized for reading. Direct raw - /// operations on the returned value bypass the session's state checks. - /// - /// The results are not ready. - /// The session was disposed. - [RequiresPluginEnabled] - public FoundList Results - { - get - { - RequireState("Results", MemoryScanState.ResultsReady); - RequireEnabledMainThread(); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, "MemoryScan.Results"); - return _foundList!.Value; - } - } - - /// Gets the number of readable results in the initialized found list. - /// - /// Cheat Engine stores the count as UInt64. The Lua boundary supplies a signed 64-bit integer, so this - /// property exposes its non-negative range as and rejects an unrepresentable host result. - /// Result-reading methods intentionally retain CE's index parameter and therefore address - /// only indices from zero through Int32.MaxValue. - /// - /// The results are not ready. - /// CE did not return a valid non-negative integer count. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public ulong ResultCount - { - get - { - RequireEnabledMainThread(); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResultCountOperation); - return ReadResultCount(operation.State, RequireResults()); - } - } - - /// - /// Releases the readable list when necessary, then destroys the owned found-list child before the owned scanner - /// parent. Never retries a destruction and is safe to call more than once. - /// - /// - /// A different runtime identity or target incarnation marks the session - /// with its before this method throws without releasing either owner. A merely - /// unavailable target leaves the current state intact. A caller may retry only while the original context remains - /// current; otherwise it must explicitly the managed owners. Once destruction begins, it - /// follows and does not retry a protected CE failure. - /// - /// - /// The plugin is attached and the caller is not on Cheat Engine's main thread. - /// - // Attached-worker cleanup is unsafe. The documented exception preserves both owners so disposal can be retried on - // the main thread; making IDisposable.Dispose non-throwing here would either leak them or violate thread affinity. + // These are public exception identifiers. Keep CE's exact member names at the private call sites instead. + private const string FirstScanOperation = "MemoryScan.FirstScan"; + private const string NextScanOperation = "MemoryScan.NextScan"; + private const string WaitForCompletionOperation = "MemoryScan.WaitForCompletion"; + private const string InitializeResultsOperation = "MemoryScan.InitializeResults"; + private const string DeinitializeResultsOperation = "MemoryScan.DeinitializeResults"; + private const string ResetOperation = "MemoryScan.Reset"; + private const string ResultCountOperation = "MemoryScan.ResultCount"; + private const string ResultAddressOperation = "MemoryScan.ResultAddress"; + private const string ResultValueOperation = "MemoryScan.ResultValue"; + private Owned? _foundList; + private bool _isBound; + private Owned? _scanner; + private TargetSelectionObservation _targetObservation; + + private MemoryScanSession(Owned scanner, Owned foundList) + { + _scanner = scanner; + _foundList = foundList; + State = MemoryScanState.New; + } + + /// Gets the session's conservative, managed state. + public MemoryScanState State + { + get; + private set; + } + + /// Gets the attach epoch and state generation that own this session's CE objects. + /// + /// A differing current identity means that this session's raw CE handles must never be used through the new Lua + /// universe. The value is diagnostic data; callers cannot manufacture a matching identity. + /// + public LuaStateIdentity RuntimeIdentity + { + get; + private set; + } + + /// Gets the target observation captured before this session's factory/adoption publication. + /// + /// An unqualified observation is retained as evidence rather than replaced with a guessed target. Such a session + /// cannot begin scan or result operations until a factory/adoption path has captured a qualified incarnation. + /// + public TargetSelectionObservation TargetObservation => _targetObservation; + + /// Gets the most recent target-incarnation validation made for a session operation, if any. + public TargetIdentityCheck? LastTargetCheck + { + get; + private set; + } + + /// Gets why the session was conservatively invalidated, or . + public MemoryScanInvalidationReason InvalidationReason + { + get; + private set; + } + + /// Gets how cancellation intersected the most recent cancellable scan or materialization operation. + public MemoryScanCancellationMilestone LastCancellationMilestone + { + get; + private set; + } + + /// + /// Gets the scanner as a borrowed handle. Direct raw operations on this value bypass the session's state checks; + /// prefer the session members for the scan lifecycle. + /// + /// The session was disposed. + [RequiresPluginEnabled] + public MemScan Scanner + { + get + { + ThrowIfDisposed(); + RequireEnabledMainThread(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, "MemoryScan.Scanner"); + return _scanner!.Value; + } + } + + /// + /// Gets the attached found list as a borrowed handle, only after it is initialized for reading. Direct raw + /// operations on the returned value bypass the session's state checks. + /// + /// The results are not ready. + /// The session was disposed. + [RequiresPluginEnabled] + public FoundList Results + { + get + { + RequireState("Results", MemoryScanState.ResultsReady); + RequireEnabledMainThread(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, "MemoryScan.Results"); + return _foundList!.Value; + } + } + + /// Gets the number of readable results in the initialized found list. + /// + /// Cheat Engine stores the count as UInt64. The Lua boundary supplies a signed 64-bit integer, so this + /// property exposes its non-negative range as and rejects an unrepresentable host result. + /// Result-reading methods intentionally retain CE's index parameter and therefore address + /// only indices from zero through Int32.MaxValue. + /// + /// The results are not ready. + /// CE did not return a valid non-negative integer count. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public ulong ResultCount + { + get + { + RequireEnabledMainThread(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResultCountOperation); + return ReadResultCount(operation.State, RequireResults()); + } + } + + /// + /// Releases the readable list when necessary, then destroys the owned found-list child before the owned scanner + /// parent. Never retries a destruction and is safe to call more than once. + /// + /// + /// A different runtime identity or target incarnation marks the session + /// with its before this method throws without releasing either owner. A merely + /// unavailable target leaves the current state intact. A caller may retry only while the original context remains + /// current; otherwise it must explicitly the managed owners. Once destruction begins, it + /// follows and does not retry a protected CE failure. + /// + /// + /// The plugin is attached and the caller is not on Cheat Engine's main thread. + /// + // Attached-worker cleanup is unsafe. The documented exception preserves both owners so disposal can be retried on + // the main thread; making IDisposable.Dispose non-throwing here would either leak them or violate thread affinity. #pragma warning disable S3877 - [MainThreadOnly] - public void Dispose() - { - if (State == MemoryScanState.Disposed) return; - - // Detached or reattached cleanup cannot prove that this is the original Lua/target context, so context - // validation below preserves both owners and requires the explicit Abandon recovery path. An attached worker - // thread is different: attempting CE cleanup there is unsafe, so reject it before touching the Lua stack. - if (LuaRuntime.IsAttached && !LuaRuntime.IsMainThread) - throw new InvalidOperationException( - "Memory scan disposal must run on Cheat Engine's main thread while the plugin is attached."); - - // Admit the complete cleanup before publishing any lifetime change. Owned retains an owner when it cannot - // begin destroy(), so this session must retain both owners in that case as well. - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - EnsureCurrentContext(state, "MemoryScan.Dispose"); - var foundList = _foundList!; - var scanner = _scanner!; - - if (State == MemoryScanState.ResultsReady) - { - using LuaFrame frame = new(state); - _ = foundList.Value.Handle.TryCallMethod(state, "deinitialize"u8, 0, 0); - } - - // Each frame removes a protected-call error before the next destroy. TryDestroy consumes an owner only once its - // protected invocation begins; the outer admitted operation keeps the binding stable for both child and parent. - using (LuaFrame frame = new(state)) - { - _ = foundList.TryDestroy(state); - } - - using (LuaFrame frame = new(state)) - { - _ = scanner.TryDestroy(state); - } - - _foundList = null; - _scanner = null; - State = MemoryScanState.Disposed; - } + [MainThreadOnly] + public void Dispose() + { + if (State == MemoryScanState.Disposed) + { + return; + } + + // Detached or reattached cleanup cannot prove that this is the original Lua/target context, so context + // validation below preserves both owners and requires the explicit Abandon recovery path. An attached worker + // thread is different: attempting CE cleanup there is unsafe, so reject it before touching the Lua stack. + if (LuaRuntime.IsAttached && !LuaRuntime.IsMainThread) + { + throw new InvalidOperationException( + "Memory scan disposal must run on Cheat Engine's main thread while the plugin is attached."); + } + + // Admit the complete cleanup before publishing any lifetime change. Owned retains an owner when it cannot + // begin destroy(), so this session must retain both owners in that case as well. + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + EnsureCurrentContext(state, "MemoryScan.Dispose"); + Owned foundList = _foundList!; + Owned scanner = _scanner!; + + if (State == MemoryScanState.ResultsReady) + { + using LuaFrame frame = new(state); + _ = foundList.Value.Handle.TryCallMethod(state, "deinitialize"u8, 0, 0); + } + + // Each frame removes a protected-call error before the next destroy. TryDestroy consumes an owner only once its + // protected invocation begins; the outer admitted operation keeps the binding stable for both child and parent. + using (LuaFrame frame = new(state)) + { + _ = foundList.TryDestroy(state); + } + + using (LuaFrame frame = new(state)) + { + _ = scanner.TryDestroy(state); + } + + _foundList = null; + _scanner = null; + State = MemoryScanState.Disposed; + } #pragma warning restore S3877 - /// - /// Stops managed cleanup without invoking CE and makes this session unusable. - /// - /// - /// This is an explicit recovery path for a session whose original Lua runtime or target cannot be validated. - /// It deliberately does not claim that native destruction occurred, and it must not be used as normal cleanup. - /// The found-list owner is abandoned before the scanner owner to preserve the parent/child ownership direction. - /// - public void Abandon() - { - if (State == MemoryScanState.Disposed) return; - - if (_foundList is not null && !_foundList.IsDisposed) _ = _foundList.Abandon(); - if (_scanner is not null && !_scanner.IsDisposed) _ = _scanner.Abandon(); - _foundList = null; - _scanner = null; - State = MemoryScanState.Disposed; - } - - /// - /// Transfers two explicit ownership wrappers into a session. The source wrappers become empty; the returned - /// session is then their only intended destroy owner. - /// - /// An owned scanner whose ownership has been proven by the caller's binding. - /// An owned result-list child attached to . - /// A new session in . - /// Either ownership wrapper is . - /// Either ownership wrapper was already released or disposed. - /// - /// is preferred for ordinary CE 7.7 code because it owns the - /// concrete factory sequence and rolls back a created parent when child creation fails. This method is for an - /// SDK-sourced binding that already carries the same ownership proof; the constructor is - /// internal, so normal consumers cannot turn an arbitrary borrowed handle into one of these owners. - /// - public static MemoryScanSession Adopt(Owned scanner, Owned foundList) - { - RequireEnabledMainThread(); - using var operation = LuaRuntime.AcquireOperation(); - var session = AdoptUnbound(scanner, foundList); - session.Bind(MemoryScanSessionContext.Capture(operation.State)); - return session; - } - - internal static MemoryScanSession AdoptUnbound(Owned scanner, Owned foundList) - { - return AdoptCore(scanner, foundList, CreateAdoptedSession); - } - - // Preparing both destinations first means an allocation failure during session construction leaves both source - // wrappers intact for the factory's child-before-parent rollback. - internal static MemoryScanSession AdoptCore(Owned scanner, Owned foundList, - MemoryScanSessionAdopter adopter) - { - ArgumentNullException.ThrowIfNull(scanner); - ArgumentNullException.ThrowIfNull(foundList); - ArgumentNullException.ThrowIfNull(adopter); - - // Value validates each source wrapper before preparation. The wrappers are deliberately single-owner and - // unsynchronized, exactly like Owned; callers must not concurrently dispose them. - _ = scanner.Value; - _ = foundList.Value; - var adoptedScanner = scanner.PrepareTransfer(); - var adoptedFoundList = foundList.PrepareTransfer(); - var session = adopter(adoptedScanner, adoptedFoundList); - scanner.CompleteTransfer(adoptedScanner); - foundList.CompleteTransfer(adoptedFoundList); - return session; - } - - private static MemoryScanSession CreateAdoptedSession(Owned scanner, Owned foundList) - { - return new MemoryScanSession(scanner, foundList); - } - - internal void Bind(MemoryScanSessionContext context) - { - if (_isBound) - throw new InvalidOperationException("A memory scan session cannot be bound to two runtime contexts."); - - _runtimeIdentity = context.RuntimeIdentity; - _targetObservation = context.TargetObservation; - _isBound = true; - } - - /// Begins a CE first scan with all fourteen documented positional arguments. - /// The complete first-scan request. - /// The session is not new. - /// The request contains an unsupported first-scan option or value type. - /// A required CE string argument is . - /// The protected CE method call failed. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public void StartFirstScan(in FirstScanRequest request) - { - StartFirstScanCancellable(in request, CancellationToken.None); - } - - /// Begins a CE first scan and observes cancellation only before or after the synchronous CE call. - /// The complete first-scan request. - /// A cooperative cancellation observation token; it cannot interrupt CE. - /// Cancellation was observed before the CE firstScan call began. - [MainThreadOnly] - [RequiresPluginEnabled] - public void StartFirstScanCancellable(in FirstScanRequest request, CancellationToken cancellationToken) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.None; - RequireEnabledMainThread(); - RequireState("StartFirstScan", MemoryScanState.New); - ValidateFirstRequest(in request); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, FirstScanOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); - - // A CE error may happen after it accepts some scan setup. Do not report the old New state after a partial call. - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallFirstScan(operation.State, _scanner!.Value, in request); - State = MemoryScanState.Scanning; - ObserveCancellationAfterNativeCall(cancellationToken); - } - - /// Begins a CE next scan over the previous readable result set. - /// The complete next-scan request. - /// The session has not completed a first or previous next scan. - /// The request contains an unsupported next-scan option. - /// A required CE string argument is . - /// The protected CE method call failed. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public void StartNextScan(in NextScanRequest request) - { - StartNextScanCancellable(in request, CancellationToken.None); - } - - /// Begins a CE next scan and observes cancellation only before or after the synchronous CE call. - /// The complete next-scan request. - /// A cooperative cancellation observation token; it cannot interrupt CE. - /// Cancellation was observed before the CE nextScan call began. - [MainThreadOnly] - [RequiresPluginEnabled] - public void StartNextScanCancellable(in NextScanRequest request, CancellationToken cancellationToken) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.None; - RequireEnabledMainThread(); - RequireState("StartNextScan", MemoryScanState.ResultsReady); - ValidateNextRequest(in request); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, NextScanOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); - - // The list stops being readable as soon as this session releases it. A failed deinitialize or nextScan leaves - // the conservative Invalidated state, from which Reset is the only recovery. - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); - CallNextScan(operation.State, _scanner!.Value, in request); - State = MemoryScanState.Scanning; - ObserveCancellationAfterNativeCall(cancellationToken); - } - - /// - /// Waits through CE's no-timeout waitTillDone() form, then initializes the attached found list only after - /// CE reports completion. - /// - /// The session is not scanning. - /// The protected CE call failed. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public void WaitForCompletion() - { - WaitForCompletionCancellable(CancellationToken.None); - } - - /// - /// Waits through CE's synchronous no-timeout waitTillDone() form and records whether cancellation was - /// observed before or after that native call. - /// - /// A cooperative cancellation observation token; it cannot interrupt CE. - /// Cancellation was observed before CE waitTillDone() began. - [MainThreadOnly] - [RequiresPluginEnabled] - public void WaitForCompletionCancellable(CancellationToken cancellationToken) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.None; - RequireEnabledMainThread(); - RequireState("WaitForCompletion", MemoryScanState.Scanning); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, WaitForCompletionOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); - - try - { - CallWaitTillDone(operation.State, _scanner!.Value); - ObserveCancellationAfterNativeCall(cancellationToken); - - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallNoResult(operation.State, _foundList!.Value.Handle, "initialize"u8, InitializeResultsOperation); - State = MemoryScanState.ResultsReady; - } - catch - { - // A wait error may mean CE is still scanning, completed, or left a partially materialized result set. - // Never leave the session in Scanning when it cannot safely decide which of those is true. - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - throw; - } - } - - /// - /// Releases any initialized readable view and asks CE to clear the current scan results through - /// MemScan.newScan. - /// - /// The session is scanning or disposed. - /// The protected CE method call failed. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public void Reset() - { - ResetCancellable(CancellationToken.None); - } - - /// Resets the CE scan only when cancellation was not observed before the first native cleanup call. - /// A cooperative cancellation observation token; it cannot interrupt CE. - /// Cancellation was observed before CE cleanup began. - [MainThreadOnly] - [RequiresPluginEnabled] - public void ResetCancellable(CancellationToken cancellationToken) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.None; - RequireEnabledMainThread(); - ThrowIfDisposed(); - if (State == MemoryScanState.New) return; - if (State == MemoryScanState.Scanning) ThrowWrongState("Reset"); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResetOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); - - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); - CallNoResult(operation.State, _scanner!.Value.Handle, "newScan"u8, ResetOperation); - State = MemoryScanState.New; - InvalidationReason = MemoryScanInvalidationReason.None; - ObserveCancellationAfterNativeCall(cancellationToken); - } - - /// Attempts to read the parsed target address at a zero-based result index. - /// - /// The CE found-list index, beginning at zero. This API deliberately supports only the managed - /// index range even when is larger. - /// - /// The parsed target address when the method returns . - /// when the index is outside the current result count. - /// is negative. - /// The results are not ready. - /// CE failed or returned an address string that cannot be parsed. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public bool TryGetAddress(int zeroBasedIndex, out Address address) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - RequireEnabledMainThread(); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResultAddressOperation); - var foundList = RequireResults(); - if ((ulong)zeroBasedIndex >= ReadResultCount(operation.State, foundList)) - { - address = default; - return false; - } - - var text = CallString(operation.State, foundList.Handle, "getAddress"u8, ResultAddressOperation, zeroBasedIndex); - if (Address.TryParse(text, out address)) return true; - - throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, ResultAddressOperation, - "The memory scan result address was not a hexadecimal target address."); - } - - /// Attempts to read the exact value text at a zero-based result index. - /// - /// The CE found-list index, beginning at zero. This API deliberately supports only the managed - /// index range even when is larger. - /// - /// The copied CE value text when the method returns . - /// when the index is outside the current result count. - /// is negative. - /// The results are not ready. - /// The protected CE method call failed or did not return text. - /// The plugin is not enabled or the caller is not on its main thread. - [MainThreadOnly] - [RequiresPluginEnabled] - public bool TryGetValue(int zeroBasedIndex, [NotNullWhen(true)] out string? value) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - RequireEnabledMainThread(); - using var operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResultValueOperation); - var foundList = RequireResults(); - if ((ulong)zeroBasedIndex >= ReadResultCount(operation.State, foundList)) - { - value = null; - return false; - } - - value = CallString(operation.State, foundList.Handle, "getValue"u8, ResultValueOperation, zeroBasedIndex); - return true; - } - - /// Copies the complete initialized found list into a caller-bounded managed destination. - /// The caller-owned storage for copied address/value rows. - /// The complete CE row count when it was read successfully; otherwise zero. - /// The copied row count, zero unless the returned status is . - /// A success, empty-result, capacity, cancellation, context, Lua, or malformed-result category. - /// - /// This method never returns a CE handle, enumerable, or deferred producer. It reads the count once, refuses a - /// destination that cannot hold every row without issuing row calls, builds a temporary complete snapshot, then - /// copies it into only on success. It is a low-level bounded copy primitive; - /// workflow-level cardinality and progress policy remain outside the SDK session. - /// - [MainThreadOnly] - [RequiresPluginEnabled] - public MemoryScanMaterializationStatus TryCopyResults(Span destination, out ulong totalCount, - out int written) - { - return TryCopyResultsCancellable(destination, out totalCount, out written, CancellationToken.None); - } - - /// Copies the complete initialized found list while observing cancellation between synchronous CE row calls. - /// The caller-owned storage for copied address/value rows. - /// The complete CE row count when it was read successfully; otherwise zero. - /// The copied row count, zero unless the returned status is . - /// A cooperative cancellation observation token; it is checked between copied rows. - /// A success, empty-result, capacity, cancellation, context, Lua, or malformed-result category. - /// - /// Cancellation prevents later row calls or publication of the temporary snapshot. It cannot interrupt a row - /// call that Cheat Engine has already begun. - /// - [MainThreadOnly] - [RequiresPluginEnabled] - public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span destination, - out ulong totalCount, out int written, CancellationToken cancellationToken) - { - totalCount = 0; - written = 0; - LastCancellationMilestone = MemoryScanCancellationMilestone.None; - RequireEnabledMainThread(); - if (State != MemoryScanState.ResultsReady) ThrowWrongState("TryCopyResults"); - - using var operation = LuaRuntime.AcquireOperation(); - var context = TryEnsureCurrentContext(operation.State, ResultCountOperation); - if (context != MemoryScanMaterializationStatus.Success) return context; - if (cancellationToken.IsCancellationRequested) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; - return MemoryScanMaterializationStatus.Cancelled; - } - - var foundList = RequireResults(); - try - { - totalCount = ReadResultCount(operation.State, foundList); - if (totalCount == 0) return MemoryScanMaterializationStatus.NoResults; - if (totalCount > int.MaxValue || totalCount > (ulong)destination.Length) - return MemoryScanMaterializationStatus.DestinationTooSmall; - - var snapshot = new MemoryScanResult[(int)totalCount]; - for (var index = 0; index < snapshot.Length; index++) - { - if (cancellationToken.IsCancellationRequested) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; - return MemoryScanMaterializationStatus.Cancelled; - } - - var addressText = CallString(operation.State, foundList.Handle, "getAddress"u8, ResultAddressOperation, - index); - if (!Address.TryParse(addressText, out var address)) return MemoryScanMaterializationStatus.InvalidResult; - - var value = CallString(operation.State, foundList.Handle, "getValue"u8, ResultValueOperation, index); - snapshot[index] = new MemoryScanResult(address, value); - } - - if (cancellationToken.IsCancellationRequested) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; - return MemoryScanMaterializationStatus.Cancelled; - } - - snapshot.AsSpan().CopyTo(destination); - written = snapshot.Length; - return MemoryScanMaterializationStatus.Success; - } - catch (MemoryScanException exception) - { - return exception.FailureKind switch - { - MemoryScanFailureKind.LuaError => MemoryScanMaterializationStatus.LuaFailure, - _ => MemoryScanMaterializationStatus.InvalidResult, - }; - } - } - - private FoundList RequireResults() - { - RequireState("results", MemoryScanState.ResultsReady); - return _foundList!.Value; - } - - private void RequireState(string operation, MemoryScanState expected) - { - ThrowIfDisposed(); - if (State != expected) ThrowWrongState(operation); - } - - private void ThrowIfDisposed() - { - if (State == MemoryScanState.Disposed) - throw new ObjectDisposedException(nameof(MemoryScanSession), "The memory-scan session was disposed."); - } - - [DoesNotReturn] - private void ThrowWrongState(string operation) - { - throw new MemoryScanStateException(operation, State); - } - - private static void RequireEnabledMainThread() - { - if (!LuaRuntime.IsAttached) - throw new InvalidOperationException( - "The Cheat Engine plugin is not enabled, so the memory scan cannot acquire its Lua state."); - if (!LuaRuntime.IsMainThread) - throw new InvalidOperationException( - "Memory scan operations must run on Cheat Engine's main thread; the session does not dispatch work implicitly."); - } - - private static void ValidateFirstRequest(in FirstScanRequest request) - { - // A default-initialized request can contain null strings. These are properties of the - // value-type request rather than parameters of this helper, therefore its parameter name - // must be the actual public argument ("request") instead of a local alias. - ArgumentNullException.ThrowIfNull(request.Input1, nameof(request)); - ArgumentNullException.ThrowIfNull(request.Input2, nameof(request)); - ArgumentNullException.ThrowIfNull(request.ProtectionFlags, nameof(request)); - ArgumentNullException.ThrowIfNull(request.AlignmentParameter, nameof(request)); - - if (request.ScanOption is < ScanOption.UnknownValue or > ScanOption.SmallerThan) - throw new ArgumentException( - "A first scan only accepts UnknownValue, ExactValue, ValueBetween, BiggerThan or SmallerThan.", - nameof(request)); - // CE 7.7 celua.txt line 2587 lists vtGrouped in addition to the contiguous Byte..All range. - if ((uint)request.VariableType > (uint)VariableType.All && request.VariableType != VariableType.Grouped) - throw new ArgumentException("The CE 7.7 firstScan contract accepts Byte through All and Grouped.", - nameof(request)); - if (request.RoundingType is < RoundingType.Rounded or > RoundingType.Truncated) - throw new ArgumentException("The rounding type is not a CE 7.7 value.", nameof(request)); - if (request.FastScanMethod is < FastScanMethod.NotAligned or > FastScanMethod.LastDigits) - throw new ArgumentException("The fast scan method is not a CE 7.7 value.", nameof(request)); - } - - private static void ValidateNextRequest(in NextScanRequest request) - { - ArgumentNullException.ThrowIfNull(request.Input1, nameof(request)); - ArgumentNullException.ThrowIfNull(request.Input2, nameof(request)); - if (request.ScanOption is < ScanOption.ExactValue or > ScanOption.Unchanged) - throw new ArgumentException("A next scan only accepts ExactValue through Unchanged, never UnknownValue.", - nameof(request)); - if (request.RoundingType is < RoundingType.Rounded or > RoundingType.Truncated) - throw new ArgumentException("The rounding type is not a CE 7.7 value.", nameof(request)); - } - - private void ThrowIfCancelledBeforeNativeCall(CancellationToken cancellationToken) - { - LastCancellationMilestone = MemoryScanCancellationMilestone.None; - if (!cancellationToken.IsCancellationRequested) return; - - LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; - throw new OperationCanceledException(cancellationToken); - } - - private void ObserveCancellationAfterNativeCall(CancellationToken cancellationToken) - { - LastCancellationMilestone = cancellationToken.IsCancellationRequested - ? MemoryScanCancellationMilestone.ObservedAfterNativeCall - : MemoryScanCancellationMilestone.None; - } - - private void EnsureCurrentContext(LuaState state, string operation) - { - var status = TryEnsureCurrentContext(state, operation); - if (status == MemoryScanMaterializationStatus.Success) return; - - var failureKind = status switch - { - MemoryScanMaterializationStatus.RuntimeInvalidated => MemoryScanFailureKind.RuntimeInvalidated, - MemoryScanMaterializationStatus.TargetIdentityMismatch => MemoryScanFailureKind.TargetIdentityMismatch, - _ => MemoryScanFailureKind.TargetIdentityUnavailable, - }; - throw new MemoryScanException(failureKind, operation, - "The memory scan session cannot use its original runtime and target context for operation '" + operation + "'."); - } - - private MemoryScanMaterializationStatus TryEnsureCurrentContext(LuaState state, string operation) - { - if (!_isBound || _runtimeIdentity != LuaRuntime.CurrentStateIdentity) - { - Invalidate(MemoryScanInvalidationReason.RuntimeIdentityChanged); - return MemoryScanMaterializationStatus.RuntimeInvalidated; - } - - if (!_targetObservation.Incarnation.HasValue) - { - LastTargetCheck = TargetSelection.CreateUnavailableCheck(_targetObservation); - return MemoryScanMaterializationStatus.TargetIdentityUnavailable; - } - - var top = state.Top; - TargetIdentityCheck check; - try - { - check = TargetSelection.ValidateCurrent(state, _targetObservation.Incarnation.GetValueOrDefault()); - } - finally - { - state.SetTop(top); - } - LastTargetCheck = check; - if (check.IsCurrent) return MemoryScanMaterializationStatus.Success; - - if (check.Kind is TargetIdentityCheckKind.TargetChanged or TargetIdentityCheckKind.ProcessReused) - { - Invalidate(check.Kind == TargetIdentityCheckKind.TargetChanged - ? MemoryScanInvalidationReason.TargetChanged - : MemoryScanInvalidationReason.TargetProcessReused); - return MemoryScanMaterializationStatus.TargetIdentityMismatch; - } - - return MemoryScanMaterializationStatus.TargetIdentityUnavailable; - } - - private void Invalidate(MemoryScanInvalidationReason reason) - { - if (State == MemoryScanState.Disposed) return; - - State = MemoryScanState.Invalidated; - InvalidationReason = reason; - } - - private static void CallFirstScan(LuaState state, MemScan scanner, in FirstScanRequest request) - { - using LuaFrame frame = new(state); - EnumMarshaller.Push(state, request.ScanOption); - EnumMarshaller.Push(state, request.VariableType); - EnumMarshaller.Push(state, request.RoundingType); - StringMarshaller.Push(state, request.Input1); - StringMarshaller.Push(state, request.Input2); - state.PushInteger(request.StartAddress.ToInt64()); - state.PushInteger(request.StopAddress.ToInt64()); - StringMarshaller.Push(state, request.ProtectionFlags); - EnumMarshaller.Push(state, request.FastScanMethod); - StringMarshaller.Push(state, request.AlignmentParameter); - BooleanMarshaller.Push(state, request.IsHexadecimalInput); - BooleanMarshaller.Push(state, request.IsNotBinaryString); - BooleanMarshaller.Push(state, request.IsUnicodeScan); - BooleanMarshaller.Push(state, request.IsCaseSensitive); - - var status = scanner.Handle.TryCallMethod(state, "firstScan"u8, 14, 0); - if (!status.IsOk) ThrowLua(state, status, FirstScanOperation); - } - - private static void CallNextScan(LuaState state, MemScan scanner, in NextScanRequest request) - { - using LuaFrame frame = new(state); - EnumMarshaller.Push(state, request.ScanOption); - EnumMarshaller.Push(state, request.RoundingType); - StringMarshaller.Push(state, request.Input1); - StringMarshaller.Push(state, request.Input2); - BooleanMarshaller.Push(state, request.IsHexadecimalInput); - BooleanMarshaller.Push(state, request.IsNotBinaryString); - BooleanMarshaller.Push(state, request.IsUnicodeScan); - BooleanMarshaller.Push(state, request.IsCaseSensitive); - BooleanMarshaller.Push(state, request.IsPercentageScan); - - var argumentCount = 9; - if (request.SavedResultName is not null) - { - StringMarshaller.Push(state, request.SavedResultName); - argumentCount = 10; - } - - var status = scanner.Handle.TryCallMethod(state, "nextScan"u8, argumentCount, 0); - if (!status.IsOk) ThrowLua(state, status, NextScanOperation); - } - - private static void CallWaitTillDone(LuaState state, MemScan scanner) - { - using LuaFrame frame = new(state); - var status = scanner.Handle.TryCallMethod(state, "waitTillDone"u8, 0, 0); - if (!status.IsOk) ThrowLua(state, status, WaitForCompletionOperation); - } - - private static ulong ReadResultCount(LuaState state, FoundList foundList) - { - using LuaFrame frame = new(state); - var status = foundList.Handle.TryCallMethod(state, "getCount"u8, 0, 1); - if (!status.IsOk) ThrowLua(state, status, ResultCountOperation); - if (!Int64Marshaller.TryRead(state, -1, out var count) || count < 0) - throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, ResultCountOperation, - "The memory scan result count was not a non-negative 64-bit Lua integer."); - - return (ulong)count; - } - - private static string CallString(LuaState state, CEObject target, ReadOnlySpan method, string operation, - int index) - { - using LuaFrame frame = new(state); - state.PushInteger(index); - var status = target.TryCallMethod(state, method, 1, 1); - if (!status.IsOk) ThrowLua(state, status, operation); - if (StringMarshaller.TryRead(state, -1, out var value)) return value; - - throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, operation, - "The memory scan operation did not return text."); - } - - private static void CallNoResult(LuaState state, CEObject target, ReadOnlySpan method, string operation) - { - using LuaFrame frame = new(state); - var status = target.TryCallMethod(state, method, 0, 0); - if (!status.IsOk) ThrowLua(state, status, operation); - } - - [DoesNotReturn] - private static void ThrowLua(LuaState state, LuaStatus status, string operation) - { - var error = LuaError.FromStack(state, status); - throw new MemoryScanException(MemoryScanFailureKind.LuaError, operation, - "The protected Lua call for memory scan operation '" + operation + "' failed.", new LuaException(error)); - } + /// + /// Stops managed cleanup without invoking CE and makes this session unusable. + /// + /// + /// This is an explicit recovery path for a session whose original Lua runtime or target cannot be validated. + /// It deliberately does not claim that native destruction occurred, and it must not be used as normal cleanup. + /// The found-list owner is abandoned before the scanner owner to preserve the parent/child ownership direction. + /// + public void Abandon() + { + if (State == MemoryScanState.Disposed) + { + return; + } + + if (_foundList is not null && !_foundList.IsDisposed) + { + _ = _foundList.Abandon(); + } + + if (_scanner is not null && !_scanner.IsDisposed) + { + _ = _scanner.Abandon(); + } + + _foundList = null; + _scanner = null; + State = MemoryScanState.Disposed; + } + + /// + /// Transfers two explicit ownership wrappers into a session. The source wrappers become empty; the returned + /// session is then their only intended destroy owner. + /// + /// An owned scanner whose ownership has been proven by the caller's binding. + /// An owned result-list child attached to . + /// A new session in . + /// Either ownership wrapper is . + /// Either ownership wrapper was already released or disposed. + /// + /// is preferred for ordinary CE 7.7 code because it owns the + /// concrete factory sequence and rolls back a created parent when child creation fails. This method is for an + /// SDK-sourced binding that already carries the same ownership proof; the constructor is + /// internal, so normal consumers cannot turn an arbitrary borrowed handle into one of these owners. + /// + public static MemoryScanSession Adopt(Owned scanner, Owned foundList) + { + RequireEnabledMainThread(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanSession session = AdoptUnbound(scanner, foundList); + session.Bind(MemoryScanSessionContext.Capture(operation.State)); + return session; + } + + internal static MemoryScanSession AdoptUnbound(Owned scanner, Owned foundList) + { + return AdoptCore(scanner, foundList, CreateAdoptedSession); + } + + // Preparing both destinations first means an allocation failure during session construction leaves both source + // wrappers intact for the factory's child-before-parent rollback. + internal static MemoryScanSession AdoptCore(Owned scanner, Owned foundList, + MemoryScanSessionAdopter adopter) + { + ArgumentNullException.ThrowIfNull(scanner); + ArgumentNullException.ThrowIfNull(foundList); + ArgumentNullException.ThrowIfNull(adopter); + + // Value validates each source wrapper before preparation. The wrappers are deliberately single-owner and + // unsynchronized, exactly like Owned; callers must not concurrently dispose them. + _ = scanner.Value; + _ = foundList.Value; + Owned adoptedScanner = scanner.PrepareTransfer(); + Owned adoptedFoundList = foundList.PrepareTransfer(); + MemoryScanSession session = adopter(adoptedScanner, adoptedFoundList); + scanner.CompleteTransfer(adoptedScanner); + foundList.CompleteTransfer(adoptedFoundList); + return session; + } + + private static MemoryScanSession CreateAdoptedSession(Owned scanner, Owned foundList) + { + return new MemoryScanSession(scanner, foundList); + } + + internal void Bind(MemoryScanSessionContext context) + { + if (_isBound) + { + throw new InvalidOperationException("A memory scan session cannot be bound to two runtime contexts."); + } + + RuntimeIdentity = context.RuntimeIdentity; + _targetObservation = context.TargetObservation; + _isBound = true; + } + + /// Begins a CE first scan with all fourteen documented positional arguments. + /// The complete first-scan request. + /// The session is not new. + /// The request contains an unsupported first-scan option or value type. + /// A required CE string argument is . + /// The protected CE method call failed. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public void StartFirstScan(in FirstScanRequest request) + { + StartFirstScanCancellable(in request, CancellationToken.None); + } + + /// Begins a CE first scan and observes cancellation only before or after the synchronous CE call. + /// The complete first-scan request. + /// A cooperative cancellation observation token; it cannot interrupt CE. + /// Cancellation was observed before the CE firstScan call began. + [MainThreadOnly] + [RequiresPluginEnabled] + public void StartFirstScanCancellable(in FirstScanRequest request, CancellationToken cancellationToken) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireEnabledMainThread(); + RequireState("StartFirstScan", MemoryScanState.New); + ValidateFirstRequest(in request); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, FirstScanOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + + // A CE error may happen after it accepts some scan setup. Do not report the old New state after a partial call. + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + CallFirstScan(operation.State, _scanner!.Value, in request); + State = MemoryScanState.Scanning; + ObserveCancellationAfterNativeCall(cancellationToken); + } + + /// Begins a CE next scan over the previous readable result set. + /// The complete next-scan request. + /// The session has not completed a first or previous next scan. + /// The request contains an unsupported next-scan option. + /// A required CE string argument is . + /// The protected CE method call failed. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public void StartNextScan(in NextScanRequest request) + { + StartNextScanCancellable(in request, CancellationToken.None); + } + + /// Begins a CE next scan and observes cancellation only before or after the synchronous CE call. + /// The complete next-scan request. + /// A cooperative cancellation observation token; it cannot interrupt CE. + /// Cancellation was observed before the CE nextScan call began. + [MainThreadOnly] + [RequiresPluginEnabled] + public void StartNextScanCancellable(in NextScanRequest request, CancellationToken cancellationToken) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireEnabledMainThread(); + RequireState("StartNextScan", MemoryScanState.ResultsReady); + ValidateNextRequest(in request); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, NextScanOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + + // The list stops being readable as soon as this session releases it. A failed deinitialize or nextScan leaves + // the conservative Invalidated state, from which Reset is the only recovery. + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); + CallNextScan(operation.State, _scanner!.Value, in request); + State = MemoryScanState.Scanning; + ObserveCancellationAfterNativeCall(cancellationToken); + } + + /// + /// Waits through CE's no-timeout waitTillDone() form, then initializes the attached found list only after + /// CE reports completion. + /// + /// The session is not scanning. + /// The protected CE call failed. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public void WaitForCompletion() + { + WaitForCompletionCancellable(CancellationToken.None); + } + + /// + /// Waits through CE's synchronous no-timeout waitTillDone() form and records whether cancellation was + /// observed before or after that native call. + /// + /// A cooperative cancellation observation token; it cannot interrupt CE. + /// Cancellation was observed before CE waitTillDone() began. + [MainThreadOnly] + [RequiresPluginEnabled] + public void WaitForCompletionCancellable(CancellationToken cancellationToken) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireEnabledMainThread(); + RequireState("WaitForCompletion", MemoryScanState.Scanning); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, WaitForCompletionOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + + try + { + CallWaitTillDone(operation.State, _scanner!.Value); + ObserveCancellationAfterNativeCall(cancellationToken); + + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + CallNoResult(operation.State, _foundList!.Value.Handle, "initialize"u8, InitializeResultsOperation); + State = MemoryScanState.ResultsReady; + } + catch + { + // A wait error may mean CE is still scanning, completed, or left a partially materialized result set. + // Never leave the session in Scanning when it cannot safely decide which of those is true. + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + throw; + } + } + + /// + /// Releases any initialized readable view and asks CE to clear the current scan results through + /// MemScan.newScan. + /// + /// The session is scanning or disposed. + /// The protected CE method call failed. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public void Reset() + { + ResetCancellable(CancellationToken.None); + } + + /// Resets the CE scan only when cancellation was not observed before the first native cleanup call. + /// A cooperative cancellation observation token; it cannot interrupt CE. + /// Cancellation was observed before CE cleanup began. + [MainThreadOnly] + [RequiresPluginEnabled] + public void ResetCancellable(CancellationToken cancellationToken) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireEnabledMainThread(); + ThrowIfDisposed(); + if (State == MemoryScanState.New) + { + return; + } + + if (State == MemoryScanState.Scanning) + { + ThrowWrongState("Reset"); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResetOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); + CallNoResult(operation.State, _scanner!.Value.Handle, "newScan"u8, ResetOperation); + State = MemoryScanState.New; + InvalidationReason = MemoryScanInvalidationReason.None; + ObserveCancellationAfterNativeCall(cancellationToken); + } + + /// Attempts to read the parsed target address at a zero-based result index. + /// + /// The CE found-list index, beginning at zero. This API deliberately supports only the managed + /// index range even when is larger. + /// + /// The parsed target address when the method returns . + /// when the index is outside the current result count. + /// is negative. + /// The results are not ready. + /// CE failed or returned an address string that cannot be parsed. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public bool TryGetAddress(int zeroBasedIndex, out Address address) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + RequireEnabledMainThread(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResultAddressOperation); + FoundList foundList = RequireResults(); + if ((ulong) zeroBasedIndex >= ReadResultCount(operation.State, foundList)) + { + address = default; + return false; + } + + string text = CallString(operation.State, foundList.Handle, "getAddress"u8, ResultAddressOperation, + zeroBasedIndex); + if (Address.TryParse(text, out address)) + { + return true; + } + + throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, ResultAddressOperation, + "The memory scan result address was not a hexadecimal target address."); + } + + /// Attempts to read the exact value text at a zero-based result index. + /// + /// The CE found-list index, beginning at zero. This API deliberately supports only the managed + /// index range even when is larger. + /// + /// The copied CE value text when the method returns . + /// when the index is outside the current result count. + /// is negative. + /// The results are not ready. + /// The protected CE method call failed or did not return text. + /// The plugin is not enabled or the caller is not on its main thread. + [MainThreadOnly] + [RequiresPluginEnabled] + public bool TryGetValue(int zeroBasedIndex, [NotNullWhen(true)] out string? value) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + RequireEnabledMainThread(); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResultValueOperation); + FoundList foundList = RequireResults(); + if ((ulong) zeroBasedIndex >= ReadResultCount(operation.State, foundList)) + { + value = null; + return false; + } + + value = CallString(operation.State, foundList.Handle, "getValue"u8, ResultValueOperation, zeroBasedIndex); + return true; + } + + /// Copies the complete initialized found list into a caller-bounded managed destination. + /// The caller-owned storage for copied address/value rows. + /// The complete CE row count when it was read successfully; otherwise zero. + /// + /// The copied row count, zero unless the returned status is + /// . + /// + /// A success, empty-result, capacity, cancellation, context, Lua, or malformed-result category. + /// + /// This method never returns a CE handle, enumerable, or deferred producer. It reads the count once, refuses a + /// destination that cannot hold every row without issuing row calls, builds a temporary complete snapshot, then + /// copies it into only on success. It is a low-level bounded copy primitive; + /// workflow-level cardinality and progress policy remain outside the SDK session. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public MemoryScanMaterializationStatus TryCopyResults(Span destination, out ulong totalCount, + out int written) + { + return TryCopyResultsCancellable(destination, out totalCount, out written, CancellationToken.None); + } + + /// Copies the complete initialized found list while observing cancellation between synchronous CE row calls. + /// The caller-owned storage for copied address/value rows. + /// The complete CE row count when it was read successfully; otherwise zero. + /// + /// The copied row count, zero unless the returned status is + /// . + /// + /// A cooperative cancellation observation token; it is checked between copied rows. + /// A success, empty-result, capacity, cancellation, context, Lua, or malformed-result category. + /// + /// Cancellation prevents later row calls or publication of the temporary snapshot. It cannot interrupt a row + /// call that Cheat Engine has already begun. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "This bounded materialization operation keeps its cancellation and ownership milestones together.")] + public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span destination, + out ulong totalCount, out int written, CancellationToken cancellationToken) + { + totalCount = 0; + written = 0; + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireEnabledMainThread(); + if (State != MemoryScanState.ResultsReady) + { + ThrowWrongState("TryCopyResults"); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State, ResultCountOperation); + if (context != MemoryScanMaterializationStatus.Success) + { + return context; + } + + if (cancellationToken.IsCancellationRequested) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + FoundList foundList = RequireResults(); + try + { + totalCount = ReadResultCount(operation.State, foundList); + if (totalCount == 0) + { + return MemoryScanMaterializationStatus.NoResults; + } + + if (totalCount > int.MaxValue || totalCount > (ulong) destination.Length) + { + return MemoryScanMaterializationStatus.DestinationTooSmall; + } + + MemoryScanResult[] snapshot = new MemoryScanResult[(int) totalCount]; + for (int index = 0; index < snapshot.Length; index++) + { + if (cancellationToken.IsCancellationRequested) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + string addressText = CallString(operation.State, foundList.Handle, "getAddress"u8, + ResultAddressOperation, + index); + if (!Address.TryParse(addressText, out Address address)) + { + return MemoryScanMaterializationStatus.InvalidResult; + } + + string value = CallString(operation.State, foundList.Handle, "getValue"u8, ResultValueOperation, index); + snapshot[index] = new MemoryScanResult(address, value); + } + + if (cancellationToken.IsCancellationRequested) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + snapshot.AsSpan().CopyTo(destination); + written = snapshot.Length; + return MemoryScanMaterializationStatus.Success; + } + catch (MemoryScanException exception) + { + return exception.FailureKind switch + { + MemoryScanFailureKind.LuaError => MemoryScanMaterializationStatus.LuaFailure, + _ => MemoryScanMaterializationStatus.InvalidResult + }; + } + } + + private FoundList RequireResults() + { + RequireState("results", MemoryScanState.ResultsReady); + return _foundList!.Value; + } + + private void RequireState(string operation, MemoryScanState expected) + { + ThrowIfDisposed(); + if (State != expected) + { + ThrowWrongState(operation); + } + } + + private void ThrowIfDisposed() + { + if (State == MemoryScanState.Disposed) + { + throw new ObjectDisposedException(nameof(MemoryScanSession), "The memory-scan session was disposed."); + } + } + + [DoesNotReturn] + private void ThrowWrongState(string operation) + { + throw new MemoryScanStateException(operation, State); + } + + private static void RequireEnabledMainThread() + { + if (!LuaRuntime.IsAttached) + { + throw new InvalidOperationException( + "The Cheat Engine plugin is not enabled, so the memory scan cannot acquire its Lua state."); + } + + if (!LuaRuntime.IsMainThread) + { + throw new InvalidOperationException( + "Memory scan operations must run on Cheat Engine's main thread; the session does not dispatch work implicitly."); + } + } + + private static void ValidateFirstRequest(in FirstScanRequest request) + { + // A default-initialized request can contain null strings. These are properties of the + // value-type request rather than parameters of this helper, therefore its parameter name + // must be the actual public argument ("request") instead of a local alias. + ArgumentNullException.ThrowIfNull(request.Input1, nameof(request)); + ArgumentNullException.ThrowIfNull(request.Input2, nameof(request)); + ArgumentNullException.ThrowIfNull(request.ProtectionFlags, nameof(request)); + ArgumentNullException.ThrowIfNull(request.AlignmentParameter, nameof(request)); + + if (request.ScanOption is < ScanOption.UnknownValue or > ScanOption.SmallerThan) + { + throw new ArgumentException( + "A first scan only accepts UnknownValue, ExactValue, ValueBetween, BiggerThan or SmallerThan.", + nameof(request)); + } + + // CE 7.7 celua.txt line 2587 lists vtGrouped in addition to the contiguous Byte..All range. + if ((uint) request.VariableType > (uint) VariableType.All && request.VariableType != VariableType.Grouped) + { + throw new ArgumentException("The CE 7.7 firstScan contract accepts Byte through All and Grouped.", + nameof(request)); + } + + if (request.RoundingType is < RoundingType.Rounded or > RoundingType.Truncated) + { + throw new ArgumentException("The rounding type is not a CE 7.7 value.", nameof(request)); + } + + if (request.FastScanMethod is < FastScanMethod.NotAligned or > FastScanMethod.LastDigits) + { + throw new ArgumentException("The fast scan method is not a CE 7.7 value.", nameof(request)); + } + } + + private static void ValidateNextRequest(in NextScanRequest request) + { + ArgumentNullException.ThrowIfNull(request.Input1, nameof(request)); + ArgumentNullException.ThrowIfNull(request.Input2, nameof(request)); + if (request.ScanOption is < ScanOption.ExactValue or > ScanOption.Unchanged) + { + throw new ArgumentException("A next scan only accepts ExactValue through Unchanged, never UnknownValue.", + nameof(request)); + } + + if (request.RoundingType is < RoundingType.Rounded or > RoundingType.Truncated) + { + throw new ArgumentException("The rounding type is not a CE 7.7 value.", nameof(request)); + } + } + + private void ThrowIfCancelledBeforeNativeCall(CancellationToken cancellationToken) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + if (!cancellationToken.IsCancellationRequested) + { + return; + } + + LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; + throw new OperationCanceledException(cancellationToken); + } + + private void ObserveCancellationAfterNativeCall(CancellationToken cancellationToken) + { + LastCancellationMilestone = cancellationToken.IsCancellationRequested + ? MemoryScanCancellationMilestone.ObservedAfterNativeCall + : MemoryScanCancellationMilestone.None; + } + + private void EnsureCurrentContext(LuaState state, string operation) + { + MemoryScanMaterializationStatus status = TryEnsureCurrentContext(state, operation); + if (status == MemoryScanMaterializationStatus.Success) + { + return; + } + + MemoryScanFailureKind failureKind = status switch + { + MemoryScanMaterializationStatus.RuntimeInvalidated => MemoryScanFailureKind.RuntimeInvalidated, + MemoryScanMaterializationStatus.TargetIdentityMismatch => MemoryScanFailureKind.TargetIdentityMismatch, + _ => MemoryScanFailureKind.TargetIdentityUnavailable + }; + throw new MemoryScanException(failureKind, operation, + "The memory scan session cannot use its original runtime and target context for operation '" + operation + + "'."); + } + + private MemoryScanMaterializationStatus TryEnsureCurrentContext(LuaState state, string operation) + { + if (!_isBound || RuntimeIdentity != LuaRuntime.CurrentStateIdentity) + { + Invalidate(MemoryScanInvalidationReason.RuntimeIdentityChanged); + return MemoryScanMaterializationStatus.RuntimeInvalidated; + } + + if (!_targetObservation.Incarnation.HasValue) + { + LastTargetCheck = TargetSelection.CreateUnavailableCheck(_targetObservation); + return MemoryScanMaterializationStatus.TargetIdentityUnavailable; + } + + int top = state.Top; + TargetIdentityCheck check; + try + { + check = TargetSelection.ValidateCurrent(state, _targetObservation.Incarnation.GetValueOrDefault()); + } + finally + { + state.SetTop(top); + } + + LastTargetCheck = check; + if (check.IsCurrent) + { + return MemoryScanMaterializationStatus.Success; + } + + if (check.Kind is TargetIdentityCheckKind.TargetChanged or TargetIdentityCheckKind.ProcessReused) + { + Invalidate(check.Kind == TargetIdentityCheckKind.TargetChanged + ? MemoryScanInvalidationReason.TargetChanged + : MemoryScanInvalidationReason.TargetProcessReused); + return MemoryScanMaterializationStatus.TargetIdentityMismatch; + } + + return MemoryScanMaterializationStatus.TargetIdentityUnavailable; + } + + private void Invalidate(MemoryScanInvalidationReason reason) + { + if (State == MemoryScanState.Disposed) + { + return; + } + + State = MemoryScanState.Invalidated; + InvalidationReason = reason; + } + + private static void CallFirstScan(LuaState state, MemScan scanner, in FirstScanRequest request) + { + using LuaFrame frame = new(state); + EnumMarshaller.Push(state, request.ScanOption); + EnumMarshaller.Push(state, request.VariableType); + EnumMarshaller.Push(state, request.RoundingType); + StringMarshaller.Push(state, request.Input1); + StringMarshaller.Push(state, request.Input2); + state.PushInteger(request.StartAddress.ToInt64()); + state.PushInteger(request.StopAddress.ToInt64()); + StringMarshaller.Push(state, request.ProtectionFlags); + EnumMarshaller.Push(state, request.FastScanMethod); + StringMarshaller.Push(state, request.AlignmentParameter); + BooleanMarshaller.Push(state, request.IsHexadecimalInput); + BooleanMarshaller.Push(state, request.IsNotBinaryString); + BooleanMarshaller.Push(state, request.IsUnicodeScan); + BooleanMarshaller.Push(state, request.IsCaseSensitive); + + LuaStatus status = scanner.Handle.TryCallMethod(state, "firstScan"u8, 14, 0); + if (!status.IsOk) + { + ThrowLua(state, status, FirstScanOperation); + } + } + + private static void CallNextScan(LuaState state, MemScan scanner, in NextScanRequest request) + { + using LuaFrame frame = new(state); + EnumMarshaller.Push(state, request.ScanOption); + EnumMarshaller.Push(state, request.RoundingType); + StringMarshaller.Push(state, request.Input1); + StringMarshaller.Push(state, request.Input2); + BooleanMarshaller.Push(state, request.IsHexadecimalInput); + BooleanMarshaller.Push(state, request.IsNotBinaryString); + BooleanMarshaller.Push(state, request.IsUnicodeScan); + BooleanMarshaller.Push(state, request.IsCaseSensitive); + BooleanMarshaller.Push(state, request.IsPercentageScan); + + int argumentCount = 9; + if (request.SavedResultName is not null) + { + StringMarshaller.Push(state, request.SavedResultName); + argumentCount = 10; + } + + LuaStatus status = scanner.Handle.TryCallMethod(state, "nextScan"u8, argumentCount, 0); + if (!status.IsOk) + { + ThrowLua(state, status, NextScanOperation); + } + } + + private static void CallWaitTillDone(LuaState state, MemScan scanner) + { + using LuaFrame frame = new(state); + LuaStatus status = scanner.Handle.TryCallMethod(state, "waitTillDone"u8, 0, 0); + if (!status.IsOk) + { + ThrowLua(state, status, WaitForCompletionOperation); + } + } + + private static ulong ReadResultCount(LuaState state, FoundList foundList) + { + using LuaFrame frame = new(state); + LuaStatus status = foundList.Handle.TryCallMethod(state, "getCount"u8, 0, 1); + if (!status.IsOk) + { + ThrowLua(state, status, ResultCountOperation); + } + + if (!Int64Marshaller.TryRead(state, -1, out long count) || count < 0) + { + throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, ResultCountOperation, + "The memory scan result count was not a non-negative 64-bit Lua integer."); + } + + return (ulong) count; + } + + private static string CallString(LuaState state, CEObject target, ReadOnlySpan method, string operation, + int index) + { + using LuaFrame frame = new(state); + state.PushInteger(index); + LuaStatus status = target.TryCallMethod(state, method, 1, 1); + if (!status.IsOk) + { + ThrowLua(state, status, operation); + } + + if (StringMarshaller.TryRead(state, -1, out string? value)) + { + return value; + } + + throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, operation, + "The memory scan operation did not return text."); + } + + private static void CallNoResult(LuaState state, CEObject target, ReadOnlySpan method, string operation) + { + using LuaFrame frame = new(state); + LuaStatus status = target.TryCallMethod(state, method, 0, 0); + if (!status.IsOk) + { + ThrowLua(state, status, operation); + } + } + + [DoesNotReturn] + private static void ThrowLua(LuaState state, LuaStatus status, string operation) + { + LuaError error = LuaError.FromStack(state, status); + throw new MemoryScanException(MemoryScanFailureKind.LuaError, operation, + "The protected Lua call for memory scan operation '" + operation + "' failed.", new LuaException(error)); + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessionContext.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessionContext.cs index 5249dc94..2b265573 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessionContext.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessionContext.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -8,19 +9,19 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// SDK-internal immutable context captured for one owned scan-session pair. [StructLayout(LayoutKind.Auto)] internal readonly record struct MemoryScanSessionContext( - LuaStateIdentity RuntimeIdentity, - TargetSelectionObservation TargetObservation) + LuaStateIdentity RuntimeIdentity, + TargetSelectionObservation TargetObservation) { - internal static MemoryScanSessionContext Capture(LuaState state) - { - var top = state.Top; - try - { - return new MemoryScanSessionContext(LuaRuntime.CurrentStateIdentity, TargetSelection.ObserveCurrent(state)); - } - finally - { - state.SetTop(top); - } - } + internal static MemoryScanSessionContext Capture(LuaState state) + { + int top = state.Top; + try + { + return new MemoryScanSessionContext(LuaRuntime.CurrentStateIdentity, TargetSelection.ObserveCurrent(state)); + } + finally + { + state.SetTop(top); + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs index ef618686..5421963b 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.Objects; @@ -30,166 +31,214 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public static class MemoryScanSessions { - private static readonly LuaRef SCreateFoundList = new(); - private static readonly LuaRef SCreateMemScan = new(); - - /// Creates one new plugin-owned scanner and its attached plugin-owned found-list child. - /// The new session on success; otherwise. - /// - /// when both documented factory calls returned valid host objects. Returns - /// when either global is unavailable, either protected call fails, or a result is not - /// a non-null host object. In every failure after scanner creation, the child (if present) is rolled back before - /// the scanner, including a failure while publishing either managed owner. - /// - /// - /// The plugin is not enabled, the caller has no host Lua state, the host cannot push objects, or the caller is - /// not on Cheat Engine's main thread. - /// - [MainThreadOnly] - [RequiresPluginEnabled] - public static bool TryCreate([NotNullWhen(true)] out MemoryScanSession? session) - { - return TryCreateDetailed(out session) == MemoryScanCreationStatus.Success; - } - - /// Creates a scanner/found-list pair and reports the factual creation category. - /// The new context-bound session only when the returned status is . - /// - /// The factory result without parsing a CE error message. A documented result, a - /// protected Lua failure and a malformed non-null result remain distinct. A detached runtime or a worker-thread - /// caller still throws because no safe Lua operation can begin. - /// - /// - /// The plugin is not enabled, the caller has no host Lua state, the host cannot push objects, or the caller is - /// not on Cheat Engine's main thread. - /// - [MainThreadOnly] - [RequiresPluginEnabled] - public static MemoryScanCreationStatus TryCreateDetailed([NotNullWhen(true)] out MemoryScanSession? session) - { - return TryCreateDetailedCore(out session, CreateSession); - } - - // Tests use this seam to prove that an ownership-transfer failure rolls the child back before its parent. The raw - // handles remain available until their wrapper is constructed, so even an allocation failure during publication - // has one direct rollback authority. This is internal deliberately: callers can select neither the owner - // construction nor a different adoption policy. - internal static bool TryCreateCore([NotNullWhen(true)] out MemoryScanSession? session, - MemoryScanSessionAdopter adopter) - { - return TryCreateDetailedCore(out session, adopter) == MemoryScanCreationStatus.Success; - } - - private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true)] out MemoryScanSession? session, - MemoryScanSessionAdopter adopter) - { - ArgumentNullException.ThrowIfNull(adopter); - RequireEnabledMainThread(); - - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - using LuaFrame frame = new(state); - Owned? scanner = null; - Owned? foundList = null; - CEObject scannerHandle = CEObject.Null; - CEObject foundListHandle = CEObject.Null; - var status = MemoryScanCreationStatus.Success; - var context = MemoryScanSessionContext.Capture(state); - session = null; - try - { - status = TryCreateScanner(state, out scanner, out scannerHandle); - if (status == MemoryScanCreationStatus.Success) - { - status = TryCreateFoundList(state, scanner!.Value, out foundList, out foundListHandle); - } - - if (status == MemoryScanCreationStatus.Success) - { - session = adopter(scanner!, foundList!); - session.Bind(context); - } - } - finally - { - // Adoption transfers and empties both wrappers. Every other exit after construction must release the child - // before the parent while the original operation is still admitted. Swallowing a protected destroy failure - // avoids hiding the factory failure and, like Owned.Dispose, never retries an unknown native state. - if (!TryRollback(state, foundList, foundListHandle) | !TryRollback(state, scanner, scannerHandle)) - { - if (session is null && status != MemoryScanCreationStatus.Success) - status = MemoryScanCreationStatus.RollbackUnconfirmed; - } - } - - return status; - } - - private static MemoryScanCreationStatus TryCreateScanner(LuaState state, out Owned? scanner, - out CEObject scannerHandle) - { - scanner = null; - scannerHandle = CEObject.Null; - var global = LuaGlobalFunctions.TryPushWithStatus(state, SCreateMemScan, "createMemScan"u8); - if (global == LuaGlobalPushStatus.Unavailable) return MemoryScanCreationStatus.GlobalUnavailable; - if (global != LuaGlobalPushStatus.Success || !state.TryCall(0, 1).IsOk) - return MemoryScanCreationStatus.LuaFailure; - if (state.IsNil(-1)) return MemoryScanCreationStatus.NoScannerResult; - if (!CEObject.TryRead(state, -1, out scannerHandle)) return MemoryScanCreationStatus.InvalidScannerResult; - - scanner = new Owned(MemScan.FromHandle(scannerHandle)); - scannerHandle = CEObject.Null; - return MemoryScanCreationStatus.Success; - } - - private static MemoryScanCreationStatus TryCreateFoundList(LuaState state, MemScan scanner, - out Owned? foundList, out CEObject foundListHandle) - { - foundList = null; - foundListHandle = CEObject.Null; - var global = LuaGlobalFunctions.TryPushWithStatus(state, SCreateFoundList, "createFoundList"u8); - if (global == LuaGlobalPushStatus.Unavailable) return MemoryScanCreationStatus.GlobalUnavailable; - if (global != LuaGlobalPushStatus.Success) return MemoryScanCreationStatus.LuaFailure; - - scanner.Handle.Push(state); - if (!state.TryCall(1, 1).IsOk) return MemoryScanCreationStatus.LuaFailure; - if (state.IsNil(-1)) return MemoryScanCreationStatus.NoFoundListResult; - if (!CEObject.TryRead(state, -1, out foundListHandle)) return MemoryScanCreationStatus.InvalidFoundListResult; - if (foundListHandle == scanner.Handle) - { - foundListHandle = CEObject.Null; - return MemoryScanCreationStatus.AliasedFoundList; - } - - foundList = new Owned(FoundList.FromHandle(foundListHandle)); - foundListHandle = CEObject.Null; - return MemoryScanCreationStatus.Success; - } - - private static bool TryRollback(LuaState state, Owned? owner, CEObject unpublishedHandle) - where T : struct, ICEObject - { - using LuaFrame rollbackFrame = new(state); - if (owner is not null && !owner.IsDisposed) - return owner.TryDestroy(state).IsOk; - if (!unpublishedHandle.IsNull) - return unpublishedHandle.TryDestroy(state).IsOk; - - return true; - } - - private static MemoryScanSession CreateSession(Owned scanner, Owned foundList) - { - return MemoryScanSession.AdoptUnbound(scanner, foundList); - } - - private static void RequireEnabledMainThread() - { - if (!LuaRuntime.IsAttached) - throw new InvalidOperationException( - "The Cheat Engine plugin is not enabled, so a memory scan session cannot be created."); - if (!LuaRuntime.IsMainThread) - throw new InvalidOperationException( - "Memory scan session creation must run on Cheat Engine's main thread."); - } + private static readonly LuaRef SCreateFoundList = new(); + private static readonly LuaRef SCreateMemScan = new(); + + /// Creates one new plugin-owned scanner and its attached plugin-owned found-list child. + /// The new session on success; otherwise. + /// + /// when both documented factory calls returned valid host objects. Returns + /// when either global is unavailable, either protected call fails, or a result is not + /// a non-null host object. In every failure after scanner creation, the child (if present) is rolled back before + /// the scanner, including a failure while publishing either managed owner. + /// + /// + /// The plugin is not enabled, the caller has no host Lua state, the host cannot push objects, or the caller is + /// not on Cheat Engine's main thread. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public static bool TryCreate([NotNullWhen(true)] out MemoryScanSession? session) + { + return TryCreateDetailed(out session) == MemoryScanCreationStatus.Success; + } + + /// Creates a scanner/found-list pair and reports the factual creation category. + /// + /// The new context-bound session only when the returned status is + /// . + /// + /// + /// The factory result without parsing a CE error message. A documented result, a + /// protected Lua failure and a malformed non-null result remain distinct. A detached runtime or a worker-thread + /// caller still throws because no safe Lua operation can begin. + /// + /// + /// The plugin is not enabled, the caller has no host Lua state, the host cannot push objects, or the caller is + /// not on Cheat Engine's main thread. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public static MemoryScanCreationStatus TryCreateDetailed([NotNullWhen(true)] out MemoryScanSession? session) + { + return TryCreateDetailedCore(out session, CreateSession); + } + + // Tests use this seam to prove that an ownership-transfer failure rolls the child back before its parent. The raw + // handles remain available until their wrapper is constructed, so even an allocation failure during publication + // has one direct rollback authority. This is internal deliberately: callers can select neither the owner + // construction nor a different adoption policy. + internal static bool TryCreateCore([NotNullWhen(true)] out MemoryScanSession? session, + MemoryScanSessionAdopter adopter) + { + return TryCreateDetailedCore(out session, adopter) == MemoryScanCreationStatus.Success; + } + + private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true)] out MemoryScanSession? session, + MemoryScanSessionAdopter adopter) + { + ArgumentNullException.ThrowIfNull(adopter); + RequireEnabledMainThread(); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + Owned? scanner = null; + Owned? foundList = null; + CEObject scannerHandle = CEObject.Null; + CEObject foundListHandle = CEObject.Null; + MemoryScanCreationStatus status = MemoryScanCreationStatus.Success; + MemoryScanSessionContext context = MemoryScanSessionContext.Capture(state); + session = null; + try + { + status = TryCreateScanner(state, out scanner, out scannerHandle); + if (status == MemoryScanCreationStatus.Success) + { + status = TryCreateFoundList(state, scanner!.Value, out foundList, out foundListHandle); + } + + if (status == MemoryScanCreationStatus.Success) + { + session = adopter(scanner!, foundList!); + session.Bind(context); + } + } + finally + { + // Adoption transfers and empties both wrappers. Every other exit after construction must release the child + // before the parent while the original operation is still admitted. Swallowing a protected destroy failure + // avoids hiding the factory failure and, like Owned.Dispose, never retries an unknown native state. + if (!TryRollback(state, foundList, foundListHandle) | !TryRollback(state, scanner, scannerHandle)) + { + if (session is null && status != MemoryScanCreationStatus.Success) + { + status = MemoryScanCreationStatus.RollbackUnconfirmed; + } + } + } + + return status; + } + + private static MemoryScanCreationStatus TryCreateScanner(LuaState state, out Owned? scanner, + out CEObject scannerHandle) + { + scanner = null; + scannerHandle = CEObject.Null; + LuaGlobalPushStatus global = LuaGlobalFunctions.TryPushWithStatus(state, SCreateMemScan, "createMemScan"u8); + if (global == LuaGlobalPushStatus.Unavailable) + { + return MemoryScanCreationStatus.GlobalUnavailable; + } + + if (global != LuaGlobalPushStatus.Success || !state.TryCall(0, 1).IsOk) + { + return MemoryScanCreationStatus.LuaFailure; + } + + if (state.IsNil(-1)) + { + return MemoryScanCreationStatus.NoScannerResult; + } + + if (!CEObject.TryRead(state, -1, out scannerHandle)) + { + return MemoryScanCreationStatus.InvalidScannerResult; + } + + scanner = new Owned(MemScan.FromHandle(scannerHandle)); + scannerHandle = CEObject.Null; + return MemoryScanCreationStatus.Success; + } + + private static MemoryScanCreationStatus TryCreateFoundList(LuaState state, MemScan scanner, + out Owned? foundList, out CEObject foundListHandle) + { + foundList = null; + foundListHandle = CEObject.Null; + LuaGlobalPushStatus global = LuaGlobalFunctions.TryPushWithStatus(state, SCreateFoundList, "createFoundList"u8); + if (global == LuaGlobalPushStatus.Unavailable) + { + return MemoryScanCreationStatus.GlobalUnavailable; + } + + if (global != LuaGlobalPushStatus.Success) + { + return MemoryScanCreationStatus.LuaFailure; + } + + scanner.Handle.Push(state); + if (!state.TryCall(1, 1).IsOk) + { + return MemoryScanCreationStatus.LuaFailure; + } + + if (state.IsNil(-1)) + { + return MemoryScanCreationStatus.NoFoundListResult; + } + + if (!CEObject.TryRead(state, -1, out foundListHandle)) + { + return MemoryScanCreationStatus.InvalidFoundListResult; + } + + if (foundListHandle == scanner.Handle) + { + foundListHandle = CEObject.Null; + return MemoryScanCreationStatus.AliasedFoundList; + } + + foundList = new Owned(FoundList.FromHandle(foundListHandle)); + foundListHandle = CEObject.Null; + return MemoryScanCreationStatus.Success; + } + + private static bool TryRollback(LuaState state, Owned? owner, CEObject unpublishedHandle) + where T : struct, ICEObject + { + using LuaFrame rollbackFrame = new(state); + if (owner is not null && !owner.IsDisposed) + { + return owner.TryDestroy(state).IsOk; + } + + if (!unpublishedHandle.IsNull) + { + return unpublishedHandle.TryDestroy(state).IsOk; + } + + return true; + } + + private static MemoryScanSession CreateSession(Owned scanner, Owned foundList) + { + return MemoryScanSession.AdoptUnbound(scanner, foundList); + } + + private static void RequireEnabledMainThread() + { + if (!LuaRuntime.IsAttached) + { + throw new InvalidOperationException( + "The Cheat Engine plugin is not enabled, so a memory scan session cannot be created."); + } + + if (!LuaRuntime.IsMainThread) + { + throw new InvalidOperationException( + "Memory scan session creation must run on Cheat Engine's main thread."); + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanState.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanState.cs index ac44cbde..2cfb3216 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanState.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanState.cs @@ -21,23 +21,23 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public enum MemoryScanState { - /// The session has a scanner and result-list pair, but no readable scan result. - New = 0, + /// The session has a scanner and result-list pair, but no readable scan result. + New = 0, - /// A first or next scan was accepted and has not yet completed successfully. - Scanning = 1, + /// A first or next scan was accepted and has not yet completed successfully. + Scanning = 1, - /// - /// waitTillDone completed successfully and the attached found list initialized successfully. - /// - ResultsReady = 2, + /// + /// waitTillDone completed successfully and the attached found list initialized successfully. + /// + ResultsReady = 2, - /// - /// A protected operation or result marshalling step failed after the session began a transition. The session - /// refuses reads and new scans until succeeds. - /// - Invalidated = 3, + /// + /// A protected operation or result marshalling step failed after the session began a transition. The session + /// refuses reads and new scans until succeeds. + /// + Invalidated = 3, - /// The session released its child list and then its scanner; no operation remains valid. - Disposed = 4, + /// The session released its child list and then its scanner; no operation remains valid. + Disposed = 4 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs index 51e296be..2e45440a 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs @@ -8,16 +8,22 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public sealed class MemoryScanStateException : InvalidOperationException { - internal MemoryScanStateException(string operation, MemoryScanState state) - : base("The memory-scan operation '" + operation + "' is not valid while the session is " + state + ".") - { - Operation = operation; - State = state; - } + internal MemoryScanStateException(string operation, MemoryScanState state) + : base("The memory-scan operation '" + operation + "' is not valid while the session is " + state + ".") + { + Operation = operation; + State = state; + } - /// Gets the managed operation the caller attempted. - public string Operation { get; } + /// Gets the managed operation the caller attempted. + public string Operation + { + get; + } - /// Gets the session state that rejected the operation. - public MemoryScanState State { get; } + /// Gets the session state that rejected the operation. + public MemoryScanState State + { + get; + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/NextScanRequest.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/NextScanRequest.cs index 64ff1c73..30a339fc 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/NextScanRequest.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/NextScanRequest.cs @@ -13,85 +13,115 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// public readonly struct NextScanRequest { - /// Initializes a complete next-scan request. - /// The comparison mode for the existing result set. - /// The floating-point comparison rule. - /// The primary scan text; use an empty string when the option does not need it. - /// The secondary scan text; use an empty string when the option does not need it. - /// Whether CE interprets the inputs as hexadecimal text. - /// Whether a binary type's input is decimal rather than a bit string. - /// Whether a string scan uses UTF-16 rather than CE's normal UTF-8 mode. - /// Whether a string scan matches case. - /// Whether CE interprets applicable comparisons as percentages. - /// An optional saved CE result-set name. - public NextScanRequest( - ScanOption scanOption, - RoundingType roundingType, - string input1, - string input2, - bool isHexadecimalInput, - bool isNotBinaryString, - bool isUnicodeScan, - bool isCaseSensitive, - bool isPercentageScan, - string? savedResultName = null) - { - ScanOption = scanOption; - RoundingType = roundingType; - Input1 = input1; - Input2 = input2; - IsHexadecimalInput = isHexadecimalInput; - IsNotBinaryString = isNotBinaryString; - IsUnicodeScan = isUnicodeScan; - IsCaseSensitive = isCaseSensitive; - IsPercentageScan = isPercentageScan; - SavedResultName = savedResultName; - } + /// Initializes a complete next-scan request. + /// The comparison mode for the existing result set. + /// The floating-point comparison rule. + /// The primary scan text; use an empty string when the option does not need it. + /// The secondary scan text; use an empty string when the option does not need it. + /// Whether CE interprets the inputs as hexadecimal text. + /// Whether a binary type's input is decimal rather than a bit string. + /// Whether a string scan uses UTF-16 rather than CE's normal UTF-8 mode. + /// Whether a string scan matches case. + /// Whether CE interprets applicable comparisons as percentages. + /// An optional saved CE result-set name. + public NextScanRequest( + ScanOption scanOption, + RoundingType roundingType, + string input1, + string input2, + bool isHexadecimalInput, + bool isNotBinaryString, + bool isUnicodeScan, + bool isCaseSensitive, + bool isPercentageScan, + string? savedResultName = null) + { + ScanOption = scanOption; + RoundingType = roundingType; + Input1 = input1; + Input2 = input2; + IsHexadecimalInput = isHexadecimalInput; + IsNotBinaryString = isNotBinaryString; + IsUnicodeScan = isUnicodeScan; + IsCaseSensitive = isCaseSensitive; + IsPercentageScan = isPercentageScan; + SavedResultName = savedResultName; + } - /// Gets the CE next-scan comparison mode. - public ScanOption ScanOption { get; } + /// Gets the CE next-scan comparison mode. + public ScanOption ScanOption + { + get; + } - /// Gets the CE floating-point rounding rule. - public RoundingType RoundingType { get; } + /// Gets the CE floating-point rounding rule. + public RoundingType RoundingType + { + get; + } - /// Gets the primary scan text. - public string Input1 { get; } + /// Gets the primary scan text. + public string Input1 + { + get; + } - /// Gets the secondary scan text. - public string Input2 { get; } + /// Gets the secondary scan text. + public string Input2 + { + get; + } - /// Gets whether CE parses the input text as hexadecimal. - public bool IsHexadecimalInput { get; } + /// Gets whether CE parses the input text as hexadecimal. + public bool IsHexadecimalInput + { + get; + } - /// Gets whether a binary input is decimal rather than a bit string. - public bool IsNotBinaryString { get; } + /// Gets whether a binary input is decimal rather than a bit string. + public bool IsNotBinaryString + { + get; + } - /// Gets whether a string scan uses UTF-16. - public bool IsUnicodeScan { get; } + /// Gets whether a string scan uses UTF-16. + public bool IsUnicodeScan + { + get; + } - /// Gets whether a string scan is case-sensitive. - public bool IsCaseSensitive { get; } + /// Gets whether a string scan is case-sensitive. + public bool IsCaseSensitive + { + get; + } - /// Gets whether CE treats applicable comparison values as percentages. - public bool IsPercentageScan { get; } + /// Gets whether CE treats applicable comparison values as percentages. + public bool IsPercentageScan + { + get; + } - /// Gets the optional CE saved-result name, or to omit the argument. - public string? SavedResultName { get; } + /// Gets the optional CE saved-result name, or to omit the argument. + public string? SavedResultName + { + get; + } - /// Builds an exact-value next-scan request using CE's normal non-hexadecimal settings. - /// The exact value text. - /// An exact-value request using CE's normal rounded comparison rule. - public static NextScanRequest ExactValue(string input) - { - return new NextScanRequest( - ScanOption.ExactValue, - RoundingType.Rounded, - input, - string.Empty, - isHexadecimalInput: false, - isNotBinaryString: false, - isUnicodeScan: false, - isCaseSensitive: false, - isPercentageScan: false); - } + /// Builds an exact-value next-scan request using CE's normal non-hexadecimal settings. + /// The exact value text. + /// An exact-value request using CE's normal rounded comparison rule. + public static NextScanRequest ExactValue(string input) + { + return new NextScanRequest( + ScanOption.ExactValue, + RoundingType.Rounded, + input, + string.Empty, + false, + false, + false, + false, + false); + } } diff --git a/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs b/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs index 6196d403..e4a8ab86 100644 --- a/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs +++ b/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; @@ -14,20 +15,20 @@ namespace CheatEngine.SDK.Engine.Tables; /// public static partial class CheatTableFiles { - /// Loads a Cheat Engine table file, optionally merging it into the current address list. - /// The opaque path text passed directly to CE. - /// Whether CE should merge instead of replacing the current table. - /// The protected binding outcome. - /// The plugin is not enabled or the calling thread has no Lua state. - [LuaGlobal("loadTable")] - [RequiresPluginEnabled] - public static partial LuaOperationStatus TryLoad(string path, bool merge); + /// Loads a Cheat Engine table file, optionally merging it into the current address list. + /// The opaque path text passed directly to CE. + /// Whether CE should merge instead of replacing the current table. + /// The protected binding outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [LuaGlobal("loadTable")] + [RequiresPluginEnabled] + public static partial LuaOperationStatus TryLoad(string path, bool merge); - /// Saves the current Cheat Engine table to a file. - /// The opaque path text passed directly to CE. - /// The protected binding outcome. - /// The plugin is not enabled or the calling thread has no Lua state. - [LuaGlobal("saveTable")] - [RequiresPluginEnabled] - public static partial LuaOperationStatus TrySave(string path); + /// Saves the current Cheat Engine table to a file. + /// The opaque path text passed directly to CE. + /// The protected binding outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [LuaGlobal("saveTable")] + [RequiresPluginEnabled] + public static partial LuaOperationStatus TrySave(string path); } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheck.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheck.cs index e4cba4c0..59f7bab8 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheck.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheck.cs @@ -6,18 +6,24 @@ namespace CheatEngine.SDK.Engine.Targets; [StructLayout(LayoutKind.Auto)] public readonly record struct TargetIdentityCheck { - internal TargetIdentityCheck(TargetIdentityCheckKind kind, TargetSelectionObservation observed) - { - Kind = kind; - Observed = observed; - } + internal TargetIdentityCheck(TargetIdentityCheckKind kind, TargetSelectionObservation observed) + { + Kind = kind; + Observed = observed; + } - /// Gets the stable validation category. - public TargetIdentityCheckKind Kind { get; } + /// Gets the stable validation category. + public TargetIdentityCheckKind Kind + { + get; + } - /// Gets the current target observation used for validation. - public TargetSelectionObservation Observed { get; } + /// Gets the current target observation used for validation. + public TargetSelectionObservation Observed + { + get; + } - /// Gets whether the current qualified target matches the owner-bound incarnation. - public bool IsCurrent => Kind == TargetIdentityCheckKind.Current; + /// Gets whether the current qualified target matches the owner-bound incarnation. + public bool IsCurrent => Kind == TargetIdentityCheckKind.Current; } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs index 020fde4c..60ab8ace 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs @@ -3,30 +3,30 @@ namespace CheatEngine.SDK.Engine.Targets; /// The result of checking an owner-bound target incarnation against the current CE selection. public enum TargetIdentityCheckKind : byte { - /// No validation result was recorded. - Unspecified = 0, + /// No validation result was recorded. + Unspecified = 0, - /// The current qualified selection matches the owner-bound incarnation. - Current = 1, + /// The current qualified selection matches the owner-bound incarnation. + Current = 1, - /// Cheat Engine no longer has a selected target. - NoTargetSelected = 2, + /// Cheat Engine no longer has a selected target. + NoTargetSelected = 2, - /// The selected PID is known but its incarnation could not be established. - CurrentTargetUnqualified = 3, + /// The selected PID is known but its incarnation could not be established. + CurrentTargetUnqualified = 3, - /// The selected PID differs from the owner-bound PID. - TargetChanged = 4, + /// The selected PID differs from the owner-bound PID. + TargetChanged = 4, - /// The PID matches but its observed creation time differs from the owner-bound incarnation. - ProcessReused = 5, + /// The PID matches but its observed creation time differs from the owner-bound incarnation. + ProcessReused = 5, - /// The target-selection global was unavailable. - GlobalUnavailable = 6, + /// The target-selection global was unavailable. + GlobalUnavailable = 6, - /// The protected Lua target observation failed. - LuaFailure = 7, + /// The protected Lua target observation failed. + LuaFailure = 7, - /// The target observation returned an unsupported value. - InvalidResult = 8, + /// The target observation returned an unsupported value. + InvalidResult = 8 } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs index fccd591e..98c42eb4 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs @@ -10,12 +10,12 @@ namespace CheatEngine.SDK.Engine.Targets; [Flags] public enum TargetIdentityEvidence : byte { - /// No target-identity fact is available. - None = 0, + /// No target-identity fact is available. + None = 0, - /// Cheat Engine reported the numeric identifier of its selected process. - CheatEngineSelectedProcessId = 1, + /// Cheat Engine reported the numeric identifier of its selected process. + CheatEngineSelectedProcessId = 1, - /// The local operating system supplied the selected process's creation time. - LocalProcessStartTime = 2, + /// The local operating system supplied the selected process's creation time. + LocalProcessStartTime = 2 } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs index 5acd43d6..507f7a3f 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs @@ -12,52 +12,63 @@ namespace CheatEngine.SDK.Engine.Targets; [StructLayout(LayoutKind.Auto)] public readonly struct TargetProcessIncarnation : IEquatable { - internal TargetProcessIncarnation(int processId, long startedAtUtcTicks) - { - if (processId <= 0) - throw new ArgumentOutOfRangeException(nameof(processId), processId, - "A target process incarnation requires a positive process identifier."); - if (startedAtUtcTicks <= 0) - throw new ArgumentOutOfRangeException(nameof(startedAtUtcTicks), startedAtUtcTicks, - "A target process incarnation requires a positive UTC creation time."); - - ProcessId = processId; - StartedAtUtcTicks = startedAtUtcTicks; - } - - /// Gets the positive Windows process identifier observed by Cheat Engine. - public int ProcessId { get; } - - /// Gets the UTC ticks of the local process creation-time observation. - public long StartedAtUtcTicks { get; } - - /// - public bool Equals(TargetProcessIncarnation other) - { - return ProcessId == other.ProcessId && StartedAtUtcTicks == other.StartedAtUtcTicks; - } - - /// - public override bool Equals(object? obj) - { - return obj is TargetProcessIncarnation other && Equals(other); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine(ProcessId, StartedAtUtcTicks); - } - - /// Compares two target-process incarnations. - public static bool operator ==(TargetProcessIncarnation left, TargetProcessIncarnation right) - { - return left.Equals(right); - } - - /// Compares two target-process incarnations. - public static bool operator !=(TargetProcessIncarnation left, TargetProcessIncarnation right) - { - return !left.Equals(right); - } + internal TargetProcessIncarnation(int processId, long startedAtUtcTicks) + { + if (processId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(processId), processId, + "A target process incarnation requires a positive process identifier."); + } + + if (startedAtUtcTicks <= 0) + { + throw new ArgumentOutOfRangeException(nameof(startedAtUtcTicks), startedAtUtcTicks, + "A target process incarnation requires a positive UTC creation time."); + } + + ProcessId = processId; + StartedAtUtcTicks = startedAtUtcTicks; + } + + /// Gets the positive Windows process identifier observed by Cheat Engine. + public int ProcessId + { + get; + } + + /// Gets the UTC ticks of the local process creation-time observation. + public long StartedAtUtcTicks + { + get; + } + + /// + public bool Equals(TargetProcessIncarnation other) + { + return ProcessId == other.ProcessId && StartedAtUtcTicks == other.StartedAtUtcTicks; + } + + /// + public override bool Equals(object? obj) + { + return obj is TargetProcessIncarnation other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(ProcessId, StartedAtUtcTicks); + } + + /// Compares two target-process incarnations. + public static bool operator ==(TargetProcessIncarnation left, TargetProcessIncarnation right) + { + return left.Equals(right); + } + + /// Compares two target-process incarnations. + public static bool operator !=(TargetProcessIncarnation left, TargetProcessIncarnation right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs index a32262d5..9250f134 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs @@ -1,6 +1,7 @@ -using CheatEngine.SDK.Engine.Errors; using System.Runtime.InteropServices; +using CheatEngine.SDK.Engine.Errors; + namespace CheatEngine.SDK.Engine.Targets; /// @@ -10,57 +11,67 @@ namespace CheatEngine.SDK.Engine.Targets; [StructLayout(LayoutKind.Auto)] public readonly record struct TargetReleaseOutcome { - internal TargetReleaseOutcome(TargetReleaseStatus status, TargetIdentityCheck? targetCheck, - EngineFailureKind? failureKind) - { - Status = status; - TargetCheck = targetCheck; - FailureKind = failureKind; - } + internal TargetReleaseOutcome(TargetReleaseStatus status, TargetIdentityCheck? targetCheck, + EngineFailureKind? failureKind) + { + Status = status; + TargetCheck = targetCheck; + FailureKind = failureKind; + } - /// Gets whether the target-bound cleanup was confirmed. - public TargetReleaseStatus Status { get; } + /// Gets whether the target-bound cleanup was confirmed. + public TargetReleaseStatus Status + { + get; + } - /// Gets the target check that refused cleanup, when one occurred. - public TargetIdentityCheck? TargetCheck { get; } + /// Gets the target check that refused cleanup, when one occurred. + public TargetIdentityCheck? TargetCheck + { + get; + } - /// - /// Gets the binding failure category after the cleanup crossed the SDK binding boundary. The status records - /// whether the target operation could begin; the value is absent when no binding failure is available. - /// - public EngineFailureKind? FailureKind { get; } + /// + /// Gets the binding failure category after the cleanup crossed the SDK binding boundary. The status records + /// whether the target operation could begin; the value is absent when no binding failure is available. + /// + public EngineFailureKind? FailureKind + { + get; + } - /// Gets whether a caller must treat the external resource as requiring manual recovery. - public bool RequiresManualRecovery => Status is not TargetReleaseStatus.Unspecified and not TargetReleaseStatus.Released; + /// Gets whether a caller must treat the external resource as requiring manual recovery. + public bool RequiresManualRecovery => + Status is not TargetReleaseStatus.Unspecified and not TargetReleaseStatus.Released; - internal static TargetReleaseOutcome Released() - { - return new TargetReleaseOutcome(TargetReleaseStatus.Released, null, null); - } + internal static TargetReleaseOutcome Released() + { + return new TargetReleaseOutcome(TargetReleaseStatus.Released, null, null); + } - internal static TargetReleaseOutcome Unconfirmed(EngineFailureKind? failureKind) - { - return new TargetReleaseOutcome(TargetReleaseStatus.UnconfirmedAfterInvocation, null, failureKind); - } + internal static TargetReleaseOutcome Unconfirmed(EngineFailureKind? failureKind) + { + return new TargetReleaseOutcome(TargetReleaseStatus.UnconfirmedAfterInvocation, null, failureKind); + } - internal static TargetReleaseOutcome NotInvoked(EngineFailureKind? failureKind = null) - { - return new TargetReleaseOutcome(TargetReleaseStatus.NotInvoked, null, failureKind); - } + internal static TargetReleaseOutcome NotInvoked(EngineFailureKind? failureKind = null) + { + return new TargetReleaseOutcome(TargetReleaseStatus.NotInvoked, null, failureKind); + } - internal static TargetReleaseOutcome Refused(TargetIdentityCheck check) - { - return new TargetReleaseOutcome(GetRefusalStatus(check.Kind), check, null); - } + internal static TargetReleaseOutcome Refused(TargetIdentityCheck check) + { + return new TargetReleaseOutcome(GetRefusalStatus(check.Kind), check, null); + } - private static TargetReleaseStatus GetRefusalStatus(TargetIdentityCheckKind kind) - { - return kind switch - { - TargetIdentityCheckKind.NoTargetSelected => TargetReleaseStatus.RefusedNoTarget, - TargetIdentityCheckKind.TargetChanged => TargetReleaseStatus.RefusedTargetChanged, - TargetIdentityCheckKind.ProcessReused => TargetReleaseStatus.RefusedProcessReused, - _ => TargetReleaseStatus.RefusedIdentityUnavailable, - }; - } + private static TargetReleaseStatus GetRefusalStatus(TargetIdentityCheckKind kind) + { + return kind switch + { + TargetIdentityCheckKind.NoTargetSelected => TargetReleaseStatus.RefusedNoTarget, + TargetIdentityCheckKind.TargetChanged => TargetReleaseStatus.RefusedTargetChanged, + TargetIdentityCheckKind.ProcessReused => TargetReleaseStatus.RefusedProcessReused, + _ => TargetReleaseStatus.RefusedIdentityUnavailable + }; + } } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs index a125c8b6..4a56fd2f 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs @@ -3,27 +3,27 @@ namespace CheatEngine.SDK.Engine.Targets; /// The externally visible result of consuming a target-bound owner. public enum TargetReleaseStatus : byte { - /// No release was attempted yet. - Unspecified = 0, + /// No release was attempted yet. + Unspecified = 0, - /// The target operation confirmed its release. - Released = 1, + /// The target operation confirmed its release. + Released = 1, - /// Cleanup was refused because Cheat Engine has no selected target. - RefusedNoTarget = 2, + /// Cleanup was refused because Cheat Engine has no selected target. + RefusedNoTarget = 2, - /// Cleanup was refused because a required target-identity fact was unavailable. - RefusedIdentityUnavailable = 3, + /// Cleanup was refused because a required target-identity fact was unavailable. + RefusedIdentityUnavailable = 3, - /// Cleanup was refused because another PID is currently selected. - RefusedTargetChanged = 4, + /// Cleanup was refused because another PID is currently selected. + RefusedTargetChanged = 4, - /// Cleanup was refused because the original PID now denotes another process incarnation. - RefusedProcessReused = 5, + /// Cleanup was refused because the original PID now denotes another process incarnation. + RefusedProcessReused = 5, - /// The owner was consumed after a CE operation began but release could not be confirmed. - UnconfirmedAfterInvocation = 6, + /// The owner was consumed after a CE operation began but release could not be confirmed. + UnconfirmedAfterInvocation = 6, - /// The owner was consumed, but cleanup could not begin a target operation. - NotInvoked = 7, + /// The owner was consumed, but cleanup could not begin a target operation. + NotInvoked = 7 } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs index 3c63bc56..23af4d95 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Diagnostics; + using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -17,118 +18,136 @@ namespace CheatEngine.SDK.Engine.Targets; /// public static class TargetSelection { - private static readonly LuaRef SGetOpenedProcessId = new(); - - /// Gets a copied observation of Cheat Engine's current target selection. - public static TargetSelectionObservation ObserveCurrent() - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - return ObserveCurrent(state); - } - finally - { - state.SetTop(top); - } - } - - /// Checks whether the current qualified selection still denotes . - /// The incarnation captured when the target-bound owner was acquired. - /// A factual current, changed, reused, or unavailable result. - public static TargetIdentityCheck ValidateCurrent(TargetProcessIncarnation expected) - { - using var operation = LuaRuntime.AcquireOperation(); - var state = operation.State; - var top = state.Top; - try - { - return ValidateCurrent(state, expected); - } - finally - { - state.SetTop(top); - } - } - - internal static TargetSelectionObservation ObserveCurrent(LuaState state) - { - var global = LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); - if (!global.IsSuccess) - { - return global.Status == LuaGlobalPushStatus.Unavailable - ? TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.GlobalUnavailable) - : TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); - } - - if (!state.TryCall(0, 1).IsOk) - return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); - if (!state.TryReadInteger(-1, out var rawProcessId)) - return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); - if (rawProcessId == 0) return TargetSelectionObservation.NoTarget(); - if (rawProcessId < 0 || rawProcessId > int.MaxValue) - return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); - - var processId = (int)rawProcessId; - return TryObserveIncarnation(processId, out var incarnation) - ? TargetSelectionObservation.Qualified(incarnation) - : TargetSelectionObservation.Unqualified(processId); - } - - internal static TargetIdentityCheck ValidateCurrent(LuaState state, TargetProcessIncarnation expected) - { - var observed = ObserveCurrent(state); - if (!observed.IsQualified) - return CreateUnavailableCheck(observed); - - var current = observed.Incarnation.GetValueOrDefault(); - if (current.ProcessId != expected.ProcessId) - return new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observed); - - return current.StartedAtUtcTicks == expected.StartedAtUtcTicks - ? new TargetIdentityCheck(TargetIdentityCheckKind.Current, observed) - : new TargetIdentityCheck(TargetIdentityCheckKind.ProcessReused, observed); - } - - internal static TargetIdentityCheck CreateUnavailableCheck(TargetSelectionObservation observation) - { - return new TargetIdentityCheck(MapUnavailable(observation.Status), observation); - } - - private static bool TryObserveIncarnation(int processId, out TargetProcessIncarnation incarnation) - { - try - { - using Process process = Process.GetProcessById(processId); - var startedAtUtcTicks = process.StartTime.ToUniversalTime().Ticks; - if (startedAtUtcTicks <= 0) - { - incarnation = default; - return false; - } - - incarnation = new TargetProcessIncarnation(processId, startedAtUtcTicks); - return true; - } - catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception - or NotSupportedException or UnauthorizedAccessException) - { - incarnation = default; - return false; - } - } - - private static TargetIdentityCheckKind MapUnavailable(TargetSelectionObservationStatus status) - { - return status switch - { - TargetSelectionObservationStatus.NoTargetSelected => TargetIdentityCheckKind.NoTargetSelected, - TargetSelectionObservationStatus.CurrentTargetUnqualified => TargetIdentityCheckKind.CurrentTargetUnqualified, - TargetSelectionObservationStatus.GlobalUnavailable => TargetIdentityCheckKind.GlobalUnavailable, - TargetSelectionObservationStatus.LuaFailure => TargetIdentityCheckKind.LuaFailure, - _ => TargetIdentityCheckKind.InvalidResult, - }; - } + private static readonly LuaRef SGetOpenedProcessId = new(); + + /// Gets a copied observation of Cheat Engine's current target selection. + public static TargetSelectionObservation ObserveCurrent() + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + return ObserveCurrent(state); + } + finally + { + state.SetTop(top); + } + } + + /// Checks whether the current qualified selection still denotes . + /// The incarnation captured when the target-bound owner was acquired. + /// A factual current, changed, reused, or unavailable result. + public static TargetIdentityCheck ValidateCurrent(TargetProcessIncarnation expected) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + return ValidateCurrent(state, expected); + } + finally + { + state.SetTop(top); + } + } + + internal static TargetSelectionObservation ObserveCurrent(LuaState state) + { + LuaGlobalPushOutcome global = + LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); + if (!global.IsSuccess) + { + return global.Status == LuaGlobalPushStatus.Unavailable + ? TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.GlobalUnavailable) + : TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); + } + + if (!state.TryCall(0, 1).IsOk) + { + return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); + } + + if (!state.TryReadInteger(-1, out long rawProcessId)) + { + return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); + } + + if (rawProcessId == 0) + { + return TargetSelectionObservation.NoTarget(); + } + + if (rawProcessId < 0 || rawProcessId > int.MaxValue) + { + return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); + } + + int processId = (int) rawProcessId; + return TryObserveIncarnation(processId, out TargetProcessIncarnation incarnation) + ? TargetSelectionObservation.Qualified(incarnation) + : TargetSelectionObservation.Unqualified(processId); + } + + internal static TargetIdentityCheck ValidateCurrent(LuaState state, TargetProcessIncarnation expected) + { + TargetSelectionObservation observed = ObserveCurrent(state); + if (!observed.IsQualified) + { + return CreateUnavailableCheck(observed); + } + + TargetProcessIncarnation current = observed.Incarnation.GetValueOrDefault(); + if (current.ProcessId != expected.ProcessId) + { + return new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observed); + } + + return current.StartedAtUtcTicks == expected.StartedAtUtcTicks + ? new TargetIdentityCheck(TargetIdentityCheckKind.Current, observed) + : new TargetIdentityCheck(TargetIdentityCheckKind.ProcessReused, observed); + } + + internal static TargetIdentityCheck CreateUnavailableCheck(TargetSelectionObservation observation) + { + return new TargetIdentityCheck(MapUnavailable(observation.Status), observation); + } + + private static bool TryObserveIncarnation(int processId, out TargetProcessIncarnation incarnation) + { + try + { + using Process process = Process.GetProcessById(processId); + long startedAtUtcTicks = process.StartTime.ToUniversalTime().Ticks; + if (startedAtUtcTicks <= 0) + { + incarnation = default; + return false; + } + + incarnation = new TargetProcessIncarnation(processId, startedAtUtcTicks); + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception + or NotSupportedException or UnauthorizedAccessException) + { + incarnation = default; + return false; + } + } + + private static TargetIdentityCheckKind MapUnavailable(TargetSelectionObservationStatus status) + { + return status switch + { + TargetSelectionObservationStatus.NoTargetSelected => TargetIdentityCheckKind.NoTargetSelected, + TargetSelectionObservationStatus.CurrentTargetUnqualified => TargetIdentityCheckKind + .CurrentTargetUnqualified, + TargetSelectionObservationStatus.GlobalUnavailable => TargetIdentityCheckKind.GlobalUnavailable, + TargetSelectionObservationStatus.LuaFailure => TargetIdentityCheckKind.LuaFailure, + _ => TargetIdentityCheckKind.InvalidResult + }; + } } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs index 4da540d7..486c0344 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs @@ -10,51 +10,64 @@ namespace CheatEngine.SDK.Engine.Targets; [StructLayout(LayoutKind.Auto)] public readonly record struct TargetSelectionObservation { - internal TargetSelectionObservation(TargetSelectionObservationStatus status, TargetIdentityEvidence evidence, - int? selectedProcessId, TargetProcessIncarnation? incarnation) - { - Status = status; - Evidence = evidence; - SelectedProcessId = selectedProcessId; - Incarnation = incarnation; - } - - /// Gets the factual observation category. - public TargetSelectionObservationStatus Status { get; } - - /// Gets the evidence actually available for this observation. - public TargetIdentityEvidence Evidence { get; } - - /// Gets the selected PID when Cheat Engine reported one; otherwise . - public int? SelectedProcessId { get; } - - /// Gets the local process incarnation only when the observation is qualified. - public TargetProcessIncarnation? Incarnation { get; } - - /// Gets whether the observation can safely identify a local target-process incarnation. - public bool IsQualified => Status == TargetSelectionObservationStatus.CurrentTargetQualified && Incarnation.HasValue; - - internal static TargetSelectionObservation Qualified(TargetProcessIncarnation incarnation) - { - return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetQualified, - TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalProcessStartTime, - incarnation.ProcessId, incarnation); - } - - internal static TargetSelectionObservation NoTarget() - { - return new TargetSelectionObservation(TargetSelectionObservationStatus.NoTargetSelected, - TargetIdentityEvidence.None, null, null); - } - - internal static TargetSelectionObservation Unqualified(int processId) - { - return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetUnqualified, - TargetIdentityEvidence.CheatEngineSelectedProcessId, processId, null); - } - - internal static TargetSelectionObservation FromStatus(TargetSelectionObservationStatus status) - { - return new TargetSelectionObservation(status, TargetIdentityEvidence.None, null, null); - } + internal TargetSelectionObservation(TargetSelectionObservationStatus status, TargetIdentityEvidence evidence, + int? selectedProcessId, TargetProcessIncarnation? incarnation) + { + Status = status; + Evidence = evidence; + SelectedProcessId = selectedProcessId; + Incarnation = incarnation; + } + + /// Gets the factual observation category. + public TargetSelectionObservationStatus Status + { + get; + } + + /// Gets the evidence actually available for this observation. + public TargetIdentityEvidence Evidence + { + get; + } + + /// Gets the selected PID when Cheat Engine reported one; otherwise . + public int? SelectedProcessId + { + get; + } + + /// Gets the local process incarnation only when the observation is qualified. + public TargetProcessIncarnation? Incarnation + { + get; + } + + /// Gets whether the observation can safely identify a local target-process incarnation. + public bool IsQualified => + Status == TargetSelectionObservationStatus.CurrentTargetQualified && Incarnation.HasValue; + + internal static TargetSelectionObservation Qualified(TargetProcessIncarnation incarnation) + { + return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetQualified, + TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalProcessStartTime, + incarnation.ProcessId, incarnation); + } + + internal static TargetSelectionObservation NoTarget() + { + return new TargetSelectionObservation(TargetSelectionObservationStatus.NoTargetSelected, + TargetIdentityEvidence.None, null, null); + } + + internal static TargetSelectionObservation Unqualified(int processId) + { + return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetUnqualified, + TargetIdentityEvidence.CheatEngineSelectedProcessId, processId, null); + } + + internal static TargetSelectionObservation FromStatus(TargetSelectionObservationStatus status) + { + return new TargetSelectionObservation(status, TargetIdentityEvidence.None, null, null); + } } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs index 06f782a4..34ff4b7f 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs @@ -3,24 +3,24 @@ namespace CheatEngine.SDK.Engine.Targets; /// The factual result of observing Cheat Engine's current target selection. public enum TargetSelectionObservationStatus : byte { - /// No selection observation was recorded. - Unspecified = 0, + /// No selection observation was recorded. + Unspecified = 0, - /// Cheat Engine reported a PID and the local process incarnation was observed. - CurrentTargetQualified = 1, + /// Cheat Engine reported a PID and the local process incarnation was observed. + CurrentTargetQualified = 1, - /// Cheat Engine reported that no target process is selected. - NoTargetSelected = 2, + /// Cheat Engine reported that no target process is selected. + NoTargetSelected = 2, - /// Cheat Engine reported a PID, but its local process incarnation could not be established. - CurrentTargetUnqualified = 3, + /// Cheat Engine reported a PID, but its local process incarnation could not be established. + CurrentTargetUnqualified = 3, - /// The getOpenedProcessID global was absent or not callable. - GlobalUnavailable = 4, + /// The getOpenedProcessID global was absent or not callable. + GlobalUnavailable = 4, - /// The protected Lua observation failed. - LuaFailure = 5, + /// The protected Lua observation failed. + LuaFailure = 5, - /// The observation result was not a supported integer PID. - InvalidResult = 6, + /// The observation result was not a supported integer PID. + InvalidResult = 6 } diff --git a/libs/CheatEngine.SDK.Engine/Values/Address.cs b/libs/CheatEngine.SDK.Engine/Values/Address.cs index c08078df..5c01ee30 100644 --- a/libs/CheatEngine.SDK.Engine/Values/Address.cs +++ b/libs/CheatEngine.SDK.Engine/Values/Address.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; @@ -48,492 +49,538 @@ namespace CheatEngine.SDK.Engine.Values; /// /// public readonly struct Address : - IEquatable
, - IComparable
, - IComparable, - ISpanFormattable, - IUtf8SpanFormattable, - ILuaMarshaller
+ IEquatable
, + IComparable
, + IComparable, + ISpanFormattable, + IUtf8SpanFormattable, + ILuaMarshaller
{ - /// Number of hexadecimal digits of a value that fits 32 bits, in the default format. - private const int NarrowDigits = 8; - - /// Number of hexadecimal digits of a value that needs more than 32 bits, in the default format. - private const int WideDigits = 16; - - /// Wraps a raw address. - /// The address; 0 is . - public Address(ulong value) - { - Value = value; - } - - /// Gets the null address, which Cheat Engine's functions return for "not found" in the numeric form. - public static Address Zero => default; - - /// Gets the raw address. - public ulong Value { get; } - - /// Gets a value indicating whether this is . - public bool IsZero => Value == 0; - - /// Converts a raw value; the named form of the implicit conversion. - /// The address. - /// The wrapped address. - public static Address FromUInt64(ulong value) - { - return new Address(value); - } - - /// - /// Converts a Lua integer to an address by bit reinterpretation: the form in which Cheat Engine's functions - /// take and return addresses as numbers, where an address above is negative. - /// - /// The 64-bit signed value. - /// The address with the same bits. - public static Address FromInt64(long bits) - { - return new Address(unchecked((ulong)bits)); - } - - /// Converts a raw value. - /// The address. - public static implicit operator Address(ulong value) - { - return new Address(value); - } - - /// Unwraps the raw value; the named form of the explicit conversion. - /// The raw address. - public ulong ToUInt64() - { - return Value; - } - - /// The address as the Lua integer that carries it: the same bits as a signed 64-bit value. - /// The bits, negative above . - public long ToInt64() - { - return unchecked((long)Value); - } - - /// Unwraps the raw value. - /// The address. - public static explicit operator ulong(Address address) - { - return address.Value; - } - - /// Offsets an address; wraps on overflow like pointer arithmetic. - /// The base. - /// The offset, negative to go down. - /// The offset address. - public static Address operator +(Address address, long offset) - { - return new Address(unchecked(address.Value + (ulong)offset)); - } - - /// Offsets an address downwards; wraps on underflow like pointer arithmetic. - /// The base. - /// The offset to subtract, negative to go up. - /// The offset address. - public static Address operator -(Address address, long offset) - { - return new Address(unchecked(address.Value - (ulong)offset)); - } - - /// Offsets an address; the named form of +. - /// The offset, negative to go down. - /// The offset address. - public Address Add(long offset) - { - return this + offset; - } - - /// Offsets an address downwards; the named form of -. - /// The offset to subtract, negative to go up. - /// The offset address. - public Address Subtract(long offset) - { - return this - offset; - } - - /// Compares two addresses. - /// First address. - /// Second address. - public static bool operator ==(Address left, Address right) - { - return left.Value == right.Value; - } - - /// Compares two addresses. - /// First address. - /// Second address. - public static bool operator !=(Address left, Address right) - { - return left.Value != right.Value; - } - - /// Orders two addresses as unsigned values. - /// First address. - /// Second address. - public static bool operator <(Address left, Address right) - { - return left.Value < right.Value; - } - - /// Orders two addresses as unsigned values. - /// First address. - /// Second address. - public static bool operator >(Address left, Address right) - { - return left.Value > right.Value; - } - - /// Orders two addresses as unsigned values. - /// First address. - /// Second address. - public static bool operator <=(Address left, Address right) - { - return left.Value <= right.Value; - } - - /// Orders two addresses as unsigned values. - /// First address. - /// Second address. - public static bool operator >=(Address left, Address right) - { - return left.Value >= right.Value; - } - - /// - /// Parses hexadecimal UTF-8 text: optional ASCII whitespace around it, an optional 0x/0X prefix, - /// then one or more hexadecimal digits (leading zeros allowed). Never allocates; culture-independent by - /// construction. - /// - /// The text. - /// The parsed address, or on failure. - /// - /// for empty text, a prefix without digits, any other character, or a value that does - /// not fit 64 bits. - /// - public static bool TryParse(ReadOnlySpan utf8, out Address address) - { - var digits = TrimAsciiWhitespace(utf8); - if (digits.Length >= 2 && digits[0] == (byte)'0' && (digits[1] | 0x20) == (byte)'x') digits = digits[2..]; - - if (digits.IsEmpty) - { - address = default; - return false; - } - - ulong value = 0; - foreach (var b in digits) - { - var digit = HexDigitValue(b); - if (digit < 0 || value >> 60 != 0) - { - address = default; - return false; - } - - value = (value << 4) | (uint)digit; - } - - address = new Address(value); - return true; - } - - /// - /// Parses hexadecimal UTF-16 text with the rules of . - /// Never allocates. - /// - /// The text. - /// The parsed address, or on failure. - /// when the text is not a hexadecimal address. - public static bool TryParse(ReadOnlySpan text, out Address address) - { - var digits = TrimAsciiWhitespace(text); - if (digits.Length >= 2 && digits[0] == '0' && (digits[1] | 0x20) == 'x') digits = digits[2..]; - - if (digits.IsEmpty) - { - address = default; - return false; - } - - ulong value = 0; - foreach (var c in digits) - { - var digit = c <= 0x7F ? HexDigitValue((byte)c) : -1; - if (digit < 0 || value >> 60 != 0) - { - address = default; - return false; - } - - value = (value << 4) | (uint)digit; - } - - address = new Address(value); - return true; - } - - /// - /// Parses hexadecimal text with the rules of ; - /// is a failure. - /// - /// The text, or . - /// The parsed address, or on failure. - /// when the text is not a hexadecimal address. - public static bool TryParse(string? text, out Address address) - { - return TryParse(text.AsSpan(), out address); - } - - /// Parses hexadecimal text, throwing when it is not an address. - /// The text. - /// The parsed address. - /// - /// The text is not a hexadecimal address (see - /// ). - /// - public static Address Parse(ReadOnlySpan text) - { - if (!TryParse(text, out var address)) ThrowFormat(text); - - return address; - } - - /// Parses hexadecimal text, throwing when it is not an address. - /// The text. - /// The parsed address. - /// is . - /// The text is not a hexadecimal address. - public static Address Parse(string text) - { - ArgumentNullException.ThrowIfNull(text); - return Parse(text.AsSpan()); - } - - /// Pushes the address as a Lua integer with the same bits (). - /// The state to push on. - /// The address. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, Address value) - { - state.PushInteger(value.ToInt64()); - } - - /// - /// Reads an address in either of Cheat Engine's forms: a number (bit reinterpretation of the Lua integer; a float - /// with an exact integral value qualifies) or a string (hexadecimal, optional 0x). The stack is not - /// modified and nothing is allocated. - /// - /// The state to read from. - /// An acceptable index. - /// The address, or when the value is neither a number nor a hexadecimal string. - /// when holds the address. - /// - /// Two C API calls either way: lua_type then lua_tolstring for a string (the type check is the one - /// inside , so it is not repeated here), lua_type then - /// lua_tointegerx for anything else. The string case is decided first and never falls through to the - /// number read: Lua's own conversion would read "10" as ten, and "1e1" as a float. - /// - [LuaStackEffect(0)] - public static bool TryRead(LuaState state, int index, out Address value) - { - if (state.TryReadUtf8(index, out var utf8)) return TryParse(utf8, out value); - - if (state.TryReadInteger(index, out var bits)) - { - value = FromInt64(bits); - return true; - } - - value = default; - return false; - } - - /// - public bool Equals(Address other) - { - return Value == other.Value; - } - - /// - public override bool Equals(object? obj) - { - return obj is Address other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Value.GetHashCode(); - } - - /// - public int CompareTo(Address other) - { - return Value.CompareTo(other.Value); - } - - /// - /// is not an . - public int CompareTo(object? obj) - { - if (obj is null) return 1; - - if (obj is Address other) return CompareTo(other); - - throw new ArgumentException("The object is not an Address.", nameof(obj)); - } - - /// - /// Formats with the default format: uppercase hexadecimal, 8 digits when the value fits 32 bits, 16 otherwise, no - /// prefix. - /// - /// The hexadecimal text. - public override string ToString() - { - return Value.ToString(DefaultFormat(Value), CultureInfo.InvariantCulture); - } - - /// - /// Formats with a format string: G or empty for the default, X/x for minimal upper/lowercase - /// digits, Xn/xn for at least n digits. The provider is ignored: addresses are - /// culture-invariant. - /// - /// The format, or for the default. - /// Ignored. - /// The hexadecimal text. - /// The format is not one of the above. - public string ToString(string? format, IFormatProvider? formatProvider) - { - return IsDefaultFormat(format) - ? ToString() - : Value.ToString(ValidateHexFormat(format!), CultureInfo.InvariantCulture); - } - - /// Formats into a UTF-16 buffer; see for the formats. - /// The buffer. - /// Characters written, 0 when the buffer is too small. - /// The format, or empty for the default. - /// Ignored. - /// when the buffer is too small. - /// The format is not supported. - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, - IFormatProvider? provider) - { - return Value.TryFormat(destination, out charsWritten, ResolveFormat(format, Value), - CultureInfo.InvariantCulture); - } - - /// - /// Formats into a UTF-8 buffer, for pushing to Lua without allocating; see - /// for the formats. - /// - /// The buffer. - /// Bytes written, 0 when the buffer is too small. - /// The format, or empty for the default. - /// Ignored. - /// when the buffer is too small. - /// The format is not supported. - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, - IFormatProvider? provider) - { - return Value.TryFormat(utf8Destination, out bytesWritten, ResolveFormat(format, Value), - CultureInfo.InvariantCulture); - } - - private static string DefaultFormat(ulong value) - { - return value <= uint.MaxValue ? "X8" : "X16"; - } - - // Empty, "G" or "g" select the default format. - private static bool IsDefaultFormat(ReadOnlySpan format) - { - return format.IsEmpty || (format.Length == 1 && (format[0] | 0x20) == 'g'); - } - - // The default format resolved for the value, or the caller's hexadecimal format validated. - private static ReadOnlySpan ResolveFormat(ReadOnlySpan format, ulong value) - { - return IsDefaultFormat(format) ? DefaultFormat(value) : ValidateHexFormat(format); - } - - // "X" or "x", optionally followed by decimal digits: the formats this type passes through to ulong unchanged. - private static ReadOnlySpan ValidateHexFormat(ReadOnlySpan format) - { - if (format.IsEmpty || (format[0] | 0x20) != 'x') ThrowUnsupportedFormat(format); - - for (var i = 1; i < format.Length; i++) - if (!char.IsAsciiDigit(format[i])) - ThrowUnsupportedFormat(format); - - return format; - } - - private static string ValidateHexFormat(string format) - { - _ = ValidateHexFormat(format.AsSpan()); - return format; - } - - private static ReadOnlySpan TrimAsciiWhitespace(ReadOnlySpan utf8) - { - var start = 0; - var end = utf8.Length; - while (start < end && IsAsciiWhitespace(utf8[start])) start++; - - while (end > start && IsAsciiWhitespace(utf8[end - 1])) end--; - - return utf8[start..end]; - } - - // Same rule as the UTF-8 form: ASCII whitespace only, so that both forms accept exactly the same text. - private static ReadOnlySpan TrimAsciiWhitespace(ReadOnlySpan text) - { - var start = 0; - var end = text.Length; - while (start < end && text[start] <= 0x7F && IsAsciiWhitespace((byte)text[start])) start++; - - while (end > start && text[end - 1] <= 0x7F && IsAsciiWhitespace((byte)text[end - 1])) end--; - - return text[start..end]; - } - - private static bool IsAsciiWhitespace(byte b) - { - return b is (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r' or (byte)'\f' or (byte)'\v'; - } - - private static int HexDigitValue(byte b) - { - if (b is >= (byte)'0' and <= (byte)'9') return b - '0'; - - var lower = b | 0x20; - if (lower is >= 'a' and <= 'f') return lower - 'a' + 10; - - return -1; - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowFormat(ReadOnlySpan text) - { - throw new FormatException("'" + text.ToString() + - "' is not a hexadecimal address (digits with an optional 0x prefix)."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowUnsupportedFormat(ReadOnlySpan format) - { - throw new FormatException("'" + format.ToString() + "' is not a supported Address format (G, X, x, Xn or xn)."); - } + /// Number of hexadecimal digits of a value that fits 32 bits, in the default format. + private const int NarrowDigits = 8; + + /// Number of hexadecimal digits of a value that needs more than 32 bits, in the default format. + private const int WideDigits = 16; + + /// Wraps a raw address. + /// The address; 0 is . + public Address(ulong value) + { + Value = value; + } + + /// Gets the null address, which Cheat Engine's functions return for "not found" in the numeric form. + public static Address Zero => default; + + /// Gets the raw address. + public ulong Value + { + get; + } + + /// Gets a value indicating whether this is . + public bool IsZero => Value == 0; + + /// Converts a raw value; the named form of the implicit conversion. + /// The address. + /// The wrapped address. + public static Address FromUInt64(ulong value) + { + return new Address(value); + } + + /// + /// Converts a Lua integer to an address by bit reinterpretation: the form in which Cheat Engine's functions + /// take and return addresses as numbers, where an address above is negative. + /// + /// The 64-bit signed value. + /// The address with the same bits. + public static Address FromInt64(long bits) + { + return new Address(unchecked((ulong) bits)); + } + + /// Converts a raw value. + /// The address. + public static implicit operator Address(ulong value) + { + return new Address(value); + } + + /// Unwraps the raw value; the named form of the explicit conversion. + /// The raw address. + public ulong ToUInt64() + { + return Value; + } + + /// The address as the Lua integer that carries it: the same bits as a signed 64-bit value. + /// The bits, negative above . + public long ToInt64() + { + return unchecked((long) Value); + } + + /// Unwraps the raw value. + /// The address. + public static explicit operator ulong(Address address) + { + return address.Value; + } + + /// Offsets an address; wraps on overflow like pointer arithmetic. + /// The base. + /// The offset, negative to go down. + /// The offset address. + public static Address operator +(Address address, long offset) + { + return new Address(unchecked(address.Value + (ulong) offset)); + } + + /// Offsets an address downwards; wraps on underflow like pointer arithmetic. + /// The base. + /// The offset to subtract, negative to go up. + /// The offset address. + public static Address operator -(Address address, long offset) + { + return new Address(unchecked(address.Value - (ulong) offset)); + } + + /// Offsets an address; the named form of +. + /// The offset, negative to go down. + /// The offset address. + public Address Add(long offset) + { + return this + offset; + } + + /// Offsets an address downwards; the named form of -. + /// The offset to subtract, negative to go up. + /// The offset address. + public Address Subtract(long offset) + { + return this - offset; + } + + /// Compares two addresses. + /// First address. + /// Second address. + public static bool operator ==(Address left, Address right) + { + return left.Value == right.Value; + } + + /// Compares two addresses. + /// First address. + /// Second address. + public static bool operator !=(Address left, Address right) + { + return left.Value != right.Value; + } + + /// Orders two addresses as unsigned values. + /// First address. + /// Second address. + public static bool operator <(Address left, Address right) + { + return left.Value < right.Value; + } + + /// Orders two addresses as unsigned values. + /// First address. + /// Second address. + public static bool operator >(Address left, Address right) + { + return left.Value > right.Value; + } + + /// Orders two addresses as unsigned values. + /// First address. + /// Second address. + public static bool operator <=(Address left, Address right) + { + return left.Value <= right.Value; + } + + /// Orders two addresses as unsigned values. + /// First address. + /// Second address. + public static bool operator >=(Address left, Address right) + { + return left.Value >= right.Value; + } + + /// + /// Parses hexadecimal UTF-8 text: optional ASCII whitespace around it, an optional 0x/0X prefix, + /// then one or more hexadecimal digits (leading zeros allowed). Never allocates; culture-independent by + /// construction. + /// + /// The text. + /// The parsed address, or on failure. + /// + /// for empty text, a prefix without digits, any other character, or a value that does + /// not fit 64 bits. + /// + public static bool TryParse(ReadOnlySpan utf8, out Address address) + { + ReadOnlySpan digits = TrimAsciiWhitespace(utf8); + if (digits.Length >= 2 && digits[0] == (byte) '0' && (digits[1] | 0x20) == (byte) 'x') + { + digits = digits[2..]; + } + + if (digits.IsEmpty) + { + address = default; + return false; + } + + ulong value = 0; + foreach (byte b in digits) + { + int digit = HexDigitValue(b); + if (digit < 0 || value >> 60 != 0) + { + address = default; + return false; + } + + value = (value << 4) | (uint) digit; + } + + address = new Address(value); + return true; + } + + /// + /// Parses hexadecimal UTF-16 text with the rules of . + /// Never allocates. + /// + /// The text. + /// The parsed address, or on failure. + /// when the text is not a hexadecimal address. + public static bool TryParse(ReadOnlySpan text, out Address address) + { + ReadOnlySpan digits = TrimAsciiWhitespace(text); + if (digits.Length >= 2 && digits[0] == '0' && (digits[1] | 0x20) == 'x') + { + digits = digits[2..]; + } + + if (digits.IsEmpty) + { + address = default; + return false; + } + + ulong value = 0; + foreach (char c in digits) + { + int digit = c <= 0x7F ? HexDigitValue((byte) c) : -1; + if (digit < 0 || value >> 60 != 0) + { + address = default; + return false; + } + + value = (value << 4) | (uint) digit; + } + + address = new Address(value); + return true; + } + + /// + /// Parses hexadecimal text with the rules of ; + /// is a failure. + /// + /// The text, or . + /// The parsed address, or on failure. + /// when the text is not a hexadecimal address. + public static bool TryParse(string? text, out Address address) + { + return TryParse(text.AsSpan(), out address); + } + + /// Parses hexadecimal text, throwing when it is not an address. + /// The text. + /// The parsed address. + /// + /// The text is not a hexadecimal address (see + /// ). + /// + public static Address Parse(ReadOnlySpan text) + { + if (!TryParse(text, out Address address)) + { + ThrowFormat(text); + } + + return address; + } + + /// Parses hexadecimal text, throwing when it is not an address. + /// The text. + /// The parsed address. + /// is . + /// The text is not a hexadecimal address. + public static Address Parse(string text) + { + ArgumentNullException.ThrowIfNull(text); + return Parse(text.AsSpan()); + } + + /// Pushes the address as a Lua integer with the same bits (). + /// The state to push on. + /// The address. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, Address value) + { + state.PushInteger(value.ToInt64()); + } + + /// + /// Reads an address in either of Cheat Engine's forms: a number (bit reinterpretation of the Lua integer; a float + /// with an exact integral value qualifies) or a string (hexadecimal, optional 0x). The stack is not + /// modified and nothing is allocated. + /// + /// The state to read from. + /// An acceptable index. + /// The address, or when the value is neither a number nor a hexadecimal string. + /// when holds the address. + /// + /// Two C API calls either way: lua_type then lua_tolstring for a string (the type check is the one + /// inside , so it is not repeated here), lua_type then + /// lua_tointegerx for anything else. The string case is decided first and never falls through to the + /// number read: Lua's own conversion would read "10" as ten, and "1e1" as a float. + /// + [LuaStackEffect(0)] + public static bool TryRead(LuaState state, int index, out Address value) + { + if (state.TryReadUtf8(index, out ReadOnlySpan utf8)) + { + return TryParse(utf8, out value); + } + + if (state.TryReadInteger(index, out long bits)) + { + value = FromInt64(bits); + return true; + } + + value = default; + return false; + } + + /// + public bool Equals(Address other) + { + return Value == other.Value; + } + + /// + public override bool Equals(object? obj) + { + return obj is Address other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + /// + public int CompareTo(Address other) + { + return Value.CompareTo(other.Value); + } + + /// + /// is not an . + public int CompareTo(object? obj) + { + if (obj is null) + { + return 1; + } + + if (obj is Address other) + { + return CompareTo(other); + } + + throw new ArgumentException("The object is not an Address.", nameof(obj)); + } + + /// + /// Formats with the default format: uppercase hexadecimal, 8 digits when the value fits 32 bits, 16 otherwise, no + /// prefix. + /// + /// The hexadecimal text. + public override string ToString() + { + return Value.ToString(DefaultFormat(Value), CultureInfo.InvariantCulture); + } + + /// + /// Formats with a format string: G or empty for the default, X/x for minimal upper/lowercase + /// digits, Xn/xn for at least n digits. The provider is ignored: addresses are + /// culture-invariant. + /// + /// The format, or for the default. + /// Ignored. + /// The hexadecimal text. + /// The format is not one of the above. + public string ToString(string? format, IFormatProvider? formatProvider) + { + return IsDefaultFormat(format) + ? ToString() + : Value.ToString(ValidateHexFormat(format!), CultureInfo.InvariantCulture); + } + + /// Formats into a UTF-16 buffer; see for the formats. + /// The buffer. + /// Characters written, 0 when the buffer is too small. + /// The format, or empty for the default. + /// Ignored. + /// when the buffer is too small. + /// The format is not supported. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, + IFormatProvider? provider) + { + return Value.TryFormat(destination, out charsWritten, ResolveFormat(format, Value), + CultureInfo.InvariantCulture); + } + + /// + /// Formats into a UTF-8 buffer, for pushing to Lua without allocating; see + /// for the formats. + /// + /// The buffer. + /// Bytes written, 0 when the buffer is too small. + /// The format, or empty for the default. + /// Ignored. + /// when the buffer is too small. + /// The format is not supported. + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, + IFormatProvider? provider) + { + return Value.TryFormat(utf8Destination, out bytesWritten, ResolveFormat(format, Value), + CultureInfo.InvariantCulture); + } + + private static string DefaultFormat(ulong value) + { + return value <= uint.MaxValue ? "X8" : "X16"; + } + + // Empty, "G" or "g" select the default format. + private static bool IsDefaultFormat(ReadOnlySpan format) + { + return format.IsEmpty || (format.Length == 1 && (format[0] | 0x20) == 'g'); + } + + // The default format resolved for the value, or the caller's hexadecimal format validated. + private static ReadOnlySpan ResolveFormat(ReadOnlySpan format, ulong value) + { + return IsDefaultFormat(format) ? DefaultFormat(value) : ValidateHexFormat(format); + } + + // "X" or "x", optionally followed by decimal digits: the formats this type passes through to ulong unchanged. + private static ReadOnlySpan ValidateHexFormat(ReadOnlySpan format) + { + if (format.IsEmpty || (format[0] | 0x20) != 'x') + { + ThrowUnsupportedFormat(format); + } + + for (int i = 1; i < format.Length; i++) + { + if (!char.IsAsciiDigit(format[i])) + { + ThrowUnsupportedFormat(format); + } + } + + return format; + } + + private static string ValidateHexFormat(string format) + { + _ = ValidateHexFormat(format.AsSpan()); + return format; + } + + private static ReadOnlySpan TrimAsciiWhitespace(ReadOnlySpan utf8) + { + int start = 0; + int end = utf8.Length; + while (start < end && IsAsciiWhitespace(utf8[start])) + { + start++; + } + + while (end > start && IsAsciiWhitespace(utf8[end - 1])) + { + end--; + } + + return utf8[start..end]; + } + + // Same rule as the UTF-8 form: ASCII whitespace only, so that both forms accept exactly the same text. + private static ReadOnlySpan TrimAsciiWhitespace(ReadOnlySpan text) + { + int start = 0; + int end = text.Length; + while (start < end && text[start] <= 0x7F && IsAsciiWhitespace((byte) text[start])) + { + start++; + } + + while (end > start && text[end - 1] <= 0x7F && IsAsciiWhitespace((byte) text[end - 1])) + { + end--; + } + + return text[start..end]; + } + + private static bool IsAsciiWhitespace(byte b) + { + return b is (byte) ' ' or (byte) '\t' or (byte) '\n' or (byte) '\r' or (byte) '\f' or (byte) '\v'; + } + + private static int HexDigitValue(byte b) + { + if (b is >= (byte) '0' and <= (byte) '9') + { + return b - '0'; + } + + int lower = b | 0x20; + if (lower is >= 'a' and <= 'f') + { + return lower - 'a' + 10; + } + + return -1; + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowFormat(ReadOnlySpan text) + { + throw new FormatException("'" + text.ToString() + + "' is not a hexadecimal address (digits with an optional 0x prefix)."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowUnsupportedFormat(ReadOnlySpan format) + { + throw new FormatException("'" + format.ToString() + "' is not a supported Address format (G, X, x, Xn or xn)."); + } } diff --git a/libs/CheatEngine.SDK.Engine/Values/IndexBase.cs b/libs/CheatEngine.SDK.Engine/Values/IndexBase.cs index 0a444bbd..da7f5816 100644 --- a/libs/CheatEngine.SDK.Engine/Values/IndexBase.cs +++ b/libs/CheatEngine.SDK.Engine/Values/IndexBase.cs @@ -29,58 +29,61 @@ namespace CheatEngine.SDK.Engine.Values; /// public static class IndexBase { - /// The first index of a Cheat Engine object, and of every index this assembly exposes. - public const int FirstObjectIndex = 0; + /// The first index of a Cheat Engine object, and of every index this assembly exposes. + public const int FirstObjectIndex = 0; - /// The first key of a Lua sequence. - public const long FirstLuaKey = 1; + /// The first key of a Lua sequence. + public const long FirstLuaKey = 1; - /// Converts a zero-based index into the key of the same element in a Lua sequence. - /// The index as C# and Cheat Engine objects count it. - /// The one-based Lua key. - /// is negative. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static long ToLuaKey(int zeroBasedIndex) - { - ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); - return zeroBasedIndex + FirstLuaKey; - } + /// Converts a zero-based index into the key of the same element in a Lua sequence. + /// The index as C# and Cheat Engine objects count it. + /// The one-based Lua key. + /// is negative. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long ToLuaKey(int zeroBasedIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); + return zeroBasedIndex + FirstLuaKey; + } - /// Converts the key of a Lua sequence element into a zero-based index. - /// The one-based Lua key. - /// The zero-based index. - /// - /// is below one, or the index would not fit an - /// . - /// - public static int FromLuaKey(long luaKey) - { - if (!TryFromLuaKey(luaKey, out var zeroBasedIndex)) ThrowNotASequenceKey(luaKey); + /// Converts the key of a Lua sequence element into a zero-based index. + /// The one-based Lua key. + /// The zero-based index. + /// + /// is below one, or the index would not fit an + /// . + /// + public static int FromLuaKey(long luaKey) + { + if (!TryFromLuaKey(luaKey, out int zeroBasedIndex)) + { + ThrowNotASequenceKey(luaKey); + } - return zeroBasedIndex; - } + return zeroBasedIndex; + } - /// Converts the key of a Lua sequence element into a zero-based index, for keys read back from Lua. - /// The key, as any Lua integer. - /// The zero-based index; 0 on failure. - /// when the key is below one or the index would not fit an . - public static bool TryFromLuaKey(long luaKey, out int zeroBasedIndex) - { - if (luaKey < FirstLuaKey || luaKey > int.MaxValue + FirstLuaKey) - { - zeroBasedIndex = 0; - return false; - } + /// Converts the key of a Lua sequence element into a zero-based index, for keys read back from Lua. + /// The key, as any Lua integer. + /// The zero-based index; 0 on failure. + /// when the key is below one or the index would not fit an . + public static bool TryFromLuaKey(long luaKey, out int zeroBasedIndex) + { + if (luaKey < FirstLuaKey || luaKey > int.MaxValue + FirstLuaKey) + { + zeroBasedIndex = 0; + return false; + } - zeroBasedIndex = (int)(luaKey - FirstLuaKey); - return true; - } + zeroBasedIndex = (int) (luaKey - FirstLuaKey); + return true; + } - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowNotASequenceKey(long luaKey) - { - throw new ArgumentOutOfRangeException(nameof(luaKey), luaKey, - "A Lua sequence key is at least 1 and at most int.MaxValue + 1."); - } + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNotASequenceKey(long luaKey) + { + throw new ArgumentOutOfRangeException(nameof(luaKey), luaKey, + "A Lua sequence key is at least 1 and at most int.MaxValue + 1."); + } } diff --git a/libs/CheatEngine.SDK.Engine/Values/LuaSequence.cs b/libs/CheatEngine.SDK.Engine/Values/LuaSequence.cs index f2786053..7ec0b70f 100644 --- a/libs/CheatEngine.SDK.Engine/Values/LuaSequence.cs +++ b/libs/CheatEngine.SDK.Engine/Values/LuaSequence.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.State; @@ -19,95 +20,95 @@ namespace CheatEngine.SDK.Engine.Values; /// public static class LuaSequence { - /// The state. - extension(LuaState state) - { - /// - /// Pushes element of the sequence at under - /// protection (t[i + 1]). Stack after success: the value (nil past the end). - /// - /// A valid index of the sequence; a relative index is taken before anything is pushed. - /// The element's zero-based position. - /// The status; on failure one error value is on top. - /// is negative. - [SuppressMessage("Meziantou.Analyzer", "MA0038", - Justification = - "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] - public LuaStatus TryGetSequenceItem(int tableIndex, int zeroBasedIndex) - { - return state.TryGetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); - } + /// The state. + extension(LuaState state) + { + /// + /// Pushes element of the sequence at under + /// protection (t[i + 1]). Stack after success: the value (nil past the end). + /// + /// A valid index of the sequence; a relative index is taken before anything is pushed. + /// The element's zero-based position. + /// The status; on failure one error value is on top. + /// is negative. + [SuppressMessage("Meziantou.Analyzer", "MA0038", + Justification = + "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] + public LuaStatus TryGetSequenceItem(int tableIndex, int zeroBasedIndex) + { + return state.TryGetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); + } - /// - /// Pops the value on top and stores it as element of the sequence at - /// under protection (t[i + 1] = v). - /// - /// - /// A valid index of the sequence, which must not be the top; a relative index is taken before - /// anything is pushed. - /// - /// The element's zero-based position. - /// The status; on failure one error value is on top in place of the value. - /// is negative. - [SuppressMessage("Meziantou.Analyzer", "MA0038", - Justification = - "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] - public LuaStatus TrySetSequenceItem(int tableIndex, int zeroBasedIndex) - { - return state.TrySetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); - } + /// + /// Pops the value on top and stores it as element of the sequence at + /// under protection (t[i + 1] = v). + /// + /// + /// A valid index of the sequence, which must not be the top; a relative index is taken before + /// anything is pushed. + /// + /// The element's zero-based position. + /// The status; on failure one error value is on top in place of the value. + /// is negative. + [SuppressMessage("Meziantou.Analyzer", "MA0038", + Justification = + "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] + public LuaStatus TrySetSequenceItem(int tableIndex, int zeroBasedIndex) + { + return state.TrySetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); + } - /// - /// Pushes element of the plain table at without - /// metamethods (lua_rawgeti with key i + 1). - /// - /// A valid index of a table. - /// The element's zero-based position. - /// The type of the pushed value; past the end. - /// is negative. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Meziantou.Analyzer", "MA0038", - Justification = - "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] - public LuaType RawGetSequenceItem(int tableIndex, int zeroBasedIndex) - { - return state.RawGetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); - } + /// + /// Pushes element of the plain table at without + /// metamethods (lua_rawgeti with key i + 1). + /// + /// A valid index of a table. + /// The element's zero-based position. + /// The type of the pushed value; past the end. + /// is negative. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Meziantou.Analyzer", "MA0038", + Justification = + "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] + public LuaType RawGetSequenceItem(int tableIndex, int zeroBasedIndex) + { + return state.RawGetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); + } - /// - /// Pops the value on top and stores it as element of the plain table at - /// without metamethods (lua_rawseti with key i + 1). - /// - /// A valid index of a table. - /// The element's zero-based position. - /// is negative. - /// Allocates inside Lua when the table grows. - [LuaStackEffect(-1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Meziantou.Analyzer", "MA0038", - Justification = - "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] - public void RawSetSequenceItem(int tableIndex, int zeroBasedIndex) - { - state.RawSetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); - } + /// + /// Pops the value on top and stores it as element of the plain table at + /// without metamethods (lua_rawseti with key i + 1). + /// + /// A valid index of a table. + /// The element's zero-based position. + /// is negative. + /// Allocates inside Lua when the table grows. + [LuaStackEffect(-1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Meziantou.Analyzer", "MA0038", + Justification = + "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] + public void RawSetSequenceItem(int tableIndex, int zeroBasedIndex) + { + state.RawSetIndex(tableIndex, IndexBase.ToLuaKey(zeroBasedIndex)); + } - /// - /// The number of elements of the sequence at without metamethods - /// (lua_rawlen: the border of the table, which is its element count for a proper sequence). - /// - /// A valid index of a table. - /// The count, which is also one past the last valid zero-based index. - /// The table has more than elements. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Meziantou.Analyzer", "MA0038", - Justification = - "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] - public int RawSequenceCount(int tableIndex) - { - return checked((int)state.RawLength(tableIndex)); - } - } + /// + /// The number of elements of the sequence at without metamethods + /// (lua_rawlen: the border of the table, which is its element count for a proper sequence). + /// + /// A valid index of a table. + /// The count, which is also one past the last valid zero-based index. + /// The table has more than elements. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Meziantou.Analyzer", "MA0038", + Justification = + "This is an instance extension member; marking it static would remove the LuaState extension receiver from its public call form.")] + public int RawSequenceCount(int tableIndex) + { + return checked((int) state.RawLength(tableIndex)); + } + } } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs index 39bd8dd6..ecd2fc9e 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs @@ -27,35 +27,38 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// internal static unsafe class AnsiNameBuffer { - /// Allocates the buffer. Never freed by design; the pointer is valid for the rest of the process. - /// The UTF-8 name without a terminating NUL. - /// The address of the first byte; never null. - /// The native allocation failed. - internal static byte* Allocate(ReadOnlySpan utf8Name) - { - var nulIndex = utf8Name.IndexOf((byte)0); - if (nulIndex >= 0) utf8Name = utf8Name[..nulIndex]; + /// Allocates the buffer. Never freed by design; the pointer is valid for the rest of the process. + /// The UTF-8 name without a terminating NUL. + /// The address of the first byte; never null. + /// The native allocation failed. + internal static byte* Allocate(ReadOnlySpan utf8Name) + { + int nulIndex = utf8Name.IndexOf((byte) 0); + if (nulIndex >= 0) + { + utf8Name = utf8Name[..nulIndex]; + } - if (Ascii.IsValid(utf8Name)) - { - var buffer = (byte*)NativeMemory.Alloc((nuint)utf8Name.Length + 1); - utf8Name.CopyTo(new Span(buffer, utf8Name.Length)); - buffer[utf8Name.Length] = 0; - return buffer; - } + if (Ascii.IsValid(utf8Name)) + { + byte* buffer = (byte*) NativeMemory.Alloc((nuint) utf8Name.Length + 1); + utf8Name.CopyTo(new Span(buffer, utf8Name.Length)); + buffer[utf8Name.Length] = 0; + return buffer; + } - // Non-ASCII: the only correct target is the process ANSI code page, which the BCL exposes through this call - // (it uses the system code page on Windows; UTF-8 elsewhere, where Cheat Engine does not run anyway). - var decoded = Encoding.UTF8.GetString(utf8Name); - var ansi = Marshal.StringToHGlobalAnsi(decoded); - return (byte*)ansi; - } + // Non-ASCII: the only correct target is the process ANSI code page, which the BCL exposes through this call + // (it uses the system code page on Windows; UTF-8 elsewhere, where Cheat Engine does not run anyway). + string decoded = Encoding.UTF8.GetString(utf8Name); + IntPtr ansi = Marshal.StringToHGlobalAnsi(decoded); + return (byte*) ansi; + } - /// Reads a buffer produced by back as bytes, without the NUL. For diagnostics and tests. - /// The buffer; null yields an empty span. - /// The bytes before the first NUL. - internal static ReadOnlySpan Read(byte* buffer) - { - return buffer is null ? default : MemoryMarshal.CreateReadOnlySpanFromNullTerminated(buffer); - } + /// Reads a buffer produced by back as bytes, without the NUL. For diagnostics and tests. + /// The buffer; null yields an empty span. + /// The bytes before the first NUL. + internal static ReadOnlySpan Read(byte* buffer) + { + return buffer is null ? default : MemoryMarshal.CreateReadOnlySpanFromNullTerminated(buffer); + } } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs index cb11de51..0f375ee0 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs @@ -1,7 +1,9 @@ +using System; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.InteropServices; using System.Threading; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Loading; @@ -17,81 +19,84 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; ///
internal static unsafe class LuaModuleLocator { - // The function pointer is stored as an integer so that it can be read and written with volatile semantics. - private static nint s_resolver; + // The function pointer is stored as an integer so that it can be read and written with volatile semantics. + private static nint s_resolver; - /// - /// Gets or sets the test seam: a static method returning the module handle to bind, or zero when there is none. - /// Null (the default) selects the production lookup. Not a delegate: a managed function pointer, read once per - /// enable. - /// - internal static delegate* Resolver - { - get => (delegate*)Volatile.Read(ref s_resolver); - set => Volatile.Write(ref s_resolver, (nint)value); - } + /// + /// Gets or sets the test seam: a static method returning the module handle to bind, or zero when there is none. + /// Null (the default) selects the production lookup. Not a delegate: a managed function pointer, read once per + /// enable. + /// + internal static delegate* Resolver + { + get => (delegate*) Volatile.Read(ref s_resolver); + set => Volatile.Write(ref s_resolver, (nint) value); + } - /// Locates the module and binds the API table to it, all or nothing. - /// Why it failed, for the log; null on success. - /// when holds for the located module. - internal static bool TryBind([NotNullWhen(false)] out string? failure) - { - var handle = Locate(out var fromSeam); - if (handle == 0) - { - failure = fromSeam - ? "The test module resolver returned no Lua module." - : "The process has no loaded module named '" + LuaModule.CheatEngine64ModuleName + - "': not a 64-bit Cheat Engine process, or its Lua library has another name."; - return false; - } + /// Locates the module and binds the API table to it, all or nothing. + /// Why it failed, for the log; null on success. + /// when holds for the located module. + internal static bool TryBind([NotNullWhen(false)] out string? failure) + { + IntPtr handle = Locate(out bool fromSeam); + if (handle == 0) + { + failure = fromSeam + ? "The test module resolver returned no Lua module." + : "The process has no loaded module named '" + LuaModule.CheatEngine64ModuleName + + "': not a 64-bit Cheat Engine process, or its Lua library has another name."; + return false; + } - return BindLocated(handle, !fromSeam, out failure); - } + return BindLocated(handle, !fromSeam, out failure); + } - /// - /// Binds the API table to and settles the loader reference that the lookup added to it. - /// - /// The located module. - /// - /// when the handle carries a reference that the lookup added and this call must settle; - /// when the caller owns the handle (the test seam), which is then never released here. - /// - /// Why it failed, for the log; null on success. - /// when holds for . - /// - /// A bound table needs one reference for the life of the process: the call that binds it keeps its own. Every - /// other outcome (already bound to this module, refused, or not a Lua library) releases it, so that enabling again - /// never accumulates references. - /// - internal static bool BindLocated(nint handle, bool counted, [NotNullWhen(false)] out string? failure) - { - // The bound module goes from zero to its final value once, so a match read here still holds after the bind. - var boundBefore = LuaApi.ModuleHandle == handle; - var bound = LuaApi.TryInitialize(handle, out var bindFailure); - if (counted && (boundBefore || !bound)) NativeLibrary.Free(handle); + /// + /// Binds the API table to and settles the loader reference that the lookup added to it. + /// + /// The located module. + /// + /// when the handle carries a reference that the lookup added and this call must settle; + /// when the caller owns the handle (the test seam), which is then never released here. + /// + /// Why it failed, for the log; null on success. + /// when holds for . + /// + /// A bound table needs one reference for the life of the process: the call that binds it keeps its own. Every + /// other outcome (already bound to this module, refused, or not a Lua library) releases it, so that enabling again + /// never accumulates references. + /// + internal static bool BindLocated(nint handle, bool counted, [NotNullWhen(false)] out string? failure) + { + // The bound module goes from zero to its final value once, so a match read here still holds after the bind. + bool boundBefore = LuaApi.ModuleHandle == handle; + bool bound = LuaApi.TryInitialize(handle, out string? bindFailure); + if (counted && (boundBefore || !bound)) + { + NativeLibrary.Free(handle); + } - if (!bound) - { - failure = string.Create(CultureInfo.InvariantCulture, - $"The Lua API table could not be bound to module 0x{handle:X}: {bindFailure}"); - return false; - } + if (!bound) + { + failure = string.Create(CultureInfo.InvariantCulture, + $"The Lua API table could not be bound to module 0x{handle:X}: {bindFailure}"); + return false; + } - failure = null; - return true; - } + failure = null; + return true; + } - private static nint Locate(out bool fromSeam) - { - var resolver = Resolver; - if (resolver is not null) - { - fromSeam = true; - return resolver(); - } + private static nint Locate(out bool fromSeam) + { + delegate* resolver = Resolver; + if (resolver is not null) + { + fromSeam = true; + return resolver(); + } - fromSeam = false; - return LuaModule.TryGetLoaded(out var handle) ? handle : 0; - } + fromSeam = false; + return LuaModule.TryGetLoaded(out IntPtr handle) ? handle : 0; + } } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs index 642f19b0..b700839a 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Hosting.Plugin; namespace CheatEngine.SDK.Hosting.Bootstrap; @@ -12,9 +13,12 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// internal abstract class PluginDescriptor { - /// Gets the factory type, for the deterministic rejection of a second, different factory. - internal abstract Type FactoryType { get; } + /// Gets the factory type, for the deterministic rejection of a second, different factory. + internal abstract Type FactoryType + { + get; + } - /// Constructs the plugin (). - internal abstract CheatEnginePlugin CreatePlugin(); + /// Constructs the plugin (). + internal abstract CheatEnginePlugin CreatePlugin(); } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor`1.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor`1.cs index fb7f3418..1f10a8d2 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor`1.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor`1.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Hosting.Plugin; namespace CheatEngine.SDK.Hosting.Bootstrap; @@ -9,12 +10,12 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// /// The generated (or hand-written) factory. internal sealed class PluginDescriptor : PluginDescriptor - where TFactory : IPluginFactory + where TFactory : IPluginFactory { - internal override Type FactoryType => typeof(TFactory); + internal override Type FactoryType => typeof(TFactory); - internal override CheatEnginePlugin CreatePlugin() - { - return TFactory.Create(); - } + internal override CheatEnginePlugin CreatePlugin() + { + return TFactory.Create(); + } } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs index 1146bd42..704cc110 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Abi.Native; @@ -20,561 +21,642 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; // The three stdcall callbacks whose addresses the bootstrap writes into the init record, and the enable/disable // logic behind them. [UnmanagedCallersOnly] methods cannot be generic or live in a generic type: the factory they // need is reached through the PluginDescriptor the bootstrap stored. -public static unsafe partial class PluginHost +public static unsafe partial class PluginHost // NOSONAR: bootstrap callbacks must expose Cheat Engine's unmanaged ABI. { - /// - /// Cheat Engine's version query. Writes and the name pointer of the - /// bootstrap into the host-owned . - /// - /// The host's record. - /// - /// The byte size the host reserved for this version record. A positive value smaller than the known record is - /// refused; zero or a negative value is treated as unknown. This contract is independent of the bootstrap's - /// opaque second integer. - /// - /// TRUE when the record was written. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 GetVersion(PluginVersion* version, int size) - { - try - { - Volatile.Write(ref s_lastVersionRecordSize, size); - if (HostLog.IsEnabled(HostLogLevel.Trace)) - HostLog.Trace(string.Create(CultureInfo.InvariantCulture, - $"GetVersion(0x{(nint)version:X}, size {size})")); - - if (version is null) - { - HostLog.Error("GetVersion: the version record address is zero."); - return Bool32.False; - } - - if (size > 0 && size < sizeof(PluginVersion)) - { - // Writing 16 bytes into a buffer the host says is smaller is memory corruption: refuse, and say so. - // The public 7.5 host passes sizeof(TPluginVersion); a host that claims less is reporting a record - // this SDK does not know. Zero or negative claims nothing and are treated as unknown for this distinct - // version-record callback; this says nothing about the opaque bootstrap argument. - HostLog.Error(string.Create( - CultureInfo.InvariantCulture, - $"GetVersion: the host reserved {size} bytes for the version record, fewer than the {sizeof(PluginVersion)} this SDK writes.")); - return Bool32.False; - } - - var name = s_name; - if (name is null) - { - HostLog.Error("GetVersion: the bootstrap has not run, there is no plugin name to report."); - return Bool32.False; - } - - version->Version = AbiConstants.SdkVersion; - version->PluginName = name; - return Bool32.True; - } - catch (Exception exception) - { - HostLog.Error("GetVersion failed.", exception); - return Bool32.False; - } - } - - /// - /// Cheat Engine's enable callback: copies the exports record, binds the Lua API, checks it, constructs the - /// plugin on the first enable, attaches the runtime binding and runs . - /// - /// The host's exports record (a stack local of the host: copied, never retained). - /// The id the host assigned. - /// - /// TRUE when the plugin is enabled when the call returns; FALSE on any failure, and when the call - /// is nested in or concurrent with another lifecycle transition. - /// - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 EnablePlugin(ManagedExportedFunctions* exports, uint pluginId) - { - try - { - return Enable(exports, pluginId) ? Bool32.True : Bool32.False; - } - catch (Exception exception) - { - HostLog.Error("EnablePlugin failed.", exception); - return Bool32.False; - } - } - - /// - /// Cheat Engine's disable callback: runs , detaches the runtime - /// binding (which neutralizes every live Lua callback) and withdraws the context. - /// - /// - /// TRUE after the plugin is disabled, including when OnDisable throws after its failure is logged; - /// FALSE when another lifecycle transition is active, when the host invokes disable from a thread other - /// than the captured plugin main thread, when it is nested in admitted Lua or dispatched work, or when Lua - /// cleanup cannot detach the runtime. Refusals leave the lifecycle unchanged; incomplete cleanup remains in - /// for diagnosis rather than reporting a false completion. - /// - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 DisablePlugin() - { - try - { - return Disable() ? Bool32.True : Bool32.False; - } - catch (Exception exception) - { - HostLog.Error("DisablePlugin failed.", exception); - return Bool32.False; - } - } - - // SGate only serializes the short state-selection step. The phase remains Enabling/Disabling for the full callback, - // so a nested callback after SGate was released is still refused. A callback arriving during state selection also - // gets an immediate FALSE rather than blocking behind a native lifecycle path. - private static bool TryEnterLifecycleCallback(string callback) - { - if (SGate.IsHeldByCurrentThread) - { - HostLog.Error(callback + - ": re-entered from plugin code while OnEnable or OnDisable is running on this thread; the call is refused and the outer transition decides the state."); - return false; - } - - if (SGate.TryEnter()) return true; - - HostLog.Error(callback + - ": another lifecycle transition is already running; concurrent callbacks fail immediately and do not wait for plugin code."); - return false; - } - - private static bool Enable(ManagedExportedFunctions* exports, uint pluginId) - { - TraceEnableCall(exports, pluginId); - var start = TryStartEnable(out var descriptor); - if (start is LifecycleStart.Refused) return false; - if (start is LifecycleStart.AlreadyStable) return true; - - try - { - return RunEnable(exports, pluginId, descriptor!); - } - finally - { - // Any pre-attach or OnEnable failure returns to Registered. Successful enable moved to Enabled first. - if (Phase is PluginHostLifecyclePhase.Enabling) SetPhase(PluginHostLifecyclePhase.Registered); - } - } - - private static void TraceEnableCall(ManagedExportedFunctions* exports, uint pluginId) - { - if (!HostLog.IsEnabled(HostLogLevel.Trace)) return; - - HostLog.Trace(string.Create( - CultureInfo.InvariantCulture, - $"EnablePlugin(0x{(nint)exports:X}, plugin id {pluginId}); reported record size {(exports is null ? -1 : exports->SizeOfExportedFunctions)}")); - } - - private static LifecycleStart TryStartEnable(out PluginDescriptor? descriptor) - { - descriptor = null; - if (!TryEnterLifecycleCallback("EnablePlugin")) return LifecycleStart.Refused; - - try - { - descriptor = s_descriptor; - if (descriptor is null) - { - HostLog.Error( - "EnablePlugin: the bootstrap has not run (InitializeManaged was never called successfully)."); - return LifecycleStart.Refused; - } - - if (Phase is PluginHostLifecyclePhase.Enabled) - { - HostLog.Warning("EnablePlugin: the plugin is already enabled; the call is ignored."); - return LifecycleStart.AlreadyStable; - } - - if (Phase is not PluginHostLifecyclePhase.Registered) - { - HostLog.Error("EnablePlugin: the plugin lifecycle is in " + Phase + - "; enable is valid only from Registered."); - return LifecycleStart.Refused; - } - - SetPhase(PluginHostLifecyclePhase.Enabling); - return LifecycleStart.Started; - } - finally - { - // The phase, rather than SGate, guards the long-running transition after this point. - SGate.Exit(); - } - } - - private static bool RunEnable(ManagedExportedFunctions* exports, uint pluginId, PluginDescriptor descriptor) - { - Volatile.Write(ref s_incompleteEnableCleanup, 0); - Volatile.Write(ref s_incompleteEnableCleanupActive, 0); - if (!TryCopyExports(exports, out var copy)) return false; - if (!TryBindLua(in copy)) return false; - if (!TryGetOrCreatePlugin(descriptor, out var plugin)) return false; - - return AttachAndEnable(plugin, in copy, pluginId); - } - - // Attaches the runtime binding (this thread is the main thread of this enable; the epoch advances), publishes - // the context so that OnEnable can use it, and runs OnEnable. It opens dispatch admission only after OnEnable - // succeeds. IsEnabled intentionally stays false until then: the published context is lifecycle-only in Enabling. - private static bool AttachAndEnable(CheatEnginePlugin plugin, in ManagedExportedFunctions exports, uint pluginId) - { - var runtimeAttached = false; - CancellationTokenSource? shutdown = null; - try - { - var context = AttachAndPublishEnableContext(in exports, pluginId, out runtimeAttached, out shutdown); - plugin.OnEnable(); - CompleteEnable(context, shutdown); - return true; - } - catch (Exception exception) - { - HostLog.Error("EnablePlugin: OnEnable threw; the plugin stays disabled.", exception); - return false; - } - finally - { - if (Phase is not PluginHostLifecyclePhase.Enabled) CleanupFailedEnable(runtimeAttached, shutdown); - } - } - - private static PluginContext AttachAndPublishEnableContext( - in ManagedExportedFunctions exports, - uint pluginId, - out bool runtimeAttached, - [NotNull] out CancellationTokenSource? shutdown) - { - var mainThreadId = Environment.CurrentManagedThreadId; - LuaHostBinding binding = new(exports.GetLuaState, exports.LuaPushClassInstance, mainThreadId); - runtimeAttached = false; - shutdown = null; - - LuaRuntime.Attach(in binding); - runtimeAttached = true; - shutdown = CreateShutdownSource(); - PluginContext context = new(in exports, pluginId, LuaRuntime.Epoch, mainThreadId, in binding, shutdown.Token); - Volatile.Write(ref s_context, context); - return context; - } - - private static void CompleteEnable(PluginContext context, CancellationTokenSource shutdown) - { - // OnEnable observes Enabling and cannot admit worker dispatch. Only its successful completion opens the gate. - OpenMainThreadWorkAdmission(shutdown); - SetPhase(PluginHostLifecyclePhase.Enabled); - - if (HostLog.IsEnabled(HostLogLevel.Information)) - HostLog.Information(string.Create(CultureInfo.InvariantCulture, - $"Plugin {context.PluginId} enabled (epoch {context.Epoch}).")); - } - - private static void CleanupFailedEnable(bool runtimeAttached, CancellationTokenSource? shutdown) - { - var cleanupSucceeded = false; - try - { - if (shutdown is not null) CloseMainThreadWorkAdmissionAndSignalShutdown(Volatile.Read(ref s_context)); - - if (runtimeAttached) - { - LuaRuntime.CloseOperationAdmissionAndDrain(); - LuaRuntime.Detach(); - } - - cleanupSucceeded = true; - } - catch (Exception exception) - { - HostLog.Error("EnablePlugin: Lua cleanup after a failed enable threw; shutdown remains incomplete.", exception); - if (runtimeAttached) - { - Volatile.Write(ref s_incompleteEnableCleanup, 1); - SetPhase(PluginHostLifecyclePhase.Disabling); - } - } - - if (!cleanupSucceeded) return; - - Volatile.Write(ref s_context, null); - Volatile.Write(ref s_incompleteEnableCleanup, 0); - Volatile.Write(ref s_incompleteEnableCleanupActive, 0); - if (shutdown is not null) EndMainThreadWorkAdmission(); - } - - // Honours the size field: a record shorter than the one this SDK knows cannot be copied safely; a longer one - // carries fields of a later host revision, which are ignored. The copy is what survives the call. - private static bool TryCopyExports(ManagedExportedFunctions* exports, out ManagedExportedFunctions copy) - { - copy = default; - if (exports is null) - { - HostLog.Error("EnablePlugin: the exports record address is zero."); - return false; - } - - var reportedSize = exports->SizeOfExportedFunctions; - if (reportedSize < sizeof(ManagedExportedFunctions)) - { - HostLog.Error(string.Create( - CultureInfo.InvariantCulture, - $"EnablePlugin: the host reports a {reportedSize}-byte exports record, smaller than the {sizeof(ManagedExportedFunctions)}-byte record this SDK expects.")); - return false; - } - - copy = *exports; - if (copy.GetLuaState is null) - { - HostLog.Error("EnablePlugin: the exports record has no GetLuaState function."); - return false; - } - - return true; - } - - // The Lua API table first (a forwarder called through an unbound table jumps to address zero), then a cheap - // check that the host's state and the bound library agree: the registry pseudo-index must hold a table. - private static bool TryBindLua(in ManagedExportedFunctions exports) - { - if (!LuaModuleLocator.TryBind(out var bindFailure)) - { - HostLog.Error("EnablePlugin: " + bindFailure); - return false; - } - - var L = (lua_State*)exports.GetLuaState(); - if (L is null) - { - HostLog.Error("EnablePlugin: GetLuaState returned no state for the enabling thread."); - return false; - } - - if (LuaApi.lua_type(L, LuaApi.LUA_REGISTRYINDEX) != LuaApi.LUA_TTABLE) - { - HostLog.Error( - "EnablePlugin: the Lua registry is not a table; the bound Lua library does not match the host's state."); - return false; - } - - return true; - } - - // Constructed once per process, and before the runtime binding is attached on purpose: SDK use from a - // constructor is a documented error, and here it fails loudly instead of working by luck on the first enable. - private static bool TryGetOrCreatePlugin(PluginDescriptor descriptor, - [NotNullWhen(true)] out CheatEnginePlugin? plugin) - { - plugin = s_plugin; - if (plugin is not null) return true; - - try - { - plugin = descriptor.CreatePlugin(); - } - catch (Exception exception) - { - HostLog.Error("EnablePlugin: the plugin constructor threw.", exception); - return false; - } - - if (plugin is null) - { - HostLog.Error("EnablePlugin: the plugin factory returned null."); - return false; - } - - s_plugin = plugin; - return true; - } - - private static bool Disable() - { - HostLog.Trace("DisablePlugin()"); - var start = TryStartDisable(out var context, out var plugin); - if (start is LifecycleStart.Refused) return false; - if (start is LifecycleStart.AlreadyStable) return true; - - var cleanupSucceeded = false; - try - { - RunDisable(context!, plugin); - } - finally - { - // A failed detach leaves the attached runtime and context available for diagnosis rather than publishing a - // successful Registered state. The native callback must report that incomplete shutdown to the host. - cleanupSucceeded = CleanupDisable(); - } - - // Keep the retry claimed through detach failure and its synchronous error log. Only after the whole attempt - // has unwound may another host request claim the pending cleanup again. - Volatile.Write(ref s_incompleteEnableCleanupActive, 0); - - if (cleanupSucceeded && HostLog.IsEnabled(HostLogLevel.Information)) - HostLog.Information($"Plugin {context!.PluginId} disabled."); - - return cleanupSucceeded; - } - - private static LifecycleStart TryStartDisable( - out PluginContext? context, - out CheatEnginePlugin? plugin) - { - context = null; - plugin = null; - if (!CanStartDisable()) return LifecycleStart.Refused; - if (!TryEnterLifecycleCallback("DisablePlugin")) return LifecycleStart.Refused; - try - { - if (Phase is PluginHostLifecyclePhase.Registered or PluginHostLifecyclePhase.Uninitialized) - { - HostLog.Warning("DisablePlugin: the plugin is not enabled; the call is ignored."); - return LifecycleStart.AlreadyStable; - } - context = s_context; - if (context is null) - { - HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; disable is valid only from Enabled or incomplete failed-enable cleanup."); - return LifecycleStart.Refused; - } - - if (Phase is PluginHostLifecyclePhase.Disabling) - { - if (Volatile.Read(ref s_incompleteEnableCleanup) == 0 - || Volatile.Read(ref s_incompleteEnableCleanupActive) != 0) - { - HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; a disable transition is already completing."); - return LifecycleStart.Refused; - } - - return TryStartIncompleteDisable(context, out plugin); - } - - if (Phase is not PluginHostLifecyclePhase.Enabled) - { - HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; disable is valid only from Enabled or incomplete failed-enable cleanup."); - return LifecycleStart.Refused; - } - - if (!context.IsMainThread) - { - HostLog.Error( - "DisablePlugin: the host invoked disable from a thread other than the captured plugin main thread; cleanup is refused because it could not safely drain GUI-bound work."); - return LifecycleStart.Refused; - } - - SetPhase(PluginHostLifecyclePhase.Disabling); - plugin = s_plugin; - return LifecycleStart.Started; - } - finally - { - SGate.Exit(); - } - } - - private static bool CanStartDisable() - { - if (LuaRuntime.IsOperationAdmittedOnCurrentThread) - { - HostLog.Error( - "DisablePlugin: disable was requested from an admitted Lua operation; the request is refused because shutdown would wait for that operation to return."); - return false; - } - - if (MainThreadDispatcher.IsExecutingInlineWorkOnCurrentThread) - { - HostLog.Error( - "DisablePlugin: disable was requested from inline main-thread work; the request is refused because shutdown would wait for that work to return."); - return false; - } - - if (MainThreadDispatcher.IsExecutingWorkOnCurrentThread) - { - HostLog.Error( - "DisablePlugin: disable was requested from dispatched main-thread work; the request is refused because shutdown would wait for that work to return."); - return false; - } - - return true; - } - - private static LifecycleStart TryStartIncompleteDisable(PluginContext context, out CheatEnginePlugin? plugin) - { - plugin = null; - if (!context.IsMainThread) - { - HostLog.Error( - "DisablePlugin: retrying incomplete enable cleanup from a different thread is refused; use the captured plugin main thread."); - return LifecycleStart.Refused; - } - - // SGate is held by the caller, so this claim closes the re-entrant window before the transition is returned. - Volatile.Write(ref s_incompleteEnableCleanupActive, 1); - - return LifecycleStart.Started; - } - - private static void RunDisable(PluginContext context, CheatEnginePlugin? plugin) - { - // Main-thread work admission is closed outside SGate, so an admitted worker can finish and release its lease. - CloseMainThreadWorkAdmissionAndSignalShutdown(context); - // Subscription callbacks are made inert before plugin state is torn down. Their host-object unregister actions - // still run later in LuaRuntime.Detach, while the attached state can be reached. - LuaRuntime.CloseHostSubscriptionAdmissionAndDrain(); - - if (plugin is not null) - try - { - // The runtime is still attached, so plugin cleanup can release Lua resources before Detach. - plugin.OnDisable(); - } - catch (Exception exception) - { - HostLog.Error("DisablePlugin: OnDisable threw; cleanup continues and detach determines the final disable result.", exception); - } - - } - - private static bool CleanupDisable() - { - try - { - // The operation gate has already shut out every admitted Lua caller before callback neutralization. - LuaRuntime.CloseOperationAdmissionAndDrain(); - LuaRuntime.Detach(); - - Volatile.Write(ref s_context, null); - Volatile.Write(ref s_incompleteEnableCleanup, 0); - EndMainThreadWorkAdmission(); - SetPhase(PluginHostLifecyclePhase.Registered); - return true; - } - catch (Exception exception) - { - HostLog.Error( - "DisablePlugin: Lua detach threw; shutdown remains incomplete and the lifecycle stays Disabling.", - exception); - return false; - } - } - - private enum LifecycleStart - { - Refused, - AlreadyStable, - Started, - } + /// + /// Cheat Engine's version query. Writes and the name pointer of the + /// bootstrap into the host-owned . + /// + /// The host's record. + /// + /// The byte size the host reserved for this version record. A positive value smaller than the known record is + /// refused; zero or a negative value is treated as unknown. This contract is independent of the bootstrap's + /// opaque second integer. + /// + /// TRUE when the record was written. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 GetVersion(PluginVersion* version, int size) + { + try + { + Volatile.Write(ref s_lastVersionRecordSize, size); + if (HostLog.IsEnabled(HostLogLevel.Trace)) + { + HostLog.Trace(string.Create(CultureInfo.InvariantCulture, + $"GetVersion(0x{(nint) version:X}, size {size})")); + } + + if (version is null) + { + HostLog.Error("GetVersion: the version record address is zero."); + return Bool32.False; + } + + if (size > 0 && size < sizeof(PluginVersion)) + { + // Writing 16 bytes into a buffer the host says is smaller is memory corruption: refuse, and say so. + // The public 7.5 host passes sizeof(TPluginVersion); a host that claims less is reporting a record + // this SDK does not know. Zero or negative claims nothing and are treated as unknown for this distinct + // version-record callback; this says nothing about the opaque bootstrap argument. + HostLog.Error(string.Create( + CultureInfo.InvariantCulture, + $"GetVersion: the host reserved {size} bytes for the version record, fewer than the {sizeof(PluginVersion)} this SDK writes.")); + return Bool32.False; + } + + byte* name = s_name; + if (name is null) + { + HostLog.Error("GetVersion: the bootstrap has not run, there is no plugin name to report."); + return Bool32.False; + } + + version->Version = AbiConstants.SdkVersion; + version->PluginName = name; + return Bool32.True; + } + catch (Exception exception) + { + HostLog.Error("GetVersion failed.", exception); + return Bool32.False; + } + } + + /// + /// Cheat Engine's enable callback: copies the exports record, binds the Lua API, checks it, constructs the + /// plugin on the first enable, attaches the runtime binding and runs . + /// + /// The host's exports record (a stack local of the host: copied, never retained). + /// The id the host assigned. + /// + /// TRUE when the plugin is enabled when the call returns; FALSE on any failure, and when the call + /// is nested in or concurrent with another lifecycle transition. + /// + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 EnablePlugin(ManagedExportedFunctions* exports, uint pluginId) + { + try + { + return Enable(exports, pluginId) ? Bool32.True : Bool32.False; + } + catch (Exception exception) + { + HostLog.Error("EnablePlugin failed.", exception); + return Bool32.False; + } + } + + /// + /// Cheat Engine's disable callback: runs , detaches the runtime + /// binding (which neutralizes every live Lua callback) and withdraws the context. + /// + /// + /// TRUE after the plugin is disabled, including when OnDisable throws after its failure is logged; + /// FALSE when another lifecycle transition is active, when the host invokes disable from a thread other + /// than the captured plugin main thread, when it is nested in admitted Lua or dispatched work, or when Lua + /// cleanup cannot detach the runtime. Refusals leave the lifecycle unchanged; incomplete cleanup remains in + /// for diagnosis rather than reporting a false completion. + /// + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 DisablePlugin() + { + try + { + return Disable() ? Bool32.True : Bool32.False; + } + catch (Exception exception) + { + HostLog.Error("DisablePlugin failed.", exception); + return Bool32.False; + } + } + + // SGate only serializes the short state-selection step. The phase remains Enabling/Disabling for the full callback, + // so a nested callback after SGate was released is still refused. A callback arriving during state selection also + // gets an immediate FALSE rather than blocking behind a native lifecycle path. + private static bool TryEnterLifecycleCallback(string callback) + { + if (SGate.IsHeldByCurrentThread) + { + HostLog.Error(callback + + ": re-entered from plugin code while OnEnable or OnDisable is running on this thread; the call is refused and the outer transition decides the state."); + return false; + } + + if (SGate.TryEnter()) + { + return true; + } + + HostLog.Error(callback + + ": another lifecycle transition is already running; concurrent callbacks fail immediately and do not wait for plugin code."); + return false; + } + + private static bool Enable(ManagedExportedFunctions* exports, uint pluginId) + { + TraceEnableCall(exports, pluginId); + LifecycleStart start = TryStartEnable(out PluginDescriptor? descriptor); + if (start is LifecycleStart.Refused) + { + return false; + } + + if (start is LifecycleStart.AlreadyStable) + { + return true; + } + + try + { + return RunEnable(exports, pluginId, descriptor!); + } + finally + { + // Any pre-attach or OnEnable failure returns to Registered. Successful enable moved to Enabled first. + if (Phase is PluginHostLifecyclePhase.Enabling) + { + SetPhase(PluginHostLifecyclePhase.Registered); + } + } + } + + private static void TraceEnableCall(ManagedExportedFunctions* exports, uint pluginId) + { + if (!HostLog.IsEnabled(HostLogLevel.Trace)) + { + return; + } + + HostLog.Trace(string.Create( + CultureInfo.InvariantCulture, + $"EnablePlugin(0x{(nint) exports:X}, plugin id {pluginId}); reported record size {(exports is null ? -1 : exports->SizeOfExportedFunctions)}")); + } + + private static LifecycleStart TryStartEnable(out PluginDescriptor? descriptor) + { + descriptor = null; + if (!TryEnterLifecycleCallback("EnablePlugin")) + { + return LifecycleStart.Refused; + } + + try + { + descriptor = s_descriptor; + if (descriptor is null) + { + HostLog.Error( + "EnablePlugin: the bootstrap has not run (InitializeManaged was never called successfully)."); + return LifecycleStart.Refused; + } + + if (Phase is PluginHostLifecyclePhase.Enabled) + { + HostLog.Warning("EnablePlugin: the plugin is already enabled; the call is ignored."); + return LifecycleStart.AlreadyStable; + } + + if (Phase is not PluginHostLifecyclePhase.Registered) + { + HostLog.Error("EnablePlugin: the plugin lifecycle is in " + Phase + + "; enable is valid only from Registered."); + return LifecycleStart.Refused; + } + + SetPhase(PluginHostLifecyclePhase.Enabling); + return LifecycleStart.Started; + } + finally + { + // The phase, rather than SGate, guards the long-running transition after this point. + SGate.Exit(); + } + } + + private static bool RunEnable(ManagedExportedFunctions* exports, uint pluginId, PluginDescriptor descriptor) + { + Volatile.Write(ref s_incompleteEnableCleanup, 0); + Volatile.Write(ref s_incompleteEnableCleanupActive, 0); + if (!TryCopyExports(exports, out ManagedExportedFunctions copy)) + { + return false; + } + + if (!TryBindLua(in copy)) + { + return false; + } + + if (!TryGetOrCreatePlugin(descriptor, out CheatEnginePlugin? plugin)) + { + return false; + } + + return AttachAndEnable(plugin, in copy, pluginId); + } + + // Attaches the runtime binding (this thread is the main thread of this enable; the epoch advances), publishes + // the context so that OnEnable can use it, and runs OnEnable. It opens dispatch admission only after OnEnable + // succeeds. IsEnabled intentionally stays false until then: the published context is lifecycle-only in Enabling. + private static bool AttachAndEnable(CheatEnginePlugin plugin, in ManagedExportedFunctions exports, uint pluginId) + { + bool runtimeAttached = false; + CancellationTokenSource? shutdown = null; + try + { + PluginContext context = + AttachAndPublishEnableContext(in exports, pluginId, out runtimeAttached, out shutdown); + plugin.OnEnable(); + CompleteEnable(context, shutdown); + return true; + } + catch (Exception exception) + { + HostLog.Error("EnablePlugin: OnEnable threw; the plugin stays disabled.", exception); + return false; + } + finally + { + if (Phase is not PluginHostLifecyclePhase.Enabled) + { + CleanupFailedEnable(runtimeAttached, shutdown); + } + } + } + + private static PluginContext AttachAndPublishEnableContext( + in ManagedExportedFunctions exports, + uint pluginId, + out bool runtimeAttached, + [NotNull] out CancellationTokenSource? shutdown) + { + int mainThreadId = Environment.CurrentManagedThreadId; + LuaHostBinding binding = new(exports.GetLuaState, exports.LuaPushClassInstance, mainThreadId); + runtimeAttached = false; + shutdown = null; + + LuaRuntime.Attach(in binding); + runtimeAttached = true; + shutdown = CreateShutdownSource(); + PluginContext context = new(in exports, pluginId, LuaRuntime.Epoch, mainThreadId, in binding, shutdown.Token); + Volatile.Write(ref s_context, context); + return context; + } + + private static void CompleteEnable(PluginContext context, CancellationTokenSource shutdown) + { + // OnEnable observes Enabling and cannot admit worker dispatch. Only its successful completion opens the gate. + OpenMainThreadWorkAdmission(shutdown); + SetPhase(PluginHostLifecyclePhase.Enabled); + + if (HostLog.IsEnabled(HostLogLevel.Information)) + { + HostLog.Information(string.Create(CultureInfo.InvariantCulture, + $"Plugin {context.PluginId} enabled (epoch {context.Epoch}).")); + } + } + + private static void CleanupFailedEnable(bool runtimeAttached, CancellationTokenSource? shutdown) + { + bool cleanupSucceeded = false; + try + { + if (shutdown is not null) + { + CloseMainThreadWorkAdmissionAndSignalShutdown(Volatile.Read(ref s_context)); + } + + if (runtimeAttached) + { + LuaRuntime.CloseOperationAdmissionAndDrain(); + LuaRuntime.Detach(); + } + + cleanupSucceeded = true; + } + catch (Exception exception) + { + HostLog.Error("EnablePlugin: Lua cleanup after a failed enable threw; shutdown remains incomplete.", + exception); + if (runtimeAttached) + { + Volatile.Write(ref s_incompleteEnableCleanup, 1); + SetPhase(PluginHostLifecyclePhase.Disabling); + } + } + + if (!cleanupSucceeded) + { + return; + } + + Volatile.Write(ref s_context, null); + Volatile.Write(ref s_incompleteEnableCleanup, 0); + Volatile.Write(ref s_incompleteEnableCleanupActive, 0); + if (shutdown is not null) + { + EndMainThreadWorkAdmission(); + } + } + + // Honours the size field: a record shorter than the one this SDK knows cannot be copied safely; a longer one + // carries fields of a later host revision, which are ignored. The copy is what survives the call. + private static bool TryCopyExports(ManagedExportedFunctions* exports, out ManagedExportedFunctions copy) + { + copy = default; + if (exports is null) + { + HostLog.Error("EnablePlugin: the exports record address is zero."); + return false; + } + + int reportedSize = exports->SizeOfExportedFunctions; + if (reportedSize < sizeof(ManagedExportedFunctions)) + { + HostLog.Error(string.Create( + CultureInfo.InvariantCulture, + $"EnablePlugin: the host reports a {reportedSize}-byte exports record, smaller than the {sizeof(ManagedExportedFunctions)}-byte record this SDK expects.")); + return false; + } + + copy = *exports; + if (copy.GetLuaState is null) + { + HostLog.Error("EnablePlugin: the exports record has no GetLuaState function."); + return false; + } + + return true; + } + + // The Lua API table first (a forwarder called through an unbound table jumps to address zero), then a cheap + // check that the host's state and the bound library agree: the registry pseudo-index must hold a table. + private static bool TryBindLua(in ManagedExportedFunctions exports) + { + if (!LuaModuleLocator.TryBind(out string? bindFailure)) + { + HostLog.Error("EnablePlugin: " + bindFailure); + return false; + } + + lua_State* L = (lua_State*) exports.GetLuaState(); + if (L is null) + { + HostLog.Error("EnablePlugin: GetLuaState returned no state for the enabling thread."); + return false; + } + + if (LuaApi.lua_type(L, LuaApi.LUA_REGISTRYINDEX) != LuaApi.LUA_TTABLE) + { + HostLog.Error( + "EnablePlugin: the Lua registry is not a table; the bound Lua library does not match the host's state."); + return false; + } + + return true; + } + + // Constructed once per process, and before the runtime binding is attached on purpose: SDK use from a + // constructor is a documented error, and here it fails loudly instead of working by luck on the first enable. + private static bool TryGetOrCreatePlugin(PluginDescriptor descriptor, + [NotNullWhen(true)] out CheatEnginePlugin? plugin) + { + plugin = s_plugin; + if (plugin is not null) + { + return true; + } + + try + { + plugin = descriptor.CreatePlugin(); + } + catch (Exception exception) + { + HostLog.Error("EnablePlugin: the plugin constructor threw.", exception); + return false; + } + + if (plugin is null) + { + HostLog.Error("EnablePlugin: the plugin factory returned null."); + return false; + } + + s_plugin = plugin; + return true; + } + + private static bool Disable() + { + HostLog.Trace("DisablePlugin()"); + LifecycleStart start = TryStartDisable(out PluginContext? context, out CheatEnginePlugin? plugin); + if (start is LifecycleStart.Refused) + { + return false; + } + + if (start is LifecycleStart.AlreadyStable) + { + return true; + } + + bool cleanupSucceeded = false; + try + { + RunDisable(context!, plugin); + } + finally + { + // A failed detach leaves the attached runtime and context available for diagnosis rather than publishing a + // successful Registered state. The native callback must report that incomplete shutdown to the host. + cleanupSucceeded = CleanupDisable(); + } + + // Keep the retry claimed through detach failure and its synchronous error log. Only after the whole attempt + // has unwound may another host request claim the pending cleanup again. + Volatile.Write(ref s_incompleteEnableCleanupActive, 0); + + if (cleanupSucceeded && HostLog.IsEnabled(HostLogLevel.Information)) + { + HostLog.Information($"Plugin {context!.PluginId} disabled."); + } + + return cleanupSucceeded; + } + + private static LifecycleStart TryStartDisable( + out PluginContext? context, + out CheatEnginePlugin? plugin) + { + context = null; + plugin = null; + if (!CanStartDisable()) + { + return LifecycleStart.Refused; + } + + if (!TryEnterLifecycleCallback("DisablePlugin")) + { + return LifecycleStart.Refused; + } + + try + { + return SelectDisableStart(out context, out plugin); + } + finally + { + SGate.Exit(); + } + } + + private static LifecycleStart SelectDisableStart( + out PluginContext? context, + out CheatEnginePlugin? plugin) + { + context = null; + plugin = null; + if (Phase is PluginHostLifecyclePhase.Registered or PluginHostLifecyclePhase.Uninitialized) + { + HostLog.Warning("DisablePlugin: the plugin is not enabled; the call is ignored."); + return LifecycleStart.AlreadyStable; + } + + context = s_context; + if (context is null) + { + HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + + "; disable is valid only from Enabled or incomplete failed-enable cleanup."); + return LifecycleStart.Refused; + } + + if (Phase is PluginHostLifecyclePhase.Disabling) + { + if (Volatile.Read(ref s_incompleteEnableCleanup) == 0 + || Volatile.Read(ref s_incompleteEnableCleanupActive) != 0) + { + HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + + "; a disable transition is already completing."); + return LifecycleStart.Refused; + } + + return TryStartIncompleteDisable(context, out plugin); + } + + if (Phase is not PluginHostLifecyclePhase.Enabled) + { + HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + + "; disable is valid only from Enabled or incomplete failed-enable cleanup."); + return LifecycleStart.Refused; + } + + if (!context.IsMainThread) + { + HostLog.Error( + "DisablePlugin: the host invoked disable from a thread other than the captured plugin main thread; cleanup is refused because it could not safely drain GUI-bound work."); + return LifecycleStart.Refused; + } + + SetPhase(PluginHostLifecyclePhase.Disabling); + plugin = s_plugin; + return LifecycleStart.Started; + } + + private static bool CanStartDisable() + { + if (LuaRuntime.IsOperationAdmittedOnCurrentThread) + { + HostLog.Error( + "DisablePlugin: disable was requested from an admitted Lua operation; the request is refused because shutdown would wait for that operation to return."); + return false; + } + + if (MainThreadDispatcher.IsExecutingInlineWorkOnCurrentThread) + { + HostLog.Error( + "DisablePlugin: disable was requested from inline main-thread work; the request is refused because shutdown would wait for that work to return."); + return false; + } + + if (MainThreadDispatcher.IsExecutingWorkOnCurrentThread) + { + HostLog.Error( + "DisablePlugin: disable was requested from dispatched main-thread work; the request is refused because shutdown would wait for that work to return."); + return false; + } + + return true; + } + + private static LifecycleStart TryStartIncompleteDisable(PluginContext context, out CheatEnginePlugin? plugin) + { + plugin = null; + if (!context.IsMainThread) + { + HostLog.Error( + "DisablePlugin: retrying incomplete enable cleanup from a different thread is refused; use the captured plugin main thread."); + return LifecycleStart.Refused; + } + + // SGate is held by the caller, so this claim closes the re-entrant window before the transition is returned. + Volatile.Write(ref s_incompleteEnableCleanupActive, 1); + + return LifecycleStart.Started; + } + + private static void RunDisable(PluginContext context, CheatEnginePlugin? plugin) + { + // Main-thread work admission is closed outside SGate, so an admitted worker can finish and release its lease. + CloseMainThreadWorkAdmissionAndSignalShutdown(context); + // Subscription callbacks are made inert before plugin state is torn down. Their host-object unregister actions + // still run later in LuaRuntime.Detach, while the attached state can be reached. + LuaRuntime.CloseHostSubscriptionAdmissionAndDrain(); + + if (plugin is not null) + { + try + { + // The runtime is still attached, so plugin cleanup can release Lua resources before Detach. + plugin.OnDisable(); + } + catch (Exception exception) + { + HostLog.Error( + "DisablePlugin: OnDisable threw; cleanup continues and detach determines the final disable result.", + exception); + } + } + } + + private static bool CleanupDisable() + { + try + { + // The operation gate has already shut out every admitted Lua caller before callback neutralization. + LuaRuntime.CloseOperationAdmissionAndDrain(); + LuaRuntime.Detach(); + + Volatile.Write(ref s_context, null); + Volatile.Write(ref s_incompleteEnableCleanup, 0); + EndMainThreadWorkAdmission(); + SetPhase(PluginHostLifecyclePhase.Registered); + return true; + } + catch (Exception exception) + { + HostLog.Error( + "DisablePlugin: Lua detach threw; shutdown remains incomplete and the lifecycle stays Disabling.", + exception); + return false; + } + } + + private enum LifecycleStart + { + Refused, + AlreadyStable, + Started + } } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs index 38dfccd5..8f9ac2f0 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Threading; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Context; @@ -46,344 +47,369 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// public static unsafe partial class PluginHost { - private static readonly Lock SGate = new(); - private static readonly Lock SAdmissionGate = new(); - private static readonly ManualResetEventSlim SNoAdmittedMainThreadWork = new(initialState: true); - - // Written once by the first successful InitializeManaged, never cleared in production. - private static PluginDescriptor? s_descriptor; - private static byte* s_name; - - // Lifecycle state, written under s_gate. - private static CheatEnginePlugin? s_plugin; - private static PluginContext? s_context; - private static CancellationTokenSource? s_shutdown; - private static int s_admittedMainThreadWork; - private static int s_acceptingMainThreadWork; - private static int s_incompleteEnableCleanup; - private static int s_incompleteEnableCleanupActive; - private static int s_phase; - private static int s_lastInitRecordArgument = -1; - private static int s_lastVersionRecordSize = -1; - - /// Gets a value indicating whether the bootstrap has run: a factory is registered and the name buffer exists. - public static bool IsInitialized => Phase is not PluginHostLifecyclePhase.Uninitialized; - - /// Gets the current stable or transitional lifecycle phase. Lock-free; safe from any thread. - public static PluginHostLifecyclePhase Phase => (PluginHostLifecyclePhase)Volatile.Read(ref s_phase); - - /// - /// Gets a value indicating whether the plugin is enabled: an enable succeeded and no disable followed. Lock-free; - /// any thread. - /// - public static bool IsEnabled => Phase is PluginHostLifecyclePhase.Enabled; - - /// - /// Gets the context of the current enable, or while the plugin is disabled. Lock-free; - /// any thread. - /// - public static PluginContext? Context => Volatile.Read(ref s_context); - - /// - /// Gets the opaque second integer from the most recent bootstrap call, or -1 before the first one. Diagnostic - /// only: CE 7.7's meaning for this value is not established, so Hosting records and forwards it without treating - /// it as a record size, version, or capability value. - /// - public static int LastInitRecordArgument => Volatile.Read(ref s_lastInitRecordArgument); - - /// - /// Gets the size argument of the most recent GetVersion call, or -1 before the first one. This is a - /// distinct version-record contract; it does not establish the meaning of the bootstrap's opaque - /// . - /// - public static int LastVersionRecordSize => Volatile.Read(ref s_lastVersionRecordSize); - - /// The plugin instance, once constructed. Tests only. - internal static CheatEnginePlugin? PluginForTests => Volatile.Read(ref s_plugin); - - /// - /// Admits one synchronous main-thread dispatch associated with . - /// - /// - /// The returned lease must be disposed after the host's synchronize call returns. Disable closes this - /// gate before signalling and waits for all such leases before it - /// detaches Lua and neutralizes its callbacks. - /// - /// - /// is stale, or the plugin is stopping and no longer accepts new main-thread work. - /// - internal static MainThreadWorkAdmission AdmitMainThreadWork(PluginContext context) - { - lock (SAdmissionGate) - { - if (Phase is not PluginHostLifecyclePhase.Enabled || - !ReferenceEquals(context, Volatile.Read(ref s_context)) || - Volatile.Read(ref s_acceptingMainThreadWork) == 0) - throw new InvalidOperationException( - "The plugin is stopping or disabled and no longer accepts new main-thread dispatch work."); - - if (s_admittedMainThreadWork == 0) SNoAdmittedMainThreadWork.Reset(); - - checked - { - s_admittedMainThreadWork++; - } - - return new MainThreadWorkAdmission(); - } - } - - /// - /// The managed bootstrap: fills the at with the - /// plugin name and the three lifecycle callbacks. Called by the generated CESDK.CESDK.CEPluginInitialize; - /// Cheat Engine calls that twice per plugin, so this method is idempotent: the second call writes the same - /// values, including the same name pointer. - /// - /// The factory the entry-point generator emitted (or a hand-written one). - /// Address of the host-owned record; 36 bytes, byte-packed. - /// - /// The opaque second integer received from Cheat Engine. Its CE 7.7 meaning has not been live-verified; this - /// method records it for diagnostics but does not derive a record size or any other behavior from it. - /// - /// 1 () when the record was written; 0 otherwise. - /// - /// Never throws. Fails, with an entry in , when is zero, the - /// process is not x64, the factory's name cannot be - /// allocated, or a different factory type was registered by an earlier call in this loaded Hosting assembly - /// instance (the first factory in that instance wins deterministically). The name buffer is allocated on the first - /// successful call and never freed: Cheat Engine keeps reading through the pointer. - /// - public static int InitializeManaged(nint initRecord, int hostArgument) - where TFactory : IPluginFactory - { - try - { - Volatile.Write(ref s_lastInitRecordArgument, hostArgument); - if (HostLog.IsEnabled(HostLogLevel.Trace)) - HostLog.Trace(string.Create(CultureInfo.InvariantCulture, - $"InitializeManaged<{typeof(TFactory)}>(0x{initRecord:X}, host argument {hostArgument})")); - - if (initRecord == 0) - { - HostLog.Error("InitializeManaged: the init record address is zero."); - return ManagedEntryPoint.Failure; - } - - if (!AbiArchitecture.IsSupported) - { - HostLog.Error("InitializeManaged: this SDK is validated for x64 processes only."); - return ManagedEntryPoint.Failure; - } - - if (!TryRegisterFactory(out var name)) return ManagedEntryPoint.Failure; - - // Written field by field through the packed layout: 36 bytes, no tail padding, no structure marshalling. - var record = (PluginInitRecord*)initRecord; - record->Name = name; - record->GetVersion = &GetVersion; - record->EnablePlugin = &EnablePlugin; - record->DisablePlugin = &DisablePlugin; - record->Version = AbiConstants.SdkVersion; - return ManagedEntryPoint.Success; - } - catch (Exception exception) - { - HostLog.Error("InitializeManaged failed.", exception); - return ManagedEntryPoint.Failure; - } - } - - // First call: copies the name out of the factory and pins the factory type. Later calls: the same type gets the - // same name pointer; a different type is rejected, deterministically, for the life of this loaded Hosting assembly - // instance. - private static bool TryRegisterFactory(out byte* name) - where TFactory : IPluginFactory - { - lock (SGate) - { - var registered = s_descriptor; - if (registered is null) - { - name = AnsiNameBuffer.Allocate(TFactory.Utf8Name); - s_name = name; - Volatile.Write(ref s_descriptor, new PluginDescriptor()); - SetPhase(PluginHostLifecyclePhase.Registered); - return true; - } - - if (registered.FactoryType != typeof(TFactory)) - { - HostLog.Error( - "InitializeManaged: a plugin factory of type " + registered.FactoryType + - " is already registered in this Hosting assembly instance; " - + typeof(TFactory) + " is rejected. One plugin per loaded Hosting assembly instance."); - name = null; - return false; - } - - name = s_name; - return true; - } - } - - /// The current context, or an exception for code that cannot proceed without one. - /// The plugin is not enabled. - internal static PluginContext RequireContext() - { - var context = Volatile.Read(ref s_context); - if (context is null) ThrowNotEnabled(); - - return context; - } - - /// - /// Returns the host to its never-bootstrapped state: disables the plugin if it is enabled (without calling - /// ), forgets the factory, the plugin instance and the name buffer (which - /// is leaked, as in production). Tests only: in Cheat Engine the state lives as long as the process. - /// - internal static void ResetForTests() - { - lock (SGate) - { - CloseMainThreadWorkAdmissionAndSignalShutdown(Volatile.Read(ref s_context)); - if (s_context is not null) - { - LuaRuntime.CloseOperationAdmissionAndDrain(); - LuaRuntime.Detach(); - Volatile.Write(ref s_context, null); - } - - EndMainThreadWorkAdmission(); - - s_plugin = null; - s_name = null; - Volatile.Write(ref s_descriptor, null); - Volatile.Write(ref s_incompleteEnableCleanup, 0); - Volatile.Write(ref s_incompleteEnableCleanupActive, 0); - SetPhase(PluginHostLifecyclePhase.Uninitialized); - Volatile.Write(ref s_lastInitRecordArgument, -1); - Volatile.Write(ref s_lastVersionRecordSize, -1); - } - } - - private static void SetPhase(PluginHostLifecyclePhase phase) - { - Volatile.Write(ref s_phase, (int)phase); - } - - // The admission lock makes the "close then drain" boundary exact: a worker either obtains a lease before - // Disable closes the gate, or observes the closed gate and never becomes work Disable must wait for. - private static CancellationTokenSource CreateShutdownSource() - { - lock (SAdmissionGate) - { - var shutdown = new CancellationTokenSource(); - s_shutdown = shutdown; - return shutdown; - } - } - - private static void OpenMainThreadWorkAdmission(CancellationTokenSource shutdown) - { - lock (SAdmissionGate) - { - if (!ReferenceEquals(s_shutdown, shutdown)) - throw new InvalidOperationException( - "The lifecycle shutdown source was replaced before work admission opened."); - - Volatile.Write(ref s_acceptingMainThreadWork, 1); - } - } - - private static void CloseMainThreadWorkAdmissionAndSignalShutdown(PluginContext? context) - { - CancellationTokenSource? shutdown; - lock (SAdmissionGate) - { - Volatile.Write(ref s_acceptingMainThreadWork, 0); - shutdown = s_shutdown; - } - - if (shutdown is not null) - try - { - shutdown.Cancel(throwOnFirstException: false); - } - catch (Exception exception) - { - // A cancellation registration is plugin code. It cannot prevent the required drain and detach. - HostLog.Error("A plugin shutdown callback threw while DisablePlugin was signalling shutdown.", - exception); - } - - DrainAdmittedMainThreadWork(context); - } - - private static void DrainAdmittedMainThreadWork(PluginContext? context) - { - // A worker already admitted through synchronize may be waiting for the GUI queue at exactly the point Disable - // begins. Waiting blindly on the GUI thread would deadlock it. When the host supplied CheckSynchronize, pump - // its queue until the last admitted work item releases its lease. MainThread.Invoke refuses worker dispatches - // without that slot, so an admitted GUI-bound work item always has this drain route. - while (!SNoAdmittedMainThreadWork.Wait(0, CancellationToken.None)) - { - if (context is not null && context.IsMainThread) - { - var checkSynchronize = context.Exports.CheckSynchronize; - if (checkSynchronize is not null) - { - checkSynchronize(0); - Thread.Yield(); - continue; - } - } - - SNoAdmittedMainThreadWork.Wait(CancellationToken.None); - } - } - - private static void EndMainThreadWorkAdmission() - { - CancellationTokenSource? shutdown; - lock (SAdmissionGate) - { - Volatile.Write(ref s_acceptingMainThreadWork, 0); - shutdown = s_shutdown; - s_shutdown = null; - } - - shutdown?.Dispose(); - } - - private static void ReleaseMainThreadWorkAdmission() - { - lock (SAdmissionGate) - { - if (s_admittedMainThreadWork <= 0) - throw new InvalidOperationException("The main-thread work admission was released more than once."); - - s_admittedMainThreadWork--; - if (s_admittedMainThreadWork == 0) SNoAdmittedMainThreadWork.Set(); - } - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowNotEnabled() - { - throw new InvalidOperationException( - "The plugin is not enabled: Cheat Engine has not called EnablePlugin, or has called DisablePlugin since."); - } - - /// A single admitted main-thread dispatch. Internal so only Hosting can close the lifecycle work gate. - internal sealed class MainThreadWorkAdmission : IDisposable - { - private int _released; - - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _released, 1) == 0) ReleaseMainThreadWorkAdmission(); - } - } + private static readonly Lock SGate = new(); + private static readonly Lock SAdmissionGate = new(); + private static readonly ManualResetEventSlim SNoAdmittedMainThreadWork = new(true); + + // Written once by the first successful InitializeManaged, never cleared in production. + private static PluginDescriptor? s_descriptor; + private static byte* s_name; + + // Lifecycle state, written under s_gate. + private static CheatEnginePlugin? s_plugin; + private static PluginContext? s_context; + private static CancellationTokenSource? s_shutdown; + private static int s_admittedMainThreadWork; + private static int s_acceptingMainThreadWork; + private static int s_incompleteEnableCleanup; + private static int s_incompleteEnableCleanupActive; + private static int s_phase; + private static int s_lastInitRecordArgument = -1; + private static int s_lastVersionRecordSize = -1; + + /// Gets a value indicating whether the bootstrap has run: a factory is registered and the name buffer exists. + public static bool IsInitialized => Phase is not PluginHostLifecyclePhase.Uninitialized; + + /// Gets the current stable or transitional lifecycle phase. Lock-free; safe from any thread. + public static PluginHostLifecyclePhase Phase => (PluginHostLifecyclePhase) Volatile.Read(ref s_phase); + + /// + /// Gets a value indicating whether the plugin is enabled: an enable succeeded and no disable followed. Lock-free; + /// any thread. + /// + public static bool IsEnabled => Phase is PluginHostLifecyclePhase.Enabled; + + /// + /// Gets the context of the current enable, or while the plugin is disabled. Lock-free; + /// any thread. + /// + public static PluginContext? Context => Volatile.Read(ref s_context); + + /// + /// Gets the opaque second integer from the most recent bootstrap call, or -1 before the first one. Diagnostic + /// only: CE 7.7's meaning for this value is not established, so Hosting records and forwards it without treating + /// it as a record size, version, or capability value. + /// + public static int LastInitRecordArgument => Volatile.Read(ref s_lastInitRecordArgument); + + /// + /// Gets the size argument of the most recent GetVersion call, or -1 before the first one. This is a + /// distinct version-record contract; it does not establish the meaning of the bootstrap's opaque + /// . + /// + public static int LastVersionRecordSize => Volatile.Read(ref s_lastVersionRecordSize); + + /// The plugin instance, once constructed. Tests only. + internal static CheatEnginePlugin? PluginForTests => Volatile.Read(ref s_plugin); + + /// + /// Admits one synchronous main-thread dispatch associated with . + /// + /// + /// The returned lease must be disposed after the host's synchronize call returns. Disable closes this + /// gate before signalling and waits for all such leases before it + /// detaches Lua and neutralizes its callbacks. + /// + /// + /// is stale, or the plugin is stopping and no longer accepts new main-thread work. + /// + internal static MainThreadWorkAdmission AdmitMainThreadWork(PluginContext context) + { + lock (SAdmissionGate) + { + if (Phase is not PluginHostLifecyclePhase.Enabled || + !ReferenceEquals(context, Volatile.Read(ref s_context)) || + Volatile.Read(ref s_acceptingMainThreadWork) == 0) + { + throw new InvalidOperationException( + "The plugin is stopping or disabled and no longer accepts new main-thread dispatch work."); + } + + if (s_admittedMainThreadWork == 0) + { + SNoAdmittedMainThreadWork.Reset(); + } + + checked + { + s_admittedMainThreadWork++; + } + + return new MainThreadWorkAdmission(); + } + } + + /// + /// The managed bootstrap: fills the at with the + /// plugin name and the three lifecycle callbacks. Called by the generated CESDK.CESDK.CEPluginInitialize; + /// Cheat Engine calls that twice per plugin, so this method is idempotent: the second call writes the same + /// values, including the same name pointer. + /// + /// The factory the entry-point generator emitted (or a hand-written one). + /// Address of the host-owned record; 36 bytes, byte-packed. + /// + /// The opaque second integer received from Cheat Engine. Its CE 7.7 meaning has not been live-verified; this + /// method records it for diagnostics but does not derive a record size or any other behavior from it. + /// + /// 1 () when the record was written; 0 otherwise. + /// + /// Never throws. Fails, with an entry in , when is zero, the + /// process is not x64, the factory's name cannot be + /// allocated, or a different factory type was registered by an earlier call in this loaded Hosting assembly + /// instance (the first factory in that instance wins deterministically). The name buffer is allocated on the first + /// successful call and never freed: Cheat Engine keeps reading through the pointer. + /// + public static int InitializeManaged(nint initRecord, int hostArgument) + where TFactory : IPluginFactory + { + try + { + Volatile.Write(ref s_lastInitRecordArgument, hostArgument); + if (HostLog.IsEnabled(HostLogLevel.Trace)) + { + HostLog.Trace(string.Create(CultureInfo.InvariantCulture, + $"InitializeManaged<{typeof(TFactory)}>(0x{initRecord:X}, host argument {hostArgument})")); + } + + if (initRecord == 0) + { + HostLog.Error("InitializeManaged: the init record address is zero."); + return ManagedEntryPoint.Failure; + } + + if (!AbiArchitecture.IsSupported) + { + HostLog.Error("InitializeManaged: this SDK is validated for x64 processes only."); + return ManagedEntryPoint.Failure; + } + + if (!TryRegisterFactory(out byte* name)) + { + return ManagedEntryPoint.Failure; + } + + // Written field by field through the packed layout: 36 bytes, no tail padding, no structure marshalling. + PluginInitRecord* record = (PluginInitRecord*) initRecord; + record->Name = name; + record->GetVersion = &GetVersion; + record->EnablePlugin = &EnablePlugin; + record->DisablePlugin = &DisablePlugin; + record->Version = AbiConstants.SdkVersion; + return ManagedEntryPoint.Success; + } + catch (Exception exception) + { + HostLog.Error("InitializeManaged failed.", exception); + return ManagedEntryPoint.Failure; + } + } + + // First call: copies the name out of the factory and pins the factory type. Later calls: the same type gets the + // same name pointer; a different type is rejected, deterministically, for the life of this loaded Hosting assembly + // instance. + private static bool TryRegisterFactory(out byte* name) + where TFactory : IPluginFactory + { + lock (SGate) + { + PluginDescriptor? registered = s_descriptor; + if (registered is null) + { + name = AnsiNameBuffer.Allocate(TFactory.Utf8Name); + s_name = name; + Volatile.Write(ref s_descriptor, new PluginDescriptor()); + SetPhase(PluginHostLifecyclePhase.Registered); + return true; + } + + if (registered.FactoryType != typeof(TFactory)) + { + HostLog.Error( + "InitializeManaged: a plugin factory of type " + registered.FactoryType + + " is already registered in this Hosting assembly instance; " + + typeof(TFactory) + " is rejected. One plugin per loaded Hosting assembly instance."); + name = null; + return false; + } + + name = s_name; + return true; + } + } + + /// The current context, or an exception for code that cannot proceed without one. + /// The plugin is not enabled. + internal static PluginContext RequireContext() + { + PluginContext? context = Volatile.Read(ref s_context); + if (context is null) + { + ThrowNotEnabled(); + } + + return context; + } + + /// + /// Returns the host to its never-bootstrapped state: disables the plugin if it is enabled (without calling + /// ), forgets the factory, the plugin instance and the name buffer (which + /// is leaked, as in production). Tests only: in Cheat Engine the state lives as long as the process. + /// + internal static void ResetForTests() + { + lock (SGate) + { + CloseMainThreadWorkAdmissionAndSignalShutdown(Volatile.Read(ref s_context)); + if (s_context is not null) + { + LuaRuntime.CloseOperationAdmissionAndDrain(); + LuaRuntime.Detach(); + Volatile.Write(ref s_context, null); + } + + EndMainThreadWorkAdmission(); + + s_plugin = null; + s_name = null; + Volatile.Write(ref s_descriptor, null); + Volatile.Write(ref s_incompleteEnableCleanup, 0); + Volatile.Write(ref s_incompleteEnableCleanupActive, 0); + SetPhase(PluginHostLifecyclePhase.Uninitialized); + Volatile.Write(ref s_lastInitRecordArgument, -1); + Volatile.Write(ref s_lastVersionRecordSize, -1); + } + } + + private static void SetPhase(PluginHostLifecyclePhase phase) + { + Volatile.Write(ref s_phase, (int) phase); + } + + // The admission lock makes the "close then drain" boundary exact: a worker either obtains a lease before + // Disable closes the gate, or observes the closed gate and never becomes work Disable must wait for. + private static CancellationTokenSource CreateShutdownSource() + { + lock (SAdmissionGate) + { + CancellationTokenSource shutdown = new(); + s_shutdown = shutdown; + return shutdown; + } + } + + private static void OpenMainThreadWorkAdmission(CancellationTokenSource shutdown) + { + lock (SAdmissionGate) + { + if (!ReferenceEquals(s_shutdown, shutdown)) + { + throw new InvalidOperationException( + "The lifecycle shutdown source was replaced before work admission opened."); + } + + Volatile.Write(ref s_acceptingMainThreadWork, 1); + } + } + + private static void CloseMainThreadWorkAdmissionAndSignalShutdown(PluginContext? context) + { + CancellationTokenSource? shutdown; + lock (SAdmissionGate) + { + Volatile.Write(ref s_acceptingMainThreadWork, 0); + shutdown = s_shutdown; + } + + if (shutdown is not null) + { + try + { + shutdown.Cancel(false); + } + catch (Exception exception) + { + // A cancellation registration is plugin code. It cannot prevent the required drain and detach. + HostLog.Error("A plugin shutdown callback threw while DisablePlugin was signalling shutdown.", + exception); + } + } + + DrainAdmittedMainThreadWork(context); + } + + private static void DrainAdmittedMainThreadWork(PluginContext? context) + { + // A worker already admitted through synchronize may be waiting for the GUI queue at exactly the point Disable + // begins. Waiting blindly on the GUI thread would deadlock it. When the host supplied CheckSynchronize, pump + // its queue until the last admitted work item releases its lease. MainThread.Invoke refuses worker dispatches + // without that slot, so an admitted GUI-bound work item always has this drain route. + while (!SNoAdmittedMainThreadWork.Wait(0, CancellationToken.None)) + { + if (context is not null && context.IsMainThread) + { + delegate* unmanaged[Stdcall] checkSynchronize = context.Exports.CheckSynchronize; + if (checkSynchronize is not null) + { + checkSynchronize(0); + Thread.Yield(); + continue; + } + } + + SNoAdmittedMainThreadWork.Wait(CancellationToken.None); + } + } + + private static void EndMainThreadWorkAdmission() + { + CancellationTokenSource? shutdown; + lock (SAdmissionGate) + { + Volatile.Write(ref s_acceptingMainThreadWork, 0); + shutdown = s_shutdown; + s_shutdown = null; + } + + shutdown?.Dispose(); + } + + private static void ReleaseMainThreadWorkAdmission() + { + lock (SAdmissionGate) + { + if (s_admittedMainThreadWork <= 0) + { + throw new InvalidOperationException("The main-thread work admission was released more than once."); + } + + s_admittedMainThreadWork--; + if (s_admittedMainThreadWork == 0) + { + SNoAdmittedMainThreadWork.Set(); + } + } + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNotEnabled() + { + throw new InvalidOperationException( + "The plugin is not enabled: Cheat Engine has not called EnablePlugin, or has called DisablePlugin since."); + } + + /// A single admitted main-thread dispatch. Internal so only Hosting can close the lifecycle work gate. + internal sealed class MainThreadWorkAdmission : IDisposable + { + private int _released; + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + ReleaseMainThreadWorkAdmission(); + } + } + } } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs index e54c4fe8..cba9caa9 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs @@ -12,18 +12,18 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// public enum PluginHostLifecyclePhase { - /// No factory has been registered by . - Uninitialized = 0, + /// No factory has been registered by . + Uninitialized = 0, - /// A factory is registered and the host may request an enable. - Registered = 1, + /// A factory is registered and the host may request an enable. + Registered = 1, - /// The host is binding Lua, creating the plugin, or running OnEnable. - Enabling = 2, + /// The host is binding Lua, creating the plugin, or running OnEnable. + Enabling = 2, - /// The plugin completed OnEnable and accepts ordinary work. - Enabled = 3, + /// The plugin completed OnEnable and accepts ordinary work. + Enabled = 3, - /// The host closed admission, signalled shutdown, or is running OnDisable and cleanup. - Disabling = 4, + /// The host closed admission, signalled shutdown, or is running OnDisable and cleanup. + Disabling = 4 } diff --git a/libs/CheatEngine.SDK.Hosting/Context/PluginContext.cs b/libs/CheatEngine.SDK.Hosting/Context/PluginContext.cs index 7b862443..e5df8c49 100644 --- a/libs/CheatEngine.SDK.Hosting/Context/PluginContext.cs +++ b/libs/CheatEngine.SDK.Hosting/Context/PluginContext.cs @@ -1,5 +1,6 @@ using System; using System.Threading; + using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Threading; @@ -25,77 +26,92 @@ namespace CheatEngine.SDK.Hosting.Context; /// public sealed unsafe class PluginContext { - private readonly ManagedExportedFunctions _exports; + private readonly ManagedExportedFunctions _exports; - internal PluginContext(in ManagedExportedFunctions exports, uint pluginId, int epoch, int mainThreadId, - in LuaHostBinding hostBinding, CancellationToken shutdownToken) - { - _exports = exports; - PluginId = pluginId; - Epoch = epoch; - MainThreadId = mainThreadId; - HostBinding = hostBinding; - ShutdownToken = shutdownToken; - } + internal PluginContext(in ManagedExportedFunctions exports, uint pluginId, int epoch, int mainThreadId, + in LuaHostBinding hostBinding, CancellationToken shutdownToken) + { + _exports = exports; + PluginId = pluginId; + Epoch = epoch; + MainThreadId = mainThreadId; + HostBinding = hostBinding; + ShutdownToken = shutdownToken; + } - /// Gets the plugin id Cheat Engine assigned in the enable callback. - public uint PluginId { get; } + /// Gets the plugin id Cheat Engine assigned in the enable callback. + public uint PluginId + { + get; + } - /// - /// Gets the this enable established. Every enable advances it, so a value cached - /// during an earlier enable identifies references and callbacks that are no longer valid. - /// - public int Epoch { get; } + /// + /// Gets the this enable established. Every enable advances it, so a value cached + /// during an earlier enable identifies references and callbacks that are no longer valid. + /// + public int Epoch + { + get; + } - /// Gets the managed thread id of the thread that ran the enable callback: Cheat Engine's main thread. - public int MainThreadId { get; } + /// Gets the managed thread id of the thread that ran the enable callback: Cheat Engine's main thread. + public int MainThreadId + { + get; + } - /// - /// Gets the token signalled as soon as disable closes admission for this enable, before OnDisable and - /// before the Lua runtime detaches. - /// - /// - /// Long-running plugin work should observe this token and finish promptly. Cancellation is cooperative: the - /// host waits only for main-thread dispatches that Hosting admitted before shutdown began. A context retained - /// from an earlier enable has a cancelled token and is . - /// - public CancellationToken ShutdownToken { get; } + /// + /// Gets the token signalled as soon as disable closes admission for this enable, before OnDisable and + /// before the Lua runtime detaches. + /// + /// + /// Long-running plugin work should observe this token and finish promptly. Cancellation is cooperative: the + /// host waits only for main-thread dispatches that Hosting admitted before shutdown began. A context retained + /// from an earlier enable has a cancelled token and is . + /// + public CancellationToken ShutdownToken + { + get; + } - /// - /// Gets the binding that was attached to for this enable: the state provider and - /// host-object pusher of the exports record. - /// - /// - /// Internal on purpose: 's StateProvider/HostObjectPusher are raw host - /// function addresses, and this is reachable from ordinary, non- - /// plugin - /// code via CheatEnginePlugin.Context — CheatEngine.SDK.Hosting's public surface speaks spans, structs - /// and handles, never raw pointers. This is CheatEngine.SDK.Hosting's own wiring detail for attaching - /// , not a plugin-facing capability. - /// - internal LuaHostBinding HostBinding { get; } + /// + /// Gets the binding that was attached to for this enable: the state provider and + /// host-object pusher of the exports record. + /// + /// + /// Internal on purpose: 's StateProvider/HostObjectPusher are raw host + /// function addresses, and this is reachable from ordinary, non- + /// plugin + /// code via CheatEnginePlugin.Context — CheatEngine.SDK.Hosting's public surface speaks spans, structs + /// and handles, never raw pointers. This is CheatEngine.SDK.Hosting's own wiring detail for attaching + /// , not a plugin-facing capability. + /// + internal LuaHostBinding HostBinding + { + get; + } - /// Gets a value indicating whether the calling thread is the main thread of this context. - public bool IsMainThread => Environment.CurrentManagedThreadId == MainThreadId; + /// Gets a value indicating whether the calling thread is the main thread of this context. + public bool IsMainThread => Environment.CurrentManagedThreadId == MainThreadId; - /// - /// Gets a value indicating whether this is the context of the current lifecycle transition or stable enable - /// (it has not been disabled or re-enabled since). - /// - public bool IsCurrent => ReferenceEquals(PluginHost.Context, this); + /// + /// Gets a value indicating whether this is the context of the current lifecycle transition or stable enable + /// (it has not been disabled or re-enabled since). + /// + public bool IsCurrent => ReferenceEquals(PluginHost.Context, this); - /// - /// Gets the size the host reported for its exports record (sizeofExportedFunctions); 48 for the only known - /// revision on x64. - /// - public int ReportedExportsSize => _exports.SizeOfExportedFunctions; + /// + /// Gets the size the host reported for its exports record (sizeofExportedFunctions); 48 for the only known + /// revision on x64. + /// + public int ReportedExportsSize => _exports.SizeOfExportedFunctions; - /// Gets a value indicating whether the host supplied the message-pump slot (ProcessMessages). - public bool HasProcessMessages => _exports.ProcessMessages is not null; + /// Gets a value indicating whether the host supplied the message-pump slot (ProcessMessages). + public bool HasProcessMessages => _exports.ProcessMessages is not null; - /// Gets a value indicating whether the host supplied the queued-call slot (CheckSynchronize). - public bool HasCheckSynchronize => _exports.CheckSynchronize is not null; + /// Gets a value indicating whether the host supplied the queued-call slot (CheckSynchronize). + public bool HasCheckSynchronize => _exports.CheckSynchronize is not null; - /// The copied record, for the host-side operations of this assembly. - internal ref readonly ManagedExportedFunctions Exports => ref _exports; + /// The copied record, for the host-side operations of this assembly. + internal ref readonly ManagedExportedFunctions Exports => ref _exports; } diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/DebugOutputLogSink.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/DebugOutputLogSink.cs index ad5547a4..39747b68 100644 --- a/libs/CheatEngine.SDK.Hosting/Diagnostics/DebugOutputLogSink.cs +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/DebugOutputLogSink.cs @@ -16,36 +16,42 @@ namespace CheatEngine.SDK.Hosting.Diagnostics; /// public sealed partial class DebugOutputLogSink : IHostLogSink { - private DebugOutputLogSink() - { - } + private DebugOutputLogSink() + { + } - /// Gets the shared instance. - public static DebugOutputLogSink Instance { get; } = new(); + /// Gets the shared instance. + public static DebugOutputLogSink Instance + { + get; + } = new(); - /// - public void Write(HostLogLevel level, string message, Exception? exception) - { - if (!OperatingSystem.IsWindows()) return; + /// + public void Write(HostLogLevel level, string message, Exception? exception) + { + if (!OperatingSystem.IsWindows()) + { + return; + } - var text = exception is null - ? "[CheatEngine.SDK.Hosting] " + level + ": " + message + "\n" - : "[CheatEngine.SDK.Hosting] " + level + ": " + message + "\n" + exception + "\n"; - WriteToDebugOutput(text); - } + string text = exception is null + ? "[CheatEngine.SDK.Hosting] " + level + ": " + message + "\n" + : "[CheatEngine.SDK.Hosting] " + level + ": " + message + "\n" + exception + "\n"; + WriteToDebugOutput(text); + } - [SupportedOSPlatform("windows")] - private static unsafe void WriteToDebugOutput(string text) - { - fixed (char* p = text) - { - OutputDebugStringW(p); - } - } + [SupportedOSPlatform("windows")] + private static unsafe void WriteToDebugOutput(string text) + { + fixed (char* p = text) + { + OutputDebugStringW(p); + } + } - // void OutputDebugStringW(LPCWSTR lpOutputString); the string is read during the call only. - [LibraryImport("kernel32", EntryPoint = "OutputDebugStringW")] - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [SupportedOSPlatform("windows")] - private static unsafe partial void OutputDebugStringW(char* lpOutputString); + // void OutputDebugStringW(LPCWSTR lpOutputString); the string is read during the call only. + [LibraryImport("kernel32", EntryPoint = "OutputDebugStringW")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows")] + private static unsafe partial void OutputDebugStringW(char* lpOutputString); } diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs index 133a174d..f80ddff2 100644 --- a/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs @@ -27,76 +27,79 @@ namespace CheatEngine.SDK.Hosting.Diagnostics; /// public static class HostLog { - private static IHostLogSink s_sink = DebugOutputLogSink.Instance; - private static int s_minimumLevel = (int)HostLogLevel.Information; + private static IHostLogSink s_sink = DebugOutputLogSink.Instance; + private static int s_minimumLevel = (int) HostLogLevel.Information; - /// Gets or sets the sink. Setting restores . - public static IHostLogSink Sink - { - get => Volatile.Read(ref s_sink); - set => Volatile.Write(ref s_sink, value ?? DebugOutputLogSink.Instance); - } + /// Gets or sets the sink. Setting restores . + public static IHostLogSink Sink + { + get => Volatile.Read(ref s_sink); + set => Volatile.Write(ref s_sink, value ?? DebugOutputLogSink.Instance); + } - /// Gets or sets the lowest level that is delivered to the sink. - public static HostLogLevel MinimumLevel - { - get => (HostLogLevel)Volatile.Read(ref s_minimumLevel); - set => Volatile.Write(ref s_minimumLevel, (int)value); - } + /// Gets or sets the lowest level that is delivered to the sink. + public static HostLogLevel MinimumLevel + { + get => (HostLogLevel) Volatile.Read(ref s_minimumLevel); + set => Volatile.Write(ref s_minimumLevel, (int) value); + } - /// - /// Tells whether an entry of would be delivered; use it to skip building an expensive - /// message. - /// - /// The level to test. - /// when is at least . - public static bool IsEnabled(HostLogLevel level) - { - return (int)level >= Volatile.Read(ref s_minimumLevel); - } + /// + /// Tells whether an entry of would be delivered; use it to skip building an expensive + /// message. + /// + /// The level to test. + /// when is at least . + public static bool IsEnabled(HostLogLevel level) + { + return (int) level >= Volatile.Read(ref s_minimumLevel); + } - /// Writes one entry to the sink, unless it is below . Never throws. - /// The severity. - /// The text; is written as an empty string. - /// The exception that caused the entry, if any. - public static void Write(HostLogLevel level, string? message, Exception? exception = null) - { - if (!IsEnabled(level)) return; + /// Writes one entry to the sink, unless it is below . Never throws. + /// The severity. + /// The text; is written as an empty string. + /// The exception that caused the entry, if any. + public static void Write(HostLogLevel level, string? message, Exception? exception = null) + { + if (!IsEnabled(level)) + { + return; + } - try - { - Volatile.Read(ref s_sink).Write(level, message ?? string.Empty, exception); - } - catch (Exception) - { - // A sink that throws must not turn a logged failure into an exception at the native boundary. - } - } + try + { + Volatile.Read(ref s_sink).Write(level, message ?? string.Empty, exception); + } + catch (Exception) + { + // A sink that throws must not turn a logged failure into an exception at the native boundary. + } + } - internal static void Error(string message, Exception? exception = null) - { - Write(HostLogLevel.Error, message, exception); - } + internal static void Error(string message, Exception? exception = null) + { + Write(HostLogLevel.Error, message, exception); + } - internal static void Warning(string message, Exception? exception = null) - { - Write(HostLogLevel.Warning, message, exception); - } + internal static void Warning(string message, Exception? exception = null) + { + Write(HostLogLevel.Warning, message, exception); + } - internal static void Information(string message) - { - Write(HostLogLevel.Information, message); - } + internal static void Information(string message) + { + Write(HostLogLevel.Information, message); + } - internal static void Trace(string message) - { - Write(HostLogLevel.Trace, message); - } + internal static void Trace(string message) + { + Write(HostLogLevel.Trace, message); + } - /// Resets the sink and level to their defaults. For tests. - internal static void ResetForTests() - { - Sink = DebugOutputLogSink.Instance; - MinimumLevel = HostLogLevel.Information; - } + /// Resets the sink and level to their defaults. For tests. + internal static void ResetForTests() + { + Sink = DebugOutputLogSink.Instance; + MinimumLevel = HostLogLevel.Information; + } } diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLogLevel.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLogLevel.cs index 758f1954..d3b451a6 100644 --- a/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLogLevel.cs +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLogLevel.cs @@ -3,15 +3,15 @@ namespace CheatEngine.SDK.Hosting.Diagnostics; /// Severity of a entry, in increasing order. public enum HostLogLevel { - /// Lifecycle tracing: every bootstrap and lifecycle call with its arguments. Off by default. - Trace = 0, + /// Lifecycle tracing: every bootstrap and lifecycle call with its arguments. Off by default. + Trace = 0, - /// A lifecycle transition that succeeded (enabled, disabled). - Information = 1, + /// A lifecycle transition that succeeded (enabled, disabled). + Information = 1, - /// Something unexpected that the host could tolerate. - Warning = 2, + /// Something unexpected that the host could tolerate. + Warning = 2, - /// A lifecycle call failed: Cheat Engine was told FALSE or 0, and this entry says why. - Error = 3 + /// A lifecycle call failed: Cheat Engine was told FALSE or 0, and this entry says why. + Error = 3 } diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs index ea1dfab4..cb1bc9c0 100644 --- a/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs @@ -13,9 +13,9 @@ namespace CheatEngine.SDK.Hosting.Diagnostics; /// public interface IHostLogSink { - /// Records one entry. - /// The severity; entries below are not delivered. - /// The text; never . - /// The exception that caused the entry, if any. - public void Write(HostLogLevel level, string message, Exception? exception); + /// Records one entry. + /// The severity; entries below are not delivered. + /// The text; never . + /// The exception that caused the entry, if any. + public void Write(HostLogLevel level, string message, Exception? exception); } diff --git a/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs b/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs index 5e419836..24e8a3ce 100644 --- a/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs +++ b/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Hosting.Bootstrap; @@ -41,40 +42,40 @@ namespace CheatEngine.SDK.Hosting.Plugin; /// public abstract class CheatEnginePlugin { - /// Initializes the plugin. Runs before the plugin is enabled: do not use SDK APIs here. - protected CheatEnginePlugin() - { - } + /// Initializes the plugin. Runs before the plugin is enabled: do not use SDK APIs here. + protected CheatEnginePlugin() + { + } - /// - /// Gets the context of the current enable: plugin id, epoch, main thread. Available from - /// until the end of . - /// - /// - /// The plugin is not enabled (called from the constructor, or after a - /// disable). - /// - [RequiresPluginEnabled] - protected static PluginContext Context => PluginHost.RequireContext(); + /// + /// Gets the context of the current enable: plugin id, epoch, main thread. Available from + /// until the end of . + /// + /// + /// The plugin is not enabled (called from the constructor, or after a + /// disable). + /// + [RequiresPluginEnabled] + protected static PluginContext Context => PluginHost.RequireContext(); - /// - /// Called by the host each time Cheat Engine enables the plugin, on the main thread, with the Lua API bound and - /// the runtime binding attached. Register Lua functions, menus and callbacks here. - /// - /// Throwing makes the enable fail; the exception is logged and does not reach Cheat Engine. - [RunsOnMainThread] - protected internal abstract void OnEnable(); + /// + /// Called by the host each time Cheat Engine enables the plugin, on the main thread, with the Lua API bound and + /// the runtime binding attached. Register Lua functions, menus and callbacks here. + /// + /// Throwing makes the enable fail; the exception is logged and does not reach Cheat Engine. + [RunsOnMainThread] + protected internal abstract void OnEnable(); - /// - /// Called by the host each time Cheat Engine disables the plugin, on the main thread, while the Lua API and the - /// runtime binding are still usable. Release what created; the runtime binding is - /// withdrawn right after this method returns and every remaining Lua callback is neutralized then. - /// - /// - /// Throwing is logged and cleanup continues. Cheat Engine is told the plugin is disabled only when cleanup, - /// including Lua detachment, completes successfully; a detach failure returns FALSE and leaves the host - /// lifecycle in Disabling. - /// - [RunsOnMainThread] - protected internal abstract void OnDisable(); + /// + /// Called by the host each time Cheat Engine disables the plugin, on the main thread, while the Lua API and the + /// runtime binding are still usable. Release what created; the runtime binding is + /// withdrawn right after this method returns and every remaining Lua callback is neutralized then. + /// + /// + /// Throwing is logged and cleanup continues. Cheat Engine is told the plugin is disabled only when cleanup, + /// including Lua detachment, completes successfully; a detach failure returns FALSE and leaves the host + /// lifecycle in Disabling. + /// + [RunsOnMainThread] + protected internal abstract void OnDisable(); } diff --git a/libs/CheatEngine.SDK.Hosting/Plugin/IPluginFactory.cs b/libs/CheatEngine.SDK.Hosting/Plugin/IPluginFactory.cs index 4cb0c4f8..44591320 100644 --- a/libs/CheatEngine.SDK.Hosting/Plugin/IPluginFactory.cs +++ b/libs/CheatEngine.SDK.Hosting/Plugin/IPluginFactory.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Hosting.Bootstrap; namespace CheatEngine.SDK.Hosting.Plugin; @@ -19,14 +20,17 @@ namespace CheatEngine.SDK.Hosting.Plugin; /// public interface IPluginFactory { - /// Gets the plugin's display name in UTF-8, without a terminating NUL. Read once, never on a hot path. - public static abstract ReadOnlySpan Utf8Name { get; } + /// Gets the plugin's display name in UTF-8, without a terminating NUL. Read once, never on a hot path. + public static abstract ReadOnlySpan Utf8Name + { + get; + } - /// - /// Constructs the plugin instance, after the Lua API is bound and before the runtime binding is attached. Called - /// until it returns an instance: once when it succeeds on the first enable; again on the next enable when it - /// threw or returned (that enable fails and is reported to Cheat Engine). - /// - /// A new plugin; must not be . - public static abstract CheatEnginePlugin Create(); + /// + /// Constructs the plugin instance, after the Lua API is bound and before the runtime binding is attached. Called + /// until it returns an instance: once when it succeeds on the first enable; again on the next enable when it + /// threw or returned (that enable fails and is reported to Cheat Engine). + /// + /// A new plugin; must not be . + public static abstract CheatEnginePlugin Create(); } diff --git a/libs/CheatEngine.SDK.Hosting/Threading/ActionWorkItem.cs b/libs/CheatEngine.SDK.Hosting/Threading/ActionWorkItem.cs index c9f56725..65924044 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/ActionWorkItem.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/ActionWorkItem.cs @@ -9,17 +9,17 @@ namespace CheatEngine.SDK.Hosting.Threading; /// internal sealed class ActionWorkItem : MainThreadWorkItem { - private readonly Action _action; - private readonly TState _state; + private readonly Action _action; + private readonly TState _state; - internal ActionWorkItem(Action action, TState state) - { - _action = action; - _state = state; - } + internal ActionWorkItem(Action action, TState state) + { + _action = action; + _state = state; + } - protected override void Run() - { - _action(_state); - } + protected override void Run() + { + _action(_state); + } } diff --git a/libs/CheatEngine.SDK.Hosting/Threading/FuncWorkItem.cs b/libs/CheatEngine.SDK.Hosting/Threading/FuncWorkItem.cs index 2a6f9f46..987335d5 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/FuncWorkItem.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/FuncWorkItem.cs @@ -7,20 +7,24 @@ namespace CheatEngine.SDK.Hosting.Threading; /// The result it produces. internal sealed class FuncWorkItem : MainThreadWorkItem { - private readonly Func _function; - private readonly TState _state; + private readonly Func _function; + private readonly TState _state; - internal FuncWorkItem(Func function, TState state) - { - _function = function; - _state = state; - } + internal FuncWorkItem(Func function, TState state) + { + _function = function; + _state = state; + } - /// Gets the result; until the function ran successfully. - internal TResult? Result { get; private set; } + /// Gets the result; until the function ran successfully. + internal TResult? Result + { + get; + private set; + } - protected override void Run() - { - Result = _function(_state); - } + protected override void Run() + { + Result = _function(_state); + } } diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs index 6f511a9d..dd2fb6b7 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; + +using CheatEngine.SDK.Abi; using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Hosting.Bootstrap; @@ -50,159 +52,175 @@ namespace CheatEngine.SDK.Hosting.Threading; /// public static unsafe class MainThread { - /// - /// Gets a value indicating whether the calling thread is the main thread of the current enable. - /// while the plugin is disabled. - /// - public static bool IsMainThread - { - get - { - var context = PluginHost.Context; - return context is not null && context.IsMainThread; - } - } - - /// - /// Pumps Cheat Engine's pending window messages (the host's ProcessMessages export), so that a long - /// operation on the main thread keeps the GUI responsive. Re-entrant: message handlers run inside the call. - /// - /// - /// The plugin is not enabled, the calling thread is not the main thread, or - /// the host supplied no ProcessMessages function. - /// - [RequiresPluginEnabled] - [MainThreadOnly] - public static void ProcessMessages() - { - var context = RequireMainThreadContext(); - var pump = context.Exports.ProcessMessages; - if (pump is null) ThrowMissingSlot("ProcessMessages"); - - pump(); - } - - /// - /// Runs the calls other threads queued for the main thread (the host's CheckSynchronize export), waiting - /// up to for one to arrive. For a main thread that blocks on a worker - /// which synchronizes with the GUI. - /// - /// How long to wait for a queued call, in milliseconds; 0 returns at once. - /// when at least one queued call was executed (inferred from the public 7.5 host source). - /// is negative. - /// - /// The plugin is not enabled, the calling thread is not the main thread, or - /// the host supplied no CheckSynchronize function. - /// - [RequiresPluginEnabled] - [MainThreadOnly] - public static bool CheckSynchronize(int timeoutMilliseconds) - { - ArgumentOutOfRangeException.ThrowIfNegative(timeoutMilliseconds); - var context = RequireMainThreadContext(); - var check = context.Exports.CheckSynchronize; - if (check is null) ThrowMissingSlot("CheckSynchronize"); - - return check(timeoutMilliseconds).IsTrue; - } - - /// - /// Runs on the main thread and returns when it has completed: inline when already - /// there, through the host's synchronize otherwise. - /// - /// - /// The state passed to the action; pass what the action needs so that it can be a - /// lambda. - /// - /// The work. - /// Its argument. - /// is . - /// - /// The plugin is not enabled, the host's synchronize is unavailable or failed, or the host omitted - /// CheckSynchronize so Hosting cannot guarantee a shutdown drain for worker work. - /// - /// - /// An exception thrown by on the main thread is rethrown here with its original stack - /// trace. See the type remarks for the deadlock rule. The cross-thread path (a call from a thread other than the - /// main one) is unverified in a live Cheat Engine: only its mechanics are tested, against a Lua stand-in. - /// - [RequiresPluginEnabled] - public static void Invoke(Action action, TState state) - { - ArgumentNullException.ThrowIfNull(action); - var context = PluginHost.RequireContext(); - using var admission = PluginHost.AdmitMainThreadWork(context); - if (context.IsMainThread) - { - MainThreadDispatcher.ExecuteInline(action, state); - return; - } - - if (!context.HasCheckSynchronize) - throw new InvalidOperationException( - "The host's exports record has no CheckSynchronize function; cross-thread dispatch cannot guarantee shutdown drain."); - - ActionWorkItem item = new(action, state); - MainThreadDispatcher.Dispatch(item); - item.ThrowIfFailed(); - } - - /// - /// Runs on the main thread and returns its result; see - /// . - /// - /// The state passed to the function. - /// The result type. - /// The work. - /// Its argument. - /// What returned on the main thread. - /// is . - /// - /// The plugin is not enabled, the host's synchronize is unavailable or failed, or the host omitted - /// CheckSynchronize so Hosting cannot guarantee a shutdown drain for worker work. - /// - /// - /// Same caveat as : the cross-thread path through synchronize is unverified - /// in a live Cheat Engine. - /// - [RequiresPluginEnabled] - public static TResult Invoke(Func function, TState state) - { - ArgumentNullException.ThrowIfNull(function); - var context = PluginHost.RequireContext(); - using var admission = PluginHost.AdmitMainThreadWork(context); - if (context.IsMainThread) return MainThreadDispatcher.ExecuteInline(function, state); - - if (!context.HasCheckSynchronize) - throw new InvalidOperationException( - "The host's exports record has no CheckSynchronize function; cross-thread dispatch cannot guarantee shutdown drain."); - - FuncWorkItem item = new(function, state); - MainThreadDispatcher.Dispatch(item); - item.ThrowIfFailed(); - return item.Result!; - } - - private static PluginContext RequireMainThreadContext() - { - var context = PluginHost.RequireContext(); - if (!context.IsMainThread) ThrowNotMainThread(); - - return context; - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowNotMainThread() - { - throw new InvalidOperationException( - "This operation pumps Cheat Engine's main thread and must be called from it; use MainThread.Invoke to get there."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowMissingSlot(string slot) - { - throw new InvalidOperationException("The host's exports record has no " + slot + " function."); - } + /// + /// Gets a value indicating whether the calling thread is the main thread of the current enable. + /// while the plugin is disabled. + /// + public static bool IsMainThread + { + get + { + PluginContext? context = PluginHost.Context; + return context is not null && context.IsMainThread; + } + } + + /// + /// Pumps Cheat Engine's pending window messages (the host's ProcessMessages export), so that a long + /// operation on the main thread keeps the GUI responsive. Re-entrant: message handlers run inside the call. + /// + /// + /// The plugin is not enabled, the calling thread is not the main thread, or + /// the host supplied no ProcessMessages function. + /// + [RequiresPluginEnabled] + [MainThreadOnly] + public static void ProcessMessages() + { + PluginContext context = RequireMainThreadContext(); + delegate* unmanaged[Stdcall] pump = context.Exports.ProcessMessages; + if (pump is null) + { + ThrowMissingSlot("ProcessMessages"); + } + + pump(); + } + + /// + /// Runs the calls other threads queued for the main thread (the host's CheckSynchronize export), waiting + /// up to for one to arrive. For a main thread that blocks on a worker + /// which synchronizes with the GUI. + /// + /// How long to wait for a queued call, in milliseconds; 0 returns at once. + /// when at least one queued call was executed (inferred from the public 7.5 host source). + /// is negative. + /// + /// The plugin is not enabled, the calling thread is not the main thread, or + /// the host supplied no CheckSynchronize function. + /// + [RequiresPluginEnabled] + [MainThreadOnly] + public static bool CheckSynchronize(int timeoutMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfNegative(timeoutMilliseconds); + PluginContext context = RequireMainThreadContext(); + delegate* unmanaged[Stdcall] check = context.Exports.CheckSynchronize; + if (check is null) + { + ThrowMissingSlot("CheckSynchronize"); + } + + return check(timeoutMilliseconds).IsTrue; + } + + /// + /// Runs on the main thread and returns when it has completed: inline when already + /// there, through the host's synchronize otherwise. + /// + /// + /// The state passed to the action; pass what the action needs so that it can be a + /// lambda. + /// + /// The work. + /// Its argument. + /// is . + /// + /// The plugin is not enabled, the host's synchronize is unavailable or failed, or the host omitted + /// CheckSynchronize so Hosting cannot guarantee a shutdown drain for worker work. + /// + /// + /// An exception thrown by on the main thread is rethrown here with its original stack + /// trace. See the type remarks for the deadlock rule. The cross-thread path (a call from a thread other than the + /// main one) is unverified in a live Cheat Engine: only its mechanics are tested, against a Lua stand-in. + /// + [RequiresPluginEnabled] + public static void Invoke(Action action, TState state) + { + ArgumentNullException.ThrowIfNull(action); + PluginContext context = PluginHost.RequireContext(); + using PluginHost.MainThreadWorkAdmission admission = PluginHost.AdmitMainThreadWork(context); + if (context.IsMainThread) + { + MainThreadDispatcher.ExecuteInline(action, state); + return; + } + + if (!context.HasCheckSynchronize) + { + throw new InvalidOperationException( + "The host's exports record has no CheckSynchronize function; cross-thread dispatch cannot guarantee shutdown drain."); + } + + ActionWorkItem item = new(action, state); + MainThreadDispatcher.Dispatch(item); + item.ThrowIfFailed(); + } + + /// + /// Runs on the main thread and returns its result; see + /// . + /// + /// The state passed to the function. + /// The result type. + /// The work. + /// Its argument. + /// What returned on the main thread. + /// is . + /// + /// The plugin is not enabled, the host's synchronize is unavailable or failed, or the host omitted + /// CheckSynchronize so Hosting cannot guarantee a shutdown drain for worker work. + /// + /// + /// Same caveat as : the cross-thread path through synchronize is unverified + /// in a live Cheat Engine. + /// + [RequiresPluginEnabled] + public static TResult Invoke(Func function, TState state) + { + ArgumentNullException.ThrowIfNull(function); + PluginContext context = PluginHost.RequireContext(); + using PluginHost.MainThreadWorkAdmission admission = PluginHost.AdmitMainThreadWork(context); + if (context.IsMainThread) + { + return MainThreadDispatcher.ExecuteInline(function, state); + } + + if (!context.HasCheckSynchronize) + { + throw new InvalidOperationException( + "The host's exports record has no CheckSynchronize function; cross-thread dispatch cannot guarantee shutdown drain."); + } + + FuncWorkItem item = new(function, state); + MainThreadDispatcher.Dispatch(item); + item.ThrowIfFailed(); + return item.Result!; + } + + private static PluginContext RequireMainThreadContext() + { + PluginContext context = PluginHost.RequireContext(); + if (!context.IsMainThread) + { + ThrowNotMainThread(); + } + + return context; + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNotMainThread() + { + throw new InvalidOperationException( + "This operation pumps Cheat Engine's main thread and must be called from it; use MainThread.Invoke to get there."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowMissingSlot(string slot) + { + throw new InvalidOperationException("The host's exports record has no " + slot + " function."); + } } diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs index a8f75118..a230f642 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; + using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; @@ -25,153 +26,165 @@ namespace CheatEngine.SDK.Hosting.Threading; /// internal static unsafe class MainThreadDispatcher { - // A disable nested in admitted main-thread work cannot drain that work: a worker can be waiting for a dispatched - // item, and an inline caller cannot release its admission until the action returns. PluginHost reads this marker - // before it starts shutdown and refuses that nested request. - [ThreadStatic] private static int t_executingWorkDepth; - [ThreadStatic] private static int t_inlineWorkDepth; - - // The simulated host uses this narrow internal seam to queue the exact work item that Dispatch would otherwise - // hand to Lua. It is reset before every test and never reaches the public API or a production host path. - private static Action? s_dispatchOverrideForTests; - - internal static Action? DispatchOverrideForTests - { - get => Volatile.Read(ref s_dispatchOverrideForTests); - set => Volatile.Write(ref s_dispatchOverrideForTests, value); - } - - internal static bool IsExecutingWorkOnCurrentThread => t_executingWorkDepth != 0; - - internal static bool IsExecutingInlineWorkOnCurrentThread => t_inlineWorkDepth != 0; - - internal static void ExecuteInline(Action action, TState state) - { - t_inlineWorkDepth++; - t_executingWorkDepth++; - try - { - action(state); - } - finally - { - t_executingWorkDepth--; - t_inlineWorkDepth--; - } - } - - internal static TResult ExecuteInline(Func function, TState state) - { - t_inlineWorkDepth++; - t_executingWorkDepth++; - try - { - return function(state); - } - finally - { - t_executingWorkDepth--; - t_inlineWorkDepth--; - } - } - - /// Runs through the host's synchronize global on the calling thread's Lua state. - /// - /// The work; its outcome is available through - /// afterwards. - /// - /// - /// The plugin is not enabled, the host has no synchronize function, or - /// the call failed on the Lua side. - /// - internal static void Dispatch(MainThreadWorkItem item) - { - var dispatchOverride = Volatile.Read(ref s_dispatchOverrideForTests); - if (dispatchOverride is not null) - { - dispatchOverride(item); - return; - } - - using var operation = LuaRuntime.AcquireOperation(); - var l = operation.State; - using LuaFrame frame = new(l); - - var status = l.TryGetGlobal("synchronize"u8); - if (!status.IsOk) - throw new InvalidOperationException("The host's 'synchronize' global could not be read: " + - LuaError.FromStack(l, status).Message); - - if (!l.IsFunction(-1)) - throw new InvalidOperationException( - "The host defines no 'synchronize' function; main-thread dispatch needs Cheat Engine's Lua environment."); - - status = LuaCallback.TryCreate(l, new LuaNativeFunction(&Thunk), item, out var callback); - if (!status.IsOk) - throw new InvalidOperationException("The dispatch callback could not be created: " + - LuaError.FromStack(l, status).Message); - - using (callback) - { - if (!callback!.TryPush(l)) - throw new InvalidOperationException("The plugin was disabled before the dispatch callback could run."); - status = l.TryCall(1, 0); - } - - if (!status.IsOk) - throw new InvalidOperationException("The host's 'synchronize' call failed: " + - LuaError.FromStack(l, status).Message); - } - - // lua_CFunction: runs the work item carried by upvalue 1. Returns no values; failures are captured in the item. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Thunk(nint l) - { - try - { - if (!LuaThunk.TryGetState(new LuaState(l), out MainThreadWorkItem? item)) - { - HostLog.Error("The main-thread dispatch thunk received no managed work item."); - return 0; - } - - ExecuteOnHostMainThread(item); - } - catch (Exception exception) - { - HostLog.Error("The main-thread dispatch thunk failed.", exception); - } - - return 0; - } - - internal static void ExecuteQueuedWorkForTests(MainThreadWorkItem item) - { - ExecuteOnHostMainThread(item); - } - - internal static void ResetForTests() - { - Volatile.Write(ref s_dispatchOverrideForTests, null); - } - - private static void ExecuteOnHostMainThread(MainThreadWorkItem item) - { - if (!MainThread.IsMainThread) - { - item.Reject(new InvalidOperationException( - "The host's synchronize callback ran dispatched work on a thread other than the enabled plugin main thread.")); - return; - } - - t_executingWorkDepth++; - try - { - item.Execute(); - } - finally - { - t_executingWorkDepth--; - } - } + // A disable nested in admitted main-thread work cannot drain that work: a worker can be waiting for a dispatched + // item, and an inline caller cannot release its admission until the action returns. PluginHost reads this marker + // before it starts shutdown and refuses that nested request. + [ThreadStatic] private static int t_executingWorkDepth; + [ThreadStatic] private static int t_inlineWorkDepth; + + // The simulated host uses this narrow internal seam to queue the exact work item that Dispatch would otherwise + // hand to Lua. It is reset before every test and never reaches the public API or a production host path. + private static Action? s_dispatchOverrideForTests; + + internal static Action? DispatchOverrideForTests + { + get => Volatile.Read(ref s_dispatchOverrideForTests); + set => Volatile.Write(ref s_dispatchOverrideForTests, value); + } + + internal static bool IsExecutingWorkOnCurrentThread => t_executingWorkDepth != 0; + + internal static bool IsExecutingInlineWorkOnCurrentThread => t_inlineWorkDepth != 0; + + internal static void ExecuteInline(Action action, TState state) + { + t_inlineWorkDepth++; + t_executingWorkDepth++; + try + { + action(state); + } + finally + { + t_executingWorkDepth--; + t_inlineWorkDepth--; + } + } + + internal static TResult ExecuteInline(Func function, TState state) + { + t_inlineWorkDepth++; + t_executingWorkDepth++; + try + { + return function(state); + } + finally + { + t_executingWorkDepth--; + t_inlineWorkDepth--; + } + } + + /// Runs through the host's synchronize global on the calling thread's Lua state. + /// + /// The work; its outcome is available through + /// afterwards. + /// + /// + /// The plugin is not enabled, the host has no synchronize function, or + /// the call failed on the Lua side. + /// + internal static void Dispatch(MainThreadWorkItem item) + { + Action? dispatchOverride = Volatile.Read(ref s_dispatchOverrideForTests); + if (dispatchOverride is not null) + { + dispatchOverride(item); + return; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState l = operation.State; + using LuaFrame frame = new(l); + + LuaStatus status = l.TryGetGlobal("synchronize"u8); + if (!status.IsOk) + { + throw new InvalidOperationException("The host's 'synchronize' global could not be read: " + + LuaError.FromStack(l, status).Message); + } + + if (!l.IsFunction(-1)) + { + throw new InvalidOperationException( + "The host defines no 'synchronize' function; main-thread dispatch needs Cheat Engine's Lua environment."); + } + + status = LuaCallback.TryCreate(l, new LuaNativeFunction(&Thunk), item, + out LuaCallback? callback); + if (!status.IsOk) + { + throw new InvalidOperationException("The dispatch callback could not be created: " + + LuaError.FromStack(l, status).Message); + } + + using (callback) + { + if (!callback!.TryPush(l)) + { + throw new InvalidOperationException("The plugin was disabled before the dispatch callback could run."); + } + + status = l.TryCall(1, 0); + } + + if (!status.IsOk) + { + throw new InvalidOperationException("The host's 'synchronize' call failed: " + + LuaError.FromStack(l, status).Message); + } + } + + // lua_CFunction: runs the work item carried by upvalue 1. Returns no values; failures are captured in the item. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Thunk(nint l) + { + try + { + if (!LuaThunk.TryGetState(new LuaState(l), out MainThreadWorkItem? item)) + { + HostLog.Error("The main-thread dispatch thunk received no managed work item."); + return 0; + } + + ExecuteOnHostMainThread(item); + } + catch (Exception exception) + { + HostLog.Error("The main-thread dispatch thunk failed.", exception); + } + + return 0; + } + + internal static void ExecuteQueuedWorkForTests(MainThreadWorkItem item) + { + ExecuteOnHostMainThread(item); + } + + internal static void ResetForTests() + { + Volatile.Write(ref s_dispatchOverrideForTests, null); + } + + private static void ExecuteOnHostMainThread(MainThreadWorkItem item) + { + if (!MainThread.IsMainThread) + { + item.Reject(new InvalidOperationException( + "The host's synchronize callback ran dispatched work on a thread other than the enabled plugin main thread.")); + return; + } + + t_executingWorkDepth++; + try + { + item.Execute(); + } + finally + { + t_executingWorkDepth--; + } + } } diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadWorkItem.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadWorkItem.cs index 1e220e46..48dc52e5 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadWorkItem.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadWorkItem.cs @@ -15,54 +15,65 @@ namespace CheatEngine.SDK.Hosting.Threading; /// internal abstract class MainThreadWorkItem { - private int _claimed; - private Exception? _failure; - private int _hasRun; + private int _claimed; + private Exception? _failure; + private int _hasRun; - /// Gets a value indicating whether the work ran (successfully or not). - internal bool HasRun => Volatile.Read(ref _hasRun) != 0; + /// Gets a value indicating whether the work ran (successfully or not). + internal bool HasRun => Volatile.Read(ref _hasRun) != 0; - /// Runs the work, capturing any exception. Called on the main thread; never throws. - internal void Execute() - { - if (Interlocked.CompareExchange(ref _claimed, 1, 0) != 0) return; + /// Runs the work, capturing any exception. Called on the main thread; never throws. + internal void Execute() + { + if (Interlocked.CompareExchange(ref _claimed, 1, 0) != 0) + { + return; + } - try - { - Run(); - } - catch (Exception exception) - { - _failure = exception; - } - finally - { - Volatile.Write(ref _hasRun, 1); - } - } + try + { + Run(); + } + catch (Exception exception) + { + _failure = exception; + } + finally + { + Volatile.Write(ref _hasRun, 1); + } + } - /// Completes the item without running it because the host violated the dispatch thread contract. - internal void Reject(Exception failure) - { - ArgumentNullException.ThrowIfNull(failure); - if (Interlocked.CompareExchange(ref _claimed, 1, 0) != 0) return; + /// Completes the item without running it because the host violated the dispatch thread contract. + internal void Reject(Exception failure) + { + ArgumentNullException.ThrowIfNull(failure); + if (Interlocked.CompareExchange(ref _claimed, 1, 0) != 0) + { + return; + } - _failure = failure; - Volatile.Write(ref _hasRun, 1); - } + _failure = failure; + Volatile.Write(ref _hasRun, 1); + } - /// Rethrows, with its original stack trace, the exception the work raised on the main thread. - /// The work never ran: the host did not invoke the function it was given. - internal void ThrowIfFailed() - { - if (!HasRun) - throw new InvalidOperationException( - "The host's synchronize function returned without running the dispatched work."); + /// Rethrows, with its original stack trace, the exception the work raised on the main thread. + /// The work never ran: the host did not invoke the function it was given. + internal void ThrowIfFailed() + { + if (!HasRun) + { + throw new InvalidOperationException( + "The host's synchronize function returned without running the dispatched work."); + } - var failure = _failure; - if (failure is not null) ExceptionDispatchInfo.Throw(failure); - } + Exception? failure = _failure; + if (failure is not null) + { + ExceptionDispatchInfo.Throw(failure); + } + } - /// The work itself. - protected abstract void Run(); + /// The work itself. + protected abstract void Run(); } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/ExportResolver.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/ExportResolver.cs index 43ae558a..7bb03ec9 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/ExportResolver.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/ExportResolver.cs @@ -9,28 +9,35 @@ namespace CheatEngine.SDK.Lua.Interop.Api; /// internal unsafe ref struct ExportResolver { - private readonly nint _module; - private List? _missing; + private readonly nint _module; + private List? _missing; - /// Creates a resolver over a module handle that the caller guarantees to be valid and loaded. - public ExportResolver(nint module) - { - _module = module; - } + /// Creates a resolver over a module handle that the caller guarantees to be valid and loaded. + public ExportResolver(nint module) + { + _module = module; + } - /// Number of names asked for so far. - public int Requested { get; private set; } + /// Number of names asked for so far. + public int Requested + { + get; + private set; + } - /// The names that were asked for and are not exported by the module, in request order. - public readonly IReadOnlyList Missing => _missing is null ? [] : _missing; + /// The names that were asked for and are not exported by the module, in request order. + public readonly IReadOnlyList Missing => _missing is null ? [] : _missing; - /// Returns the address of , or null after recording the name as missing. - public void* Resolve(string name) - { - Requested++; - if (NativeLibrary.TryGetExport(_module, name, out var address) && address != 0) return (void*)address; + /// Returns the address of , or null after recording the name as missing. + public void* Resolve(string name) + { + Requested++; + if (NativeLibrary.TryGetExport(_module, name, out lua_KContext address) && address != 0) + { + return (void*) address; + } - (_missing ??= []).Add(name); - return null; - } + (_missing ??= []).Add(name); + return null; + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Access.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Access.cs index c4e4aea4..7af907ef 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Access.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Access.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -7,261 +8,261 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // DisableRuntimeMarshalling a bool would be read as one unnormalized byte. public static unsafe partial class LuaApi { - /// - /// int lua_isnumber (lua_State *L, int idx). 1 when the value is a number or a string convertible to a - /// number, else 0. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_isnumber(lua_State* L, int idx) - { - return s_table.lua_isnumber(L, idx); - } + /// + /// int lua_isnumber (lua_State *L, int idx). 1 when the value is a number or a string convertible to a + /// number, else 0. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_isnumber(lua_State* L, int idx) + { + return s_table.lua_isnumber(L, idx); + } - /// - /// int lua_isstring (lua_State *L, int idx). 1 when the value is a string or a number (numbers convert to - /// strings), else 0. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. Use lua_type(L, idx) == LUA_TSTRING to test for a real string. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_isstring(lua_State* L, int idx) - { - return s_table.lua_isstring(L, idx); - } + /// + /// int lua_isstring (lua_State *L, int idx). 1 when the value is a string or a number (numbers convert to + /// strings), else 0. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. Use lua_type(L, idx) == LUA_TSTRING to test for a real string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_isstring(lua_State* L, int idx) + { + return s_table.lua_isstring(L, idx); + } - /// int lua_iscfunction (lua_State *L, int idx). 1 when the value is a C function, else 0. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_iscfunction(lua_State* L, int idx) - { - return s_table.lua_iscfunction(L, idx); - } + /// int lua_iscfunction (lua_State *L, int idx). 1 when the value is a C function, else 0. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_iscfunction(lua_State* L, int idx) + { + return s_table.lua_iscfunction(L, idx); + } - /// - /// int lua_isinteger (lua_State *L, int idx). 1 when the value is a number with the integer subtype, else - /// 0 (strings never qualify). - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_isinteger(lua_State* L, int idx) - { - return s_table.lua_isinteger(L, idx); - } + /// + /// int lua_isinteger (lua_State *L, int idx). 1 when the value is a number with the integer subtype, else + /// 0 (strings never qualify). + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_isinteger(lua_State* L, int idx) + { + return s_table.lua_isinteger(L, idx); + } - /// int lua_isuserdata (lua_State *L, int idx). 1 when the value is a full or a light userdata, else 0. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_isuserdata(lua_State* L, int idx) - { - return s_table.lua_isuserdata(L, idx); - } + /// int lua_isuserdata (lua_State *L, int idx). 1 when the value is a full or a light userdata, else 0. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_isuserdata(lua_State* L, int idx) + { + return s_table.lua_isuserdata(L, idx); + } - /// - /// int lua_type (lua_State *L, int idx). Type tag of the value (LUA_T*), - /// for an index beyond the top. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_type(lua_State* L, int idx) - { - return s_table.lua_type(L, idx); - } + /// + /// int lua_type (lua_State *L, int idx). Type tag of the value (LUA_T*), + /// for an index beyond the top. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_type(lua_State* L, int idx) + { + return s_table.lua_type(L, idx); + } - /// - /// const char *lua_typename (lua_State *L, int tp). Name of a type tag as a NUL-terminated static string - /// of the library. - /// - /// The state (unused by the library). - /// A LUA_T* tag. - /// Stack: -0 +0. Raises: never. The pointer is valid while the module is loaded. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_typename(lua_State* L, int tp) - { - return s_table.lua_typename(L, tp); - } + /// + /// const char *lua_typename (lua_State *L, int tp). Name of a type tag as a NUL-terminated static string + /// of the library. + /// + /// The state (unused by the library). + /// A LUA_T* tag. + /// Stack: -0 +0. Raises: never. The pointer is valid while the module is loaded. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_typename(lua_State* L, int tp) + { + return s_table.lua_typename(L, tp); + } - /// - /// lua_Number lua_tonumberx (lua_State *L, int idx, int *isnum). Converts the value to a float; numbers and - /// numeric strings convert, everything else yields 0. - /// - /// The state. - /// Acceptable index. - /// Null, or receives 1 when the conversion succeeded and 0 otherwise. - /// Stack: -0 +0. Raises: never. The stack slot is not modified. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Number lua_tonumberx(lua_State* L, int idx, int* isnum) - { - return s_table.lua_tonumberx(L, idx, isnum); - } + /// + /// lua_Number lua_tonumberx (lua_State *L, int idx, int *isnum). Converts the value to a float; numbers and + /// numeric strings convert, everything else yields 0. + /// + /// The state. + /// Acceptable index. + /// Null, or receives 1 when the conversion succeeded and 0 otherwise. + /// Stack: -0 +0. Raises: never. The stack slot is not modified. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Number lua_tonumberx(lua_State* L, int idx, int* isnum) + { + return s_table.lua_tonumberx(L, idx, isnum); + } - /// - /// lua_Integer lua_tointegerx (lua_State *L, int idx, int *isnum). Converts the value to a 64-bit integer; - /// integers, floats with an exact integer value and such strings convert, everything else yields 0. - /// - /// The state. - /// Acceptable index. - /// Null, or receives 1 when the conversion succeeded and 0 otherwise. - /// Stack: -0 +0. Raises: never. The stack slot is not modified. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Integer lua_tointegerx(lua_State* L, int idx, int* isnum) - { - return s_table.lua_tointegerx(L, idx, isnum); - } + /// + /// lua_Integer lua_tointegerx (lua_State *L, int idx, int *isnum). Converts the value to a 64-bit integer; + /// integers, floats with an exact integer value and such strings convert, everything else yields 0. + /// + /// The state. + /// Acceptable index. + /// Null, or receives 1 when the conversion succeeded and 0 otherwise. + /// Stack: -0 +0. Raises: never. The stack slot is not modified. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Integer lua_tointegerx(lua_State* L, int idx, int* isnum) + { + return s_table.lua_tointegerx(L, idx, isnum); + } - /// - /// int lua_toboolean (lua_State *L, int idx). 0 for , nil and none; 1 for every other - /// value - /// (including 0 and ""). - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_toboolean(lua_State* L, int idx) - { - return s_table.lua_toboolean(L, idx); - } + /// + /// int lua_toboolean (lua_State *L, int idx). 0 for , nil and none; 1 for every other + /// value + /// (including 0 and ""). + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_toboolean(lua_State* L, int idx) + { + return s_table.lua_toboolean(L, idx); + } - /// - /// const char *lua_tolstring (lua_State *L, int idx, size_t *len). Bytes of a string value: a pointer into - /// Lua's own copy, always followed by a NUL, possibly containing NULs. Null when the value is neither a string nor - /// a number. - /// - /// The state. - /// Acceptable index. - /// Null, or receives the length in bytes. - /// - /// Stack: -0 +0. Raises: memory. A number is converted to a string in place, which changes the slot and - /// breaks a traversal when applied to a key. The pointer is borrowed: valid only while the - /// value stays on the stack (or otherwise reachable), so copy or decode before popping. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_tolstring(lua_State* L, int idx, size_t* len) - { - return s_table.lua_tolstring(L, idx, len); - } + /// + /// const char *lua_tolstring (lua_State *L, int idx, size_t *len). Bytes of a string value: a pointer into + /// Lua's own copy, always followed by a NUL, possibly containing NULs. Null when the value is neither a string nor + /// a number. + /// + /// The state. + /// Acceptable index. + /// Null, or receives the length in bytes. + /// + /// Stack: -0 +0. Raises: memory. A number is converted to a string in place, which changes the slot and + /// breaks a traversal when applied to a key. The pointer is borrowed: valid only while the + /// value stays on the stack (or otherwise reachable), so copy or decode before popping. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_tolstring(lua_State* L, int idx, size_t* len) + { + return s_table.lua_tolstring(L, idx, len); + } - /// - /// size_t lua_rawlen (lua_State *L, int idx). Raw length: bytes of a string, border of a table without - /// metamethods, block size of a full userdata, 0 for anything else. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. The native return type is size_t, not . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static size_t lua_rawlen(lua_State* L, int idx) - { - return s_table.lua_rawlen(L, idx); - } + /// + /// size_t lua_rawlen (lua_State *L, int idx). Raw length: bytes of a string, border of a table without + /// metamethods, block size of a full userdata, 0 for anything else. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. The native return type is size_t, not . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static size_t lua_rawlen(lua_State* L, int idx) + { + return s_table.lua_rawlen(L, idx); + } - /// - /// lua_CFunction lua_tocfunction (lua_State *L, int idx). The C function behind the value, or null when it - /// is not a C function. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_CFunction lua_tocfunction(lua_State* L, int idx) - { - return s_table.lua_tocfunction(L, idx); - } + /// + /// lua_CFunction lua_tocfunction (lua_State *L, int idx). The C function behind the value, or null when it + /// is not a C function. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_CFunction lua_tocfunction(lua_State* L, int idx) + { + return s_table.lua_tocfunction(L, idx); + } - /// - /// void *lua_touserdata (lua_State *L, int idx). Block address of a full userdata, or the pointer of a light - /// userdata; null for anything else. - /// - /// The state. - /// Acceptable index. - /// - /// Stack: -0 +0. Raises: never. A full userdata block is owned by Lua and lives as long as the userdata is - /// reachable: read what you need before popping it. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void* lua_touserdata(lua_State* L, int idx) - { - return s_table.lua_touserdata(L, idx); - } + /// + /// void *lua_touserdata (lua_State *L, int idx). Block address of a full userdata, or the pointer of a light + /// userdata; null for anything else. + /// + /// The state. + /// Acceptable index. + /// + /// Stack: -0 +0. Raises: never. A full userdata block is owned by Lua and lives as long as the userdata is + /// reachable: read what you need before popping it. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void* lua_touserdata(lua_State* L, int idx) + { + return s_table.lua_touserdata(L, idx); + } - /// - /// lua_State *lua_tothread (lua_State *L, int idx). The thread behind the value, or null when it is not a - /// thread. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_State* lua_tothread(lua_State* L, int idx) - { - return s_table.lua_tothread(L, idx); - } + /// + /// lua_State *lua_tothread (lua_State *L, int idx). The thread behind the value, or null when it is not a + /// thread. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_State* lua_tothread(lua_State* L, int idx) + { + return s_table.lua_tothread(L, idx); + } - /// - /// const void *lua_topointer (lua_State *L, int idx). An identity pointer for tables, functions, threads and - /// userdata (distinct objects give distinct pointers); null for other types. Only good for hashing and diagnostics. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void* lua_topointer(lua_State* L, int idx) - { - return s_table.lua_topointer(L, idx); - } + /// + /// const void *lua_topointer (lua_State *L, int idx). An identity pointer for tables, functions, threads and + /// userdata (distinct objects give distinct pointers); null for other types. Only good for hashing and diagnostics. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void* lua_topointer(lua_State* L, int idx) + { + return s_table.lua_topointer(L, idx); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_isnumber; - internal delegate* unmanaged[Cdecl] lua_isstring; - internal delegate* unmanaged[Cdecl] lua_iscfunction; - internal delegate* unmanaged[Cdecl] lua_isinteger; - internal delegate* unmanaged[Cdecl] lua_isuserdata; - internal delegate* unmanaged[Cdecl] lua_type; - internal delegate* unmanaged[Cdecl] lua_typename; - internal delegate* unmanaged[Cdecl] lua_tonumberx; - internal delegate* unmanaged[Cdecl] lua_tointegerx; - internal delegate* unmanaged[Cdecl] lua_toboolean; - internal delegate* unmanaged[Cdecl] lua_tolstring; - internal delegate* unmanaged[Cdecl] lua_rawlen; - internal delegate* unmanaged[Cdecl] lua_tocfunction; - internal delegate* unmanaged[Cdecl] lua_touserdata; - internal delegate* unmanaged[Cdecl] lua_tothread; - internal delegate* unmanaged[Cdecl] lua_topointer; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_isnumber; + internal delegate* unmanaged[Cdecl] lua_isstring; + internal delegate* unmanaged[Cdecl] lua_iscfunction; + internal delegate* unmanaged[Cdecl] lua_isinteger; + internal delegate* unmanaged[Cdecl] lua_isuserdata; + internal delegate* unmanaged[Cdecl] lua_type; + internal delegate* unmanaged[Cdecl] lua_typename; + internal delegate* unmanaged[Cdecl] lua_tonumberx; + internal delegate* unmanaged[Cdecl] lua_tointegerx; + internal delegate* unmanaged[Cdecl] lua_toboolean; + internal delegate* unmanaged[Cdecl] lua_tolstring; + internal delegate* unmanaged[Cdecl] lua_rawlen; + internal delegate* unmanaged[Cdecl] lua_tocfunction; + internal delegate* unmanaged[Cdecl] lua_touserdata; + internal delegate* unmanaged[Cdecl] lua_tothread; + internal delegate* unmanaged[Cdecl] lua_topointer; - private void LoadAccess(ref ExportResolver exports) - { - lua_isnumber = (delegate* unmanaged[Cdecl])exports.Resolve("lua_isnumber"); - lua_isstring = (delegate* unmanaged[Cdecl])exports.Resolve("lua_isstring"); - lua_iscfunction = (delegate* unmanaged[Cdecl])exports.Resolve("lua_iscfunction"); - lua_isinteger = (delegate* unmanaged[Cdecl])exports.Resolve("lua_isinteger"); - lua_isuserdata = (delegate* unmanaged[Cdecl])exports.Resolve("lua_isuserdata"); - lua_type = (delegate* unmanaged[Cdecl])exports.Resolve("lua_type"); - lua_typename = (delegate* unmanaged[Cdecl])exports.Resolve("lua_typename"); - lua_tonumberx = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_tonumberx"); - lua_tointegerx = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_tointegerx"); - lua_toboolean = (delegate* unmanaged[Cdecl])exports.Resolve("lua_toboolean"); - lua_tolstring = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_tolstring"); - lua_rawlen = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawlen"); - lua_tocfunction = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_tocfunction"); - lua_touserdata = (delegate* unmanaged[Cdecl])exports.Resolve("lua_touserdata"); - lua_tothread = (delegate* unmanaged[Cdecl])exports.Resolve("lua_tothread"); - lua_topointer = (delegate* unmanaged[Cdecl])exports.Resolve("lua_topointer"); - } - } + private void LoadAccess(ref ExportResolver exports) + { + lua_isnumber = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_isnumber"); + lua_isstring = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_isstring"); + lua_iscfunction = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_iscfunction"); + lua_isinteger = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_isinteger"); + lua_isuserdata = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_isuserdata"); + lua_type = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_type"); + lua_typename = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_typename"); + lua_tonumberx = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_tonumberx"); + lua_tointegerx = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_tointegerx"); + lua_toboolean = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_toboolean"); + lua_tolstring = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_tolstring"); + lua_rawlen = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawlen"); + lua_tocfunction = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_tocfunction"); + lua_touserdata = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_touserdata"); + lua_tothread = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_tothread"); + lua_topointer = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_topointer"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Auxiliary.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Auxiliary.cs index e2a8ebbc..3d15f7ff 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Auxiliary.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Auxiliary.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -11,298 +12,299 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // luaL_fileresult, luaL_execresult helpers for the C runtime's errno. public static unsafe partial class LuaApi { - /// - /// int luaL_loadbufferx (lua_State *L, const char *buff, size_t sz, const char *name, const char *mode). - /// Compiles a chunk held in memory and pushes the function, or an error message. Returns , - /// , or . The chunk is not run. - /// - /// The state. - /// Chunk bytes (source text or binary); only read during the call, no terminator needed. - /// Size in bytes. - /// NUL-terminated chunk name for messages ("=name" shows as is). - /// NUL-terminated "t", "b" or "bt"; null means both. Pass "t" for anything that is meant to be source. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_loadbufferx(lua_State* L, byte* buff, size_t sz, byte* name, byte* mode) - { - return s_table.luaL_loadbufferx(L, buff, sz, name, mode); - } + /// + /// int luaL_loadbufferx (lua_State *L, const char *buff, size_t sz, const char *name, const char *mode). + /// Compiles a chunk held in memory and pushes the function, or an error message. Returns , + /// , or . The chunk is not run. + /// + /// The state. + /// Chunk bytes (source text or binary); only read during the call, no terminator needed. + /// Size in bytes. + /// NUL-terminated chunk name for messages ("=name" shows as is). + /// NUL-terminated "t", "b" or "bt"; null means both. Pass "t" for anything that is meant to be source. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_loadbufferx(lua_State* L, byte* buff, size_t sz, byte* name, byte* mode) + { + return s_table.luaL_loadbufferx(L, buff, sz, name, mode); + } - /// - /// int luaL_loadstring (lua_State *L, const char *s). over a - /// NUL-terminated source string, which also serves as the chunk name. - /// - /// The state. - /// NUL-terminated Lua source. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_loadstring(lua_State* L, byte* s) - { - return s_table.luaL_loadstring(L, s); - } + /// + /// int luaL_loadstring (lua_State *L, const char *s). over a + /// NUL-terminated source string, which also serves as the chunk name. + /// + /// The state. + /// NUL-terminated Lua source. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_loadstring(lua_State* L, byte* s) + { + return s_table.luaL_loadstring(L, s); + } - /// - /// int luaL_loadfilex (lua_State *L, const char *filename, const char *mode). Compiles a file (stdin when - /// is null). Returns a load status or ; pushes the function or - /// a message. - /// - /// The state. - /// - /// NUL-terminated path handed to the C runtime's fopen: on Windows that is the ANSI code - /// page, not UTF-8. - /// - /// NUL-terminated "t", "b" or "bt"; null means both. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_loadfilex(lua_State* L, byte* filename, byte* mode) - { - return s_table.luaL_loadfilex(L, filename, mode); - } + /// + /// int luaL_loadfilex (lua_State *L, const char *filename, const char *mode). Compiles a file (stdin when + /// is null). Returns a load status or ; pushes the function or + /// a message. + /// + /// The state. + /// + /// NUL-terminated path handed to the C runtime's fopen: on Windows that is the ANSI code + /// page, not UTF-8. + /// + /// NUL-terminated "t", "b" or "bt"; null means both. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_loadfilex(lua_State* L, byte* filename, byte* mode) + { + return s_table.luaL_loadfilex(L, filename, mode); + } - /// - /// int luaL_ref (lua_State *L, int t). Pops a value, stores it in the table at under a - /// fresh integer key and returns that key; for nil (nothing stored). - /// - /// The state. - /// Valid index of the table, normally . - /// - /// Stack: -1 +0. Raises: memory. The reference keeps the value alive until ; it belongs - /// to the global state, so it is valid from every thread of that state and dies with it. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_ref(lua_State* L, int t) - { - return s_table.luaL_ref(L, t); - } + /// + /// int luaL_ref (lua_State *L, int t). Pops a value, stores it in the table at under a + /// fresh integer key and returns that key; for nil (nothing stored). + /// + /// The state. + /// Valid index of the table, normally . + /// + /// Stack: -1 +0. Raises: memory. The reference keeps the value alive until ; it belongs + /// to the global state, so it is valid from every thread of that state and dies with it. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_ref(lua_State* L, int t) + { + return s_table.luaL_ref(L, t); + } - /// - /// void luaL_unref (lua_State *L, int t, int ref). Releases a reference so that its key can be reused. - /// and are ignored. - /// - /// The state. - /// Valid index of the table the reference was created in. - /// The reference. Releasing the same live reference twice corrupts the free list. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void luaL_unref(lua_State* L, int t, int @ref) - { - s_table.luaL_unref(L, t, @ref); - } + /// + /// void luaL_unref (lua_State *L, int t, int ref). Releases a reference so that its key can be reused. + /// and are ignored. + /// + /// The state. + /// Valid index of the table the reference was created in. + /// The reference. Releasing the same live reference twice corrupts the free list. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void luaL_unref(lua_State* L, int t, int @ref) + { + s_table.luaL_unref(L, t, @ref); + } - /// - /// int luaL_getmetafield (lua_State *L, int obj, const char *e). Pushes field of the - /// value's metatable and returns its type tag; returns and pushes nothing when there is no - /// metatable or no such field. - /// - /// The state. - /// Acceptable index of the value. - /// NUL-terminated field name. - /// Stack: -0 +(0|1). Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_getmetafield(lua_State* L, int obj, byte* e) - { - return s_table.luaL_getmetafield(L, obj, e); - } + /// + /// int luaL_getmetafield (lua_State *L, int obj, const char *e). Pushes field of the + /// value's metatable and returns its type tag; returns and pushes nothing when there is no + /// metatable or no such field. + /// + /// The state. + /// Acceptable index of the value. + /// NUL-terminated field name. + /// Stack: -0 +(0|1). Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_getmetafield(lua_State* L, int obj, byte* e) + { + return s_table.luaL_getmetafield(L, obj, e); + } - /// - /// int luaL_callmeta (lua_State *L, int obj, const char *e). Calls metamethod with - /// the value as its only argument, pushes the result and returns 1; returns 0 and pushes nothing when there is no such - /// metamethod. - /// - /// The state. - /// Acceptable index of the value. - /// NUL-terminated metamethod name. - /// Stack: -0 +(0|1). Raises: any (the metamethod runs unprotected). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_callmeta(lua_State* L, int obj, byte* e) - { - return s_table.luaL_callmeta(L, obj, e); - } + /// + /// int luaL_callmeta (lua_State *L, int obj, const char *e). Calls metamethod with + /// the value as its only argument, pushes the result and returns 1; returns 0 and pushes nothing when there is no such + /// metamethod. + /// + /// The state. + /// Acceptable index of the value. + /// NUL-terminated metamethod name. + /// Stack: -0 +(0|1). Raises: any (the metamethod runs unprotected). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_callmeta(lua_State* L, int obj, byte* e) + { + return s_table.luaL_callmeta(L, obj, e); + } - /// - /// const char *luaL_tolstring (lua_State *L, int idx, size_t *len). Pushes a printable string for any value - /// (honouring __tostring) and returns its bytes. Unlike the original slot is - /// left untouched. - /// - /// The state. - /// Acceptable index of the value. - /// Null, or receives the length in bytes. - /// - /// Stack: -0 +1. Raises: any (__tostring; a __tostring that does not return a string). The pointer - /// is valid while the pushed string stays on the stack. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* luaL_tolstring(lua_State* L, int idx, size_t* len) - { - return s_table.luaL_tolstring(L, idx, len); - } + /// + /// const char *luaL_tolstring (lua_State *L, int idx, size_t *len). Pushes a printable string for any value + /// (honouring __tostring) and returns its bytes. Unlike the original slot is + /// left untouched. + /// + /// The state. + /// Acceptable index of the value. + /// Null, or receives the length in bytes. + /// + /// Stack: -0 +1. Raises: any (__tostring; a __tostring that does not return a string). The pointer + /// is valid while the pushed string stays on the stack. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* luaL_tolstring(lua_State* L, int idx, size_t* len) + { + return s_table.luaL_tolstring(L, idx, len); + } - /// - /// int luaL_newmetatable (lua_State *L, const char *tname). Pushes the registry table named - /// , creating it (with __name set) when absent. Returns 1 when it was created, 0 when - /// it already existed. - /// - /// The state. - /// NUL-terminated type name, unique in the registry. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_newmetatable(lua_State* L, byte* tname) - { - return s_table.luaL_newmetatable(L, tname); - } + /// + /// int luaL_newmetatable (lua_State *L, const char *tname). Pushes the registry table named + /// , creating it (with __name set) when absent. Returns 1 when it was created, 0 when + /// it already existed. + /// + /// The state. + /// NUL-terminated type name, unique in the registry. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_newmetatable(lua_State* L, byte* tname) + { + return s_table.luaL_newmetatable(L, tname); + } - /// - /// void luaL_setmetatable (lua_State *L, const char *tname). Sets the registry metatable named - /// on the value on top of the stack. - /// - /// The state. - /// NUL-terminated type name. - /// Stack: -0 +0. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void luaL_setmetatable(lua_State* L, byte* tname) - { - s_table.luaL_setmetatable(L, tname); - } + /// + /// void luaL_setmetatable (lua_State *L, const char *tname). Sets the registry metatable named + /// on the value on top of the stack. + /// + /// The state. + /// NUL-terminated type name. + /// Stack: -0 +0. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void luaL_setmetatable(lua_State* L, byte* tname) + { + s_table.luaL_setmetatable(L, tname); + } - /// - /// void *luaL_testudata (lua_State *L, int ud, const char *tname). Block address of the userdata at - /// when its metatable is the registry table named ; null otherwise. - /// The non-raising type check. - /// - /// The state. - /// Acceptable index of the value. - /// NUL-terminated type name. - /// Stack: -0 +0. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void* luaL_testudata(lua_State* L, int ud, byte* tname) - { - return s_table.luaL_testudata(L, ud, tname); - } + /// + /// void *luaL_testudata (lua_State *L, int ud, const char *tname). Block address of the userdata at + /// when its metatable is the registry table named ; null otherwise. + /// The non-raising type check. + /// + /// The state. + /// Acceptable index of the value. + /// NUL-terminated type name. + /// Stack: -0 +0. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void* luaL_testudata(lua_State* L, int ud, byte* tname) + { + return s_table.luaL_testudata(L, ud, tname); + } - /// lua_Integer luaL_len (lua_State *L, int idx). Length of the value with Lua semantics, as an integer. - /// The state. - /// Valid index. - /// - /// Stack: -0 +0. Raises: any (__len; a length that is not an integer). is the - /// non-raising alternative. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Integer luaL_len(lua_State* L, int idx) - { - return s_table.luaL_len(L, idx); - } + /// lua_Integer luaL_len (lua_State *L, int idx). Length of the value with Lua semantics, as an integer. + /// The state. + /// Valid index. + /// + /// Stack: -0 +0. Raises: any (__len; a length that is not an integer). is the + /// non-raising alternative. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Integer luaL_len(lua_State* L, int idx) + { + return s_table.luaL_len(L, idx); + } - /// - /// int luaL_getsubtable (lua_State *L, int idx, const char *fname). Pushes t[fname], creating and - /// storing a new table when the field is not a table. Returns 1 when it already was a table, 0 when it was created. - /// - /// The state. - /// Valid index of the parent table. - /// NUL-terminated field name. - /// Stack: -0 +1. Raises: any (metamethods of the parent). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_getsubtable(lua_State* L, int idx, byte* fname) - { - return s_table.luaL_getsubtable(L, idx, fname); - } + /// + /// int luaL_getsubtable (lua_State *L, int idx, const char *fname). Pushes t[fname], creating and + /// storing a new table when the field is not a table. Returns 1 when it already was a table, 0 when it was created. + /// + /// The state. + /// Valid index of the parent table. + /// NUL-terminated field name. + /// Stack: -0 +1. Raises: any (metamethods of the parent). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_getsubtable(lua_State* L, int idx, byte* fname) + { + return s_table.luaL_getsubtable(L, idx, fname); + } - /// - /// void luaL_traceback (lua_State *L, lua_State *L1, const char *msg, int level). Pushes onto - /// a traceback of the call stack of , prefixed by - /// when not null. - /// - /// The state that receives the string. - /// The thread whose stack is described (may be ). - /// NUL-terminated first line, or null. - /// Call level at which the traceback starts. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void luaL_traceback(lua_State* L, lua_State* L1, byte* msg, int level) - { - s_table.luaL_traceback(L, L1, msg, level); - } + /// + /// void luaL_traceback (lua_State *L, lua_State *L1, const char *msg, int level). Pushes onto + /// a traceback of the call stack of , prefixed by + /// when not null. + /// + /// The state that receives the string. + /// The thread whose stack is described (may be ). + /// NUL-terminated first line, or null. + /// Call level at which the traceback starts. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void luaL_traceback(lua_State* L, lua_State* L1, byte* msg, int level) + { + s_table.luaL_traceback(L, L1, msg, level); + } - /// - /// void luaL_requiref (lua_State *L, const char *modname, lua_CFunction openf, int glb). Loads a module the - /// way require would when it is not yet in package.loaded: calls with the - /// name, records the result, optionally stores it in a global, and pushes it. - /// - /// The state. - /// NUL-terminated module name (see the LUA_*LIBNAME constants; "_G" for the base library). - /// The opener, for example . - /// Non-zero to also assign the module to the global . - /// Stack: -0 +1. Raises: any (the opener runs unprotected; the stock openers only raise on memory exhaustion). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void luaL_requiref(lua_State* L, byte* modname, lua_CFunction openf, int glb) - { - s_table.luaL_requiref(L, modname, openf, glb); - } + /// + /// void luaL_requiref (lua_State *L, const char *modname, lua_CFunction openf, int glb). Loads a module the + /// way require would when it is not yet in package.loaded: calls with the + /// name, records the result, optionally stores it in a global, and pushes it. + /// + /// The state. + /// NUL-terminated module name (see the LUA_*LIBNAME constants; "_G" for the base library). + /// The opener, for example . + /// Non-zero to also assign the module to the global . + /// Stack: -0 +1. Raises: any (the opener runs unprotected; the stock openers only raise on memory exhaustion). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void luaL_requiref(lua_State* L, byte* modname, lua_CFunction openf, int glb) + { + s_table.luaL_requiref(L, modname, openf, glb); + } - /// - /// void luaL_setfuncs (lua_State *L, const luaL_Reg *l, int nup). Registers every function of the array - /// into the table below the upvalues on top of the stack; each function becomes a closure - /// sharing those upvalues, which are popped. - /// - /// The state. - /// Array terminated by an all-null entry; only read during the call. - /// Number of shared upvalues on top of the stack. - /// Stack: -nup +0. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void luaL_setfuncs(lua_State* L, luaL_Reg* l, int nup) - { - s_table.luaL_setfuncs(L, l, nup); - } + /// + /// void luaL_setfuncs (lua_State *L, const luaL_Reg *l, int nup). Registers every function of the array + /// into the table below the upvalues on top of the stack; each function becomes a closure + /// sharing those upvalues, which are popped. + /// + /// The state. + /// Array terminated by an all-null entry; only read during the call. + /// Number of shared upvalues on top of the stack. + /// Stack: -nup +0. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void luaL_setfuncs(lua_State* L, luaL_Reg* l, int nup) + { + s_table.luaL_setfuncs(L, l, nup); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] luaL_loadbufferx; - internal delegate* unmanaged[Cdecl] luaL_loadstring; - internal delegate* unmanaged[Cdecl] luaL_loadfilex; - internal delegate* unmanaged[Cdecl] luaL_ref; - internal delegate* unmanaged[Cdecl] luaL_unref; - internal delegate* unmanaged[Cdecl] luaL_getmetafield; - internal delegate* unmanaged[Cdecl] luaL_callmeta; - internal delegate* unmanaged[Cdecl] luaL_tolstring; - internal delegate* unmanaged[Cdecl] luaL_newmetatable; - internal delegate* unmanaged[Cdecl] luaL_setmetatable; - internal delegate* unmanaged[Cdecl] luaL_testudata; - internal delegate* unmanaged[Cdecl] luaL_len; - internal delegate* unmanaged[Cdecl] luaL_getsubtable; - internal delegate* unmanaged[Cdecl] luaL_traceback; - internal delegate* unmanaged[Cdecl] luaL_requiref; - internal delegate* unmanaged[Cdecl] luaL_setfuncs; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] luaL_loadbufferx; + internal delegate* unmanaged[Cdecl] luaL_loadstring; + internal delegate* unmanaged[Cdecl] luaL_loadfilex; + internal delegate* unmanaged[Cdecl] luaL_ref; + internal delegate* unmanaged[Cdecl] luaL_unref; + internal delegate* unmanaged[Cdecl] luaL_getmetafield; + internal delegate* unmanaged[Cdecl] luaL_callmeta; + internal delegate* unmanaged[Cdecl] luaL_tolstring; + internal delegate* unmanaged[Cdecl] luaL_newmetatable; + internal delegate* unmanaged[Cdecl] luaL_setmetatable; + internal delegate* unmanaged[Cdecl] luaL_testudata; + internal delegate* unmanaged[Cdecl] luaL_len; + internal delegate* unmanaged[Cdecl] luaL_getsubtable; + internal delegate* unmanaged[Cdecl] luaL_traceback; + internal delegate* unmanaged[Cdecl] luaL_requiref; + internal delegate* unmanaged[Cdecl] luaL_setfuncs; - private void LoadAuxiliary(ref ExportResolver exports) - { - luaL_loadbufferx = - (delegate* unmanaged[Cdecl])exports.Resolve( - "luaL_loadbufferx"); - luaL_loadstring = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_loadstring"); - luaL_loadfilex = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_loadfilex"); - luaL_ref = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_ref"); - luaL_unref = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_unref"); - luaL_getmetafield = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_getmetafield"); - luaL_callmeta = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_callmeta"); - luaL_tolstring = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_tolstring"); - luaL_newmetatable = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_newmetatable"); - luaL_setmetatable = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_setmetatable"); - luaL_testudata = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_testudata"); - luaL_len = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_len"); - luaL_getsubtable = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_getsubtable"); - luaL_traceback = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_traceback"); - luaL_requiref = - (delegate* unmanaged[Cdecl])exports.Resolve( - "luaL_requiref"); - luaL_setfuncs = - (delegate* unmanaged[Cdecl])exports.Resolve("luaL_setfuncs"); - } - } + private void LoadAuxiliary(ref ExportResolver exports) + { + luaL_loadbufferx = + (delegate* unmanaged[Cdecl]) exports.Resolve( + "luaL_loadbufferx"); + luaL_loadstring = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_loadstring"); + luaL_loadfilex = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_loadfilex"); + luaL_ref = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_ref"); + luaL_unref = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_unref"); + luaL_getmetafield = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_getmetafield"); + luaL_callmeta = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_callmeta"); + luaL_tolstring = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_tolstring"); + luaL_newmetatable = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_newmetatable"); + luaL_setmetatable = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_setmetatable"); + luaL_testudata = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_testudata"); + luaL_len = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_len"); + luaL_getsubtable = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_getsubtable"); + luaL_traceback = + (delegate* unmanaged[Cdecl]) exports.Resolve( + "luaL_traceback"); + luaL_requiref = + (delegate* unmanaged[Cdecl]) exports.Resolve( + "luaL_requiref"); + luaL_setfuncs = + (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_setfuncs"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.AuxiliaryMacros.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.AuxiliaryMacros.cs index 47326631..0fa2c668 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.AuxiliaryMacros.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.AuxiliaryMacros.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -8,76 +9,76 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // luaL_argcheck, luaL_checkstring, luaL_optstring, luaL_opt, and the luaL_Buffer macros. public static unsafe partial class LuaApi { - /// luaL_loadfile(L,f): accepting text and binary chunks. - /// The state. - /// NUL-terminated path in the C runtime's encoding, or null for stdin. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_loadfile(lua_State* L, byte* f) - { - return luaL_loadfilex(L, f, null); - } + /// luaL_loadfile(L,f): accepting text and binary chunks. + /// The state. + /// NUL-terminated path in the C runtime's encoding, or null for stdin. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_loadfile(lua_State* L, byte* f) + { + return luaL_loadfilex(L, f, null); + } - /// luaL_loadbuffer(L,s,sz,n): accepting text and binary chunks. - /// The state. - /// Chunk bytes. - /// Size in bytes. - /// NUL-terminated chunk name. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_loadbuffer(lua_State* L, byte* s, size_t sz, byte* n) - { - return luaL_loadbufferx(L, s, sz, n, null); - } + /// luaL_loadbuffer(L,s,sz,n): accepting text and binary chunks. + /// The state. + /// Chunk bytes. + /// Size in bytes. + /// NUL-terminated chunk name. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_loadbuffer(lua_State* L, byte* s, size_t sz, byte* n) + { + return luaL_loadbufferx(L, s, sz, n, null); + } - /// - /// luaL_typename(L,i): name of the type of the value at , as a NUL-terminated static - /// string of the library. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* luaL_typename(lua_State* L, int i) - { - return lua_typename(L, lua_type(L, i)); - } + /// + /// luaL_typename(L,i): name of the type of the value at , as a NUL-terminated static + /// string of the library. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* luaL_typename(lua_State* L, int i) + { + return lua_typename(L, lua_type(L, i)); + } - /// - /// luaL_dostring(L,s): loads and runs a NUL-terminated source string under a protected call, keeping all - /// results. Like the C macro (load || pcall) the result is a truth value, not a status: 0 on success, 1 - /// when loading or running failed, in which case the error value is on top of the stack. - /// - /// The state. - /// NUL-terminated Lua source. - /// Stack: -0 +?. Raises: never. - public static int luaL_dostring(lua_State* L, byte* s) - { - return luaL_loadstring(L, s) != LUA_OK || lua_pcall(L, 0, LUA_MULTRET, 0) != LUA_OK ? 1 : 0; - } + /// + /// luaL_dostring(L,s): loads and runs a NUL-terminated source string under a protected call, keeping all + /// results. Like the C macro (load || pcall) the result is a truth value, not a status: 0 on success, 1 + /// when loading or running failed, in which case the error value is on top of the stack. + /// + /// The state. + /// NUL-terminated Lua source. + /// Stack: -0 +?. Raises: never. + public static int luaL_dostring(lua_State* L, byte* s) + { + return luaL_loadstring(L, s) != LUA_OK || lua_pcall(L, 0, LUA_MULTRET, 0) != LUA_OK ? 1 : 0; + } - /// - /// luaL_dofile(L,fn): loads and runs a file under a protected call, keeping all results. Same truth-value - /// result as . - /// - /// The state. - /// NUL-terminated path in the C runtime's encoding, or null for stdin. - /// Stack: -0 +?. Raises: memory. - public static int luaL_dofile(lua_State* L, byte* fn) - { - return luaL_loadfile(L, fn) != LUA_OK || lua_pcall(L, 0, LUA_MULTRET, 0) != LUA_OK ? 1 : 0; - } + /// + /// luaL_dofile(L,fn): loads and runs a file under a protected call, keeping all results. Same truth-value + /// result as . + /// + /// The state. + /// NUL-terminated path in the C runtime's encoding, or null for stdin. + /// Stack: -0 +?. Raises: memory. + public static int luaL_dofile(lua_State* L, byte* fn) + { + return luaL_loadfile(L, fn) != LUA_OK || lua_pcall(L, 0, LUA_MULTRET, 0) != LUA_OK ? 1 : 0; + } - /// - /// luaL_getmetatable(L,n): pushes the registry entry named (a metatable created by - /// , or nil); the result is its type tag. - /// - /// The state. - /// NUL-terminated type name. - /// Stack: -0 +1. Raises: memory (the registry has no metatable, so no metamethod can run). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int luaL_getmetatable(lua_State* L, byte* n) - { - return lua_getfield(L, LUA_REGISTRYINDEX, n); - } + /// + /// luaL_getmetatable(L,n): pushes the registry entry named (a metatable created by + /// , or nil); the result is its type tag. + /// + /// The state. + /// NUL-terminated type name. + /// Stack: -0 +1. Raises: memory (the registry has no metatable, so no metamethod can run). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int luaL_getmetatable(lua_State* L, byte* n) + { + return lua_getfield(L, LUA_REGISTRYINDEX, n); + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Calls.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Calls.cs index a65ca1b0..e2e3da22 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Calls.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Calls.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,124 +7,125 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "'load' and 'call' functions" plus lua_error. public static unsafe partial class LuaApi { - /// - /// void lua_callk (lua_State *L, int nargs, int nresults, lua_KContext ctx, lua_KFunction k). Unprotected - /// call: the function sits below its arguments; all of them are replaced by the results. - /// - /// The state. - /// Number of arguments on the stack. - /// Number of results to keep, or . - /// Value handed to ; 0 without a continuation. - /// Continuation for a yield across the call, or null. - /// - /// Stack: -(nargs+1) +nresults. Raises: any. An error in the callee propagates to the enclosing protected call - /// with longjmp, across the frame that made this call: from managed code use . - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_callk(lua_State* L, int nargs, int nresults, lua_KContext ctx, lua_KFunction k) - { - s_table.lua_callk(L, nargs, nresults, ctx, k); - } + /// + /// void lua_callk (lua_State *L, int nargs, int nresults, lua_KContext ctx, lua_KFunction k). Unprotected + /// call: the function sits below its arguments; all of them are replaced by the results. + /// + /// The state. + /// Number of arguments on the stack. + /// Number of results to keep, or . + /// Value handed to ; 0 without a continuation. + /// Continuation for a yield across the call, or null. + /// + /// Stack: -(nargs+1) +nresults. Raises: any. An error in the callee propagates to the enclosing protected call + /// with longjmp, across the frame that made this call: from managed code use . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_callk(lua_State* L, int nargs, int nresults, lua_KContext ctx, lua_KFunction k) + { + s_table.lua_callk(L, nargs, nresults, ctx, k); + } - /// - /// int lua_pcallk (lua_State *L, int nargs, int nresults, int errfunc, lua_KContext ctx, lua_KFunction k). - /// Protected call. Returns with the results on the stack, or an error status - /// (, , , ) with - /// exactly one error value in place of the function and its arguments. - /// - /// The state. - /// Number of arguments on the stack, above the function. - /// Number of results to keep, or . - /// - /// 0, or the stack index of a message handler that receives the error value and returns the value to - /// report. - /// - /// Value handed to ; 0 without a continuation. - /// Continuation for a yield across the call, or null. - /// - /// Stack: -(nargs+1) +(nresults|1). Raises: never. The native function takes six parameters; a shorter declaration - /// leaves and to whatever the registers hold. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_pcallk(lua_State* L, int nargs, int nresults, int errfunc, lua_KContext ctx, lua_KFunction k) - { - return s_table.lua_pcallk(L, nargs, nresults, errfunc, ctx, k); - } + /// + /// int lua_pcallk (lua_State *L, int nargs, int nresults, int errfunc, lua_KContext ctx, lua_KFunction k). + /// Protected call. Returns with the results on the stack, or an error status + /// (, , , ) with + /// exactly one error value in place of the function and its arguments. + /// + /// The state. + /// Number of arguments on the stack, above the function. + /// Number of results to keep, or . + /// + /// 0, or the stack index of a message handler that receives the error value and returns the value to + /// report. + /// + /// Value handed to ; 0 without a continuation. + /// Continuation for a yield across the call, or null. + /// + /// Stack: -(nargs+1) +(nresults|1). Raises: never. The native function takes six parameters; a shorter declaration + /// leaves and to whatever the registers hold. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_pcallk(lua_State* L, int nargs, int nresults, int errfunc, lua_KContext ctx, lua_KFunction k) + { + return s_table.lua_pcallk(L, nargs, nresults, errfunc, ctx, k); + } - /// - /// int lua_load (lua_State *L, lua_Reader reader, void *dt, const char *chunkname, const char *mode). - /// Compiles a chunk delivered piecewise by and pushes the resulting function, or an - /// error message. Returns , , or - /// . - /// - /// The state. - /// - /// Returns the next piece and its size, or null / size 0 at the end. The piece must stay valid until - /// the next reader call. - /// - /// Opaque pointer passed to . - /// NUL-terminated chunk name for messages and debug info ("=name" or "@file"), or null. - /// NUL-terminated "t", "b" or "bt"; null means both text and binary. - /// Stack: -0 +1. Raises: never. Never accept binary chunks from an untrusted source. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_load(lua_State* L, lua_Reader reader, void* dt, byte* chunkname, byte* mode) - { - return s_table.lua_load(L, reader, dt, chunkname, mode); - } + /// + /// int lua_load (lua_State *L, lua_Reader reader, void *dt, const char *chunkname, const char *mode). + /// Compiles a chunk delivered piecewise by and pushes the resulting function, or an + /// error message. Returns , , or + /// . + /// + /// The state. + /// + /// Returns the next piece and its size, or null / size 0 at the end. The piece must stay valid until + /// the next reader call. + /// + /// Opaque pointer passed to . + /// NUL-terminated chunk name for messages and debug info ("=name" or "@file"), or null. + /// NUL-terminated "t", "b" or "bt"; null means both text and binary. + /// Stack: -0 +1. Raises: never. Never accept binary chunks from an untrusted source. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_load(lua_State* L, lua_Reader reader, void* dt, byte* chunkname, byte* mode) + { + return s_table.lua_load(L, reader, dt, chunkname, mode); + } - /// - /// int lua_dump (lua_State *L, lua_Writer writer, void *data, int strip). Serializes the Lua function on top - /// of the stack as a binary chunk through . Returns the last writer result (0 = no error). - /// - /// The state. - /// Receives each piece; a non-zero result stops the dump. - /// Opaque pointer passed to . - /// Non-zero to omit debug information. - /// Stack: -0 +0. Raises: never. The function stays on the stack. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_dump(lua_State* L, lua_Writer writer, void* data, int strip) - { - return s_table.lua_dump(L, writer, data, strip); - } + /// + /// int lua_dump (lua_State *L, lua_Writer writer, void *data, int strip). Serializes the Lua function on top + /// of the stack as a binary chunk through . Returns the last writer result (0 = no error). + /// + /// The state. + /// Receives each piece; a non-zero result stops the dump. + /// Opaque pointer passed to . + /// Non-zero to omit debug information. + /// Stack: -0 +0. Raises: never. The function stays on the stack. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_dump(lua_State* L, lua_Writer writer, void* data, int strip) + { + return s_table.lua_dump(L, writer, data, strip); + } - /// - /// int lua_error (lua_State *L). Raises the value on top of the stack as a Lua error. It never returns: it - /// longjmps to the enclosing protected call. - /// - /// The state. - /// - /// Stack: -1 +0. Raises: always. Forbidden from managed frames, including [UnmanagedCallersOnly] - /// callbacks: the jump would skip managed frames, which the runtime does not support. It is declared so that the - /// prohibition is written down where a caller would look for the function; a managed callback reports failure - /// through its return values and lets a Lua-side wrapper call error. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_error(lua_State* L) - { - return s_table.lua_error(L); - } + /// + /// int lua_error (lua_State *L). Raises the value on top of the stack as a Lua error. It never returns: it + /// longjmps to the enclosing protected call. + /// + /// The state. + /// + /// Stack: -1 +0. Raises: always. Forbidden from managed frames, including [UnmanagedCallersOnly] + /// callbacks: the jump would skip managed frames, which the runtime does not support. It is declared so that the + /// prohibition is written down where a caller would look for the function; a managed callback reports failure + /// through its return values and lets a Lua-side wrapper call error. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_error(lua_State* L) + { + return s_table.lua_error(L); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_callk; - internal delegate* unmanaged[Cdecl] lua_pcallk; - internal delegate* unmanaged[Cdecl] lua_load; - internal delegate* unmanaged[Cdecl] lua_dump; - internal delegate* unmanaged[Cdecl] lua_error; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_callk; + internal delegate* unmanaged[Cdecl] lua_pcallk; + internal delegate* unmanaged[Cdecl] lua_load; + internal delegate* unmanaged[Cdecl] lua_dump; + internal delegate* unmanaged[Cdecl] lua_error; - private void LoadCalls(ref ExportResolver exports) - { - lua_callk = - (delegate* unmanaged[Cdecl])exports.Resolve( - "lua_callk"); - lua_pcallk = - (delegate* unmanaged[Cdecl]) - exports.Resolve("lua_pcallk"); - lua_load = - (delegate* unmanaged[Cdecl])exports.Resolve( - "lua_load"); - lua_dump = (delegate* unmanaged[Cdecl])exports.Resolve("lua_dump"); - lua_error = (delegate* unmanaged[Cdecl])exports.Resolve("lua_error"); - } - } + private void LoadCalls(ref ExportResolver exports) + { + lua_callk = + (delegate* unmanaged[Cdecl]) exports.Resolve( + "lua_callk"); + lua_pcallk = + (delegate* unmanaged[Cdecl]) + exports.Resolve("lua_pcallk"); + lua_load = + (delegate* unmanaged[Cdecl]) exports.Resolve( + "lua_load"); + lua_dump = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_dump"); + lua_error = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_error"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Constants.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Constants.cs index b5269d2c..49aac567 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Constants.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Constants.cs @@ -1,4 +1,5 @@ using System; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -7,272 +8,272 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // Values are those of a default 64-bit Lua 5.3 build, which is also what Cheat Engine's SDK headers declare. public static unsafe partial class LuaApi { - // ---- version ------------------------------------------------------------------------------------------------- + // ---- version ------------------------------------------------------------------------------------------------- - /// LUA_VERSION_NUM: the value points at for every 5.3.x library. - public const int LUA_VERSION_NUM = 503; + /// LUA_VERSION_NUM: the value points at for every 5.3.x library. + public const int LUA_VERSION_NUM = 503; - /// - /// LUAL_NUMSIZES: the size fingerprint luaL_checkversion compares, - /// sizeof(lua_Integer) * 16 + sizeof(lua_Number). - /// - public const int LUAL_NUMSIZES = sizeof(lua_Integer) * 16 + sizeof(lua_Number); + /// + /// LUAL_NUMSIZES: the size fingerprint luaL_checkversion compares, + /// sizeof(lua_Integer) * 16 + sizeof(lua_Number). + /// + public const int LUAL_NUMSIZES = sizeof(lua_Integer) * 16 + sizeof(lua_Number); - // ---- limits and pseudo-indices (luaconf.h) ------------------------------------------------------------------- + // ---- limits and pseudo-indices (luaconf.h) ------------------------------------------------------------------- - /// LUAI_MAXSTACK: the stack size limit of a state, which also anchors the pseudo-indices. - public const int LUAI_MAXSTACK = 1000000; + /// LUAI_MAXSTACK: the stack size limit of a state, which also anchors the pseudo-indices. + public const int LUAI_MAXSTACK = 1000000; - /// LUAI_FIRSTPSEUDOIDX: first index that does not denote a stack slot. - public const int LUAI_FIRSTPSEUDOIDX = -LUAI_MAXSTACK - 1000; + /// LUAI_FIRSTPSEUDOIDX: first index that does not denote a stack slot. + public const int LUAI_FIRSTPSEUDOIDX = -LUAI_MAXSTACK - 1000; - /// - /// LUA_REGISTRYINDEX (-1001000): pseudo-index of the registry. It is a compile-time constant of the native - /// library, so a library built with another LUAI_MAXSTACK would silently disagree; the conformance tests - /// check it against the real module. - /// - public const int LUA_REGISTRYINDEX = LUAI_FIRSTPSEUDOIDX; + /// + /// LUA_REGISTRYINDEX (-1001000): pseudo-index of the registry. It is a compile-time constant of the native + /// library, so a library built with another LUAI_MAXSTACK would silently disagree; the conformance tests + /// check it against the real module. + /// + public const int LUA_REGISTRYINDEX = LUAI_FIRSTPSEUDOIDX; - /// LUA_MINSTACK: free stack slots a C function can rely on when it is called. - public const int LUA_MINSTACK = 20; + /// LUA_MINSTACK: free stack slots a C function can rely on when it is called. + public const int LUA_MINSTACK = 20; - /// LUA_IDSIZE: size of . - public const int LUA_IDSIZE = 60; + /// LUA_IDSIZE: size of . + public const int LUA_IDSIZE = 60; - /// LUA_MAXINTEGER: largest lua_Integer. - public const lua_Integer LUA_MAXINTEGER = lua_Integer.MaxValue; + /// LUA_MAXINTEGER: largest lua_Integer. + public const lua_Integer LUA_MAXINTEGER = lua_Integer.MaxValue; - /// LUA_MININTEGER: smallest lua_Integer. - public const lua_Integer LUA_MININTEGER = lua_Integer.MinValue; + /// LUA_MININTEGER: smallest lua_Integer. + public const lua_Integer LUA_MININTEGER = lua_Integer.MinValue; - // ---- call and reference markers ------------------------------------------------------------------------------ + // ---- call and reference markers ------------------------------------------------------------------------------ - /// LUA_MULTRET: "keep all results" as the nresults argument of a call. - public const int LUA_MULTRET = -1; + /// LUA_MULTRET: "keep all results" as the nresults argument of a call. + public const int LUA_MULTRET = -1; - /// LUA_NOREF: a reference value that never returns; the "no reference" marker. - public const int LUA_NOREF = -2; + /// LUA_NOREF: a reference value that never returns; the "no reference" marker. + public const int LUA_NOREF = -2; - /// LUA_REFNIL: what returns for a nil value. - public const int LUA_REFNIL = -1; + /// LUA_REFNIL: what returns for a nil value. + public const int LUA_REFNIL = -1; - // ---- thread status / call results ---------------------------------------------------------------------------- + // ---- thread status / call results ---------------------------------------------------------------------------- - /// LUA_OK: success. - public const int LUA_OK = 0; + /// LUA_OK: success. + public const int LUA_OK = 0; - /// LUA_YIELD: the coroutine yielded. - public const int LUA_YIELD = 1; + /// LUA_YIELD: the coroutine yielded. + public const int LUA_YIELD = 1; - /// LUA_ERRRUN: runtime error. - public const int LUA_ERRRUN = 2; + /// LUA_ERRRUN: runtime error. + public const int LUA_ERRRUN = 2; - /// LUA_ERRSYNTAX: syntax error while loading a chunk. - public const int LUA_ERRSYNTAX = 3; + /// LUA_ERRSYNTAX: syntax error while loading a chunk. + public const int LUA_ERRSYNTAX = 3; - /// LUA_ERRMEM: allocation failure; the message handler is not called. - public const int LUA_ERRMEM = 4; + /// LUA_ERRMEM: allocation failure; the message handler is not called. + public const int LUA_ERRMEM = 4; - /// LUA_ERRGCMM: error inside a __gc metamethod. - public const int LUA_ERRGCMM = 5; + /// LUA_ERRGCMM: error inside a __gc metamethod. + public const int LUA_ERRGCMM = 5; - /// LUA_ERRERR: error inside the message handler. - public const int LUA_ERRERR = 6; + /// LUA_ERRERR: error inside the message handler. + public const int LUA_ERRERR = 6; - /// LUA_ERRFILE (lauxlib): the file of could not be opened or read. - public const int LUA_ERRFILE = LUA_ERRERR + 1; + /// LUA_ERRFILE (lauxlib): the file of could not be opened or read. + public const int LUA_ERRFILE = LUA_ERRERR + 1; - // ---- type tags ----------------------------------------------------------------------------------------------- + // ---- type tags ----------------------------------------------------------------------------------------------- - /// LUA_TNONE: the index is not a valid (but acceptable) stack slot. - public const int LUA_TNONE = -1; + /// LUA_TNONE: the index is not a valid (but acceptable) stack slot. + public const int LUA_TNONE = -1; - /// LUA_TNIL. - public const int LUA_TNIL = 0; + /// LUA_TNIL. + public const int LUA_TNIL = 0; - /// LUA_TBOOLEAN. - public const int LUA_TBOOLEAN = 1; + /// LUA_TBOOLEAN. + public const int LUA_TBOOLEAN = 1; - /// LUA_TLIGHTUSERDATA: a bare pointer value. - public const int LUA_TLIGHTUSERDATA = 2; + /// LUA_TLIGHTUSERDATA: a bare pointer value. + public const int LUA_TLIGHTUSERDATA = 2; - /// LUA_TNUMBER: integer or float subtype, see . - public const int LUA_TNUMBER = 3; + /// LUA_TNUMBER: integer or float subtype, see . + public const int LUA_TNUMBER = 3; - /// LUA_TSTRING. - public const int LUA_TSTRING = 4; + /// LUA_TSTRING. + public const int LUA_TSTRING = 4; - /// LUA_TTABLE. - public const int LUA_TTABLE = 5; + /// LUA_TTABLE. + public const int LUA_TTABLE = 5; - /// LUA_TFUNCTION: Lua or C function. - public const int LUA_TFUNCTION = 6; + /// LUA_TFUNCTION: Lua or C function. + public const int LUA_TFUNCTION = 6; - /// LUA_TUSERDATA: full userdata (a block of memory owned by Lua). Cheat Engine objects are of this type. - public const int LUA_TUSERDATA = 7; + /// LUA_TUSERDATA: full userdata (a block of memory owned by Lua). Cheat Engine objects are of this type. + public const int LUA_TUSERDATA = 7; - /// LUA_TTHREAD: coroutine. - public const int LUA_TTHREAD = 8; + /// LUA_TTHREAD: coroutine. + public const int LUA_TTHREAD = 8; - /// LUA_NUMTAGS: number of type tags. - public const int LUA_NUMTAGS = 9; + /// LUA_NUMTAGS: number of type tags. + public const int LUA_NUMTAGS = 9; - // ---- registry slots ------------------------------------------------------------------------------------------ + // ---- registry slots ------------------------------------------------------------------------------------------ - /// LUA_RIDX_MAINTHREAD: integer key of the main thread in the registry. - public const int LUA_RIDX_MAINTHREAD = 1; + /// LUA_RIDX_MAINTHREAD: integer key of the main thread in the registry. + public const int LUA_RIDX_MAINTHREAD = 1; - /// LUA_RIDX_GLOBALS: integer key of the globals table in the registry. - public const int LUA_RIDX_GLOBALS = 2; + /// LUA_RIDX_GLOBALS: integer key of the globals table in the registry. + public const int LUA_RIDX_GLOBALS = 2; - /// LUA_RIDX_LAST: last predefined registry key; hands out larger ones. - public const int LUA_RIDX_LAST = LUA_RIDX_GLOBALS; + /// LUA_RIDX_LAST: last predefined registry key; hands out larger ones. + public const int LUA_RIDX_LAST = LUA_RIDX_GLOBALS; - // ---- lua_arith operators ------------------------------------------------------------------------------------- + // ---- lua_arith operators ------------------------------------------------------------------------------------- - /// LUA_OPADD: +. - public const int LUA_OPADD = 0; + /// LUA_OPADD: +. + public const int LUA_OPADD = 0; - /// LUA_OPSUB: binary -. - public const int LUA_OPSUB = 1; + /// LUA_OPSUB: binary -. + public const int LUA_OPSUB = 1; - /// LUA_OPMUL: *. - public const int LUA_OPMUL = 2; + /// LUA_OPMUL: *. + public const int LUA_OPMUL = 2; - /// LUA_OPMOD: %. - public const int LUA_OPMOD = 3; + /// LUA_OPMOD: %. + public const int LUA_OPMOD = 3; - /// LUA_OPPOW: ^. - public const int LUA_OPPOW = 4; + /// LUA_OPPOW: ^. + public const int LUA_OPPOW = 4; - /// LUA_OPDIV: float division /. - public const int LUA_OPDIV = 5; + /// LUA_OPDIV: float division /. + public const int LUA_OPDIV = 5; - /// LUA_OPIDIV: floor division //. - public const int LUA_OPIDIV = 6; + /// LUA_OPIDIV: floor division //. + public const int LUA_OPIDIV = 6; - /// LUA_OPBAND: bitwise and. - public const int LUA_OPBAND = 7; + /// LUA_OPBAND: bitwise and. + public const int LUA_OPBAND = 7; - /// LUA_OPBOR: bitwise or. - public const int LUA_OPBOR = 8; + /// LUA_OPBOR: bitwise or. + public const int LUA_OPBOR = 8; - /// LUA_OPBXOR: bitwise exclusive or. - public const int LUA_OPBXOR = 9; + /// LUA_OPBXOR: bitwise exclusive or. + public const int LUA_OPBXOR = 9; - /// LUA_OPSHL: shift left. - public const int LUA_OPSHL = 10; + /// LUA_OPSHL: shift left. + public const int LUA_OPSHL = 10; - /// LUA_OPSHR: shift right. - public const int LUA_OPSHR = 11; + /// LUA_OPSHR: shift right. + public const int LUA_OPSHR = 11; - /// LUA_OPUNM: unary minus (one operand). - public const int LUA_OPUNM = 12; + /// LUA_OPUNM: unary minus (one operand). + public const int LUA_OPUNM = 12; - /// LUA_OPBNOT: bitwise not (one operand). - public const int LUA_OPBNOT = 13; + /// LUA_OPBNOT: bitwise not (one operand). + public const int LUA_OPBNOT = 13; - // ---- lua_compare operators ----------------------------------------------------------------------------------- + // ---- lua_compare operators ----------------------------------------------------------------------------------- - /// LUA_OPEQ: ==. - public const int LUA_OPEQ = 0; + /// LUA_OPEQ: ==. + public const int LUA_OPEQ = 0; - /// LUA_OPLT: <. - public const int LUA_OPLT = 1; + /// LUA_OPLT: <. + public const int LUA_OPLT = 1; - /// LUA_OPLE: <=. - public const int LUA_OPLE = 2; + /// LUA_OPLE: <=. + public const int LUA_OPLE = 2; - // ---- lua_gc commands ----------------------------------------------------------------------------------------- + // ---- lua_gc commands ----------------------------------------------------------------------------------------- - /// LUA_GCSTOP: stop the collector. - public const int LUA_GCSTOP = 0; + /// LUA_GCSTOP: stop the collector. + public const int LUA_GCSTOP = 0; - /// LUA_GCRESTART: restart the collector. - public const int LUA_GCRESTART = 1; + /// LUA_GCRESTART: restart the collector. + public const int LUA_GCRESTART = 1; - /// LUA_GCCOLLECT: run a full cycle. - public const int LUA_GCCOLLECT = 2; + /// LUA_GCCOLLECT: run a full cycle. + public const int LUA_GCCOLLECT = 2; - /// LUA_GCCOUNT: memory in use, in KiB. - public const int LUA_GCCOUNT = 3; + /// LUA_GCCOUNT: memory in use, in KiB. + public const int LUA_GCCOUNT = 3; - /// LUA_GCCOUNTB: remainder of the memory in use, in bytes (0..1023). - public const int LUA_GCCOUNTB = 4; + /// LUA_GCCOUNTB: remainder of the memory in use, in bytes (0..1023). + public const int LUA_GCCOUNTB = 4; - /// LUA_GCSTEP: run one incremental step. - public const int LUA_GCSTEP = 5; + /// LUA_GCSTEP: run one incremental step. + public const int LUA_GCSTEP = 5; - /// LUA_GCSETPAUSE: set the pause, returns the previous value. - public const int LUA_GCSETPAUSE = 6; + /// LUA_GCSETPAUSE: set the pause, returns the previous value. + public const int LUA_GCSETPAUSE = 6; - /// LUA_GCSETSTEPMUL: set the step multiplier, returns the previous value. - public const int LUA_GCSETSTEPMUL = 7; + /// LUA_GCSETSTEPMUL: set the step multiplier, returns the previous value. + public const int LUA_GCSETSTEPMUL = 7; - /// LUA_GCISRUNNING: whether the collector is running (there is no command 8 in 5.3). - public const int LUA_GCISRUNNING = 9; + /// LUA_GCISRUNNING: whether the collector is running (there is no command 8 in 5.3). + public const int LUA_GCISRUNNING = 9; - // ---- debug hooks --------------------------------------------------------------------------------------------- + // ---- debug hooks --------------------------------------------------------------------------------------------- - /// LUA_HOOKCALL: event code, a function is being called. - public const int LUA_HOOKCALL = 0; + /// LUA_HOOKCALL: event code, a function is being called. + public const int LUA_HOOKCALL = 0; - /// LUA_HOOKRET: event code, a function is returning. - public const int LUA_HOOKRET = 1; + /// LUA_HOOKRET: event code, a function is returning. + public const int LUA_HOOKRET = 1; - /// LUA_HOOKLINE: event code, a new line is about to run. - public const int LUA_HOOKLINE = 2; + /// LUA_HOOKLINE: event code, a new line is about to run. + public const int LUA_HOOKLINE = 2; - /// LUA_HOOKCOUNT: event code, the instruction count elapsed. - public const int LUA_HOOKCOUNT = 3; + /// LUA_HOOKCOUNT: event code, the instruction count elapsed. + public const int LUA_HOOKCOUNT = 3; - /// LUA_HOOKTAILCALL: event code, a function is being tail-called. - public const int LUA_HOOKTAILCALL = 4; + /// LUA_HOOKTAILCALL: event code, a function is being tail-called. + public const int LUA_HOOKTAILCALL = 4; - /// LUA_MASKCALL: hook mask bit for call events. - public const int LUA_MASKCALL = 1 << LUA_HOOKCALL; + /// LUA_MASKCALL: hook mask bit for call events. + public const int LUA_MASKCALL = 1 << LUA_HOOKCALL; - /// LUA_MASKRET: hook mask bit for return events. - public const int LUA_MASKRET = 1 << LUA_HOOKRET; + /// LUA_MASKRET: hook mask bit for return events. + public const int LUA_MASKRET = 1 << LUA_HOOKRET; - /// LUA_MASKLINE: hook mask bit for line events. - public const int LUA_MASKLINE = 1 << LUA_HOOKLINE; + /// LUA_MASKLINE: hook mask bit for line events. + public const int LUA_MASKLINE = 1 << LUA_HOOKLINE; - /// LUA_MASKCOUNT: hook mask bit for count events. - public const int LUA_MASKCOUNT = 1 << LUA_HOOKCOUNT; + /// LUA_MASKCOUNT: hook mask bit for count events. + public const int LUA_MASKCOUNT = 1 << LUA_HOOKCOUNT; - /// LUA_EXTRASPACE: bytes of user scratch memory in front of every (one pointer). - public static int LUA_EXTRASPACE => sizeof(void*); + /// LUA_EXTRASPACE: bytes of user scratch memory in front of every (one pointer). + public static int LUA_EXTRASPACE => sizeof(void*); - // ---- byte-string constants (NUL-terminated static data, usable directly as const char*) ------------------------ + // ---- byte-string constants (NUL-terminated static data, usable directly as const char*) ------------------------ - /// LUA_SIGNATURE: first bytes of a precompiled chunk. - public static ReadOnlySpan LUA_SIGNATURE => "\u001BLua"u8; + /// LUA_SIGNATURE: first bytes of a precompiled chunk. + public static ReadOnlySpan LUA_SIGNATURE => "\u001BLua"u8; - /// LUA_COLIBNAME: module name of the coroutine library. - public static ReadOnlySpan LUA_COLIBNAME => "coroutine"u8; + /// LUA_COLIBNAME: module name of the coroutine library. + public static ReadOnlySpan LUA_COLIBNAME => "coroutine"u8; - /// LUA_TABLIBNAME: module name of the table library. - public static ReadOnlySpan LUA_TABLIBNAME => "table"u8; + /// LUA_TABLIBNAME: module name of the table library. + public static ReadOnlySpan LUA_TABLIBNAME => "table"u8; - /// LUA_IOLIBNAME: module name of the I/O library. - public static ReadOnlySpan LUA_IOLIBNAME => "io"u8; + /// LUA_IOLIBNAME: module name of the I/O library. + public static ReadOnlySpan LUA_IOLIBNAME => "io"u8; - /// LUA_OSLIBNAME: module name of the operating system library. - public static ReadOnlySpan LUA_OSLIBNAME => "os"u8; + /// LUA_OSLIBNAME: module name of the operating system library. + public static ReadOnlySpan LUA_OSLIBNAME => "os"u8; - /// LUA_STRLIBNAME: module name of the string library. - public static ReadOnlySpan LUA_STRLIBNAME => "string"u8; + /// LUA_STRLIBNAME: module name of the string library. + public static ReadOnlySpan LUA_STRLIBNAME => "string"u8; - /// LUA_UTF8LIBNAME: module name of the UTF-8 library. - public static ReadOnlySpan LUA_UTF8LIBNAME => "utf8"u8; + /// LUA_UTF8LIBNAME: module name of the UTF-8 library. + public static ReadOnlySpan LUA_UTF8LIBNAME => "utf8"u8; - /// LUA_MATHLIBNAME: module name of the math library. - public static ReadOnlySpan LUA_MATHLIBNAME => "math"u8; + /// LUA_MATHLIBNAME: module name of the math library. + public static ReadOnlySpan LUA_MATHLIBNAME => "math"u8; - /// LUA_DBLIBNAME: module name of the debug library. - public static ReadOnlySpan LUA_DBLIBNAME => "debug"u8; + /// LUA_DBLIBNAME: module name of the debug library. + public static ReadOnlySpan LUA_DBLIBNAME => "debug"u8; - /// LUA_LOADLIBNAME: module name of the package library. - public static ReadOnlySpan LUA_LOADLIBNAME => "package"u8; + /// LUA_LOADLIBNAME: module name of the package library. + public static ReadOnlySpan LUA_LOADLIBNAME => "package"u8; } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Coroutines.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Coroutines.cs index 60d9e28b..3a45eaa6 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Coroutines.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Coroutines.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -7,56 +8,56 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // longjmp), which makes it unusable from managed code by construction. public static unsafe partial class LuaApi { - /// - /// int lua_resume (lua_State *L, lua_State *from, int narg). Starts or continues a coroutine on thread - /// . Returns (yielded values on the thread's stack), - /// (results on the stack) or an error status (error value on top; the stack is not unwound). - /// - /// The coroutine thread. To start it: push the function, then arguments. - /// The thread that resumes, or null. - /// Number of arguments (start) or of values handed to the pending yield (continue). - /// Stack: -? +?. Raises: never (errors inside the coroutine come back as the status). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_resume(lua_State* L, lua_State* from, int narg) - { - return s_table.lua_resume(L, from, narg); - } + /// + /// int lua_resume (lua_State *L, lua_State *from, int narg). Starts or continues a coroutine on thread + /// . Returns (yielded values on the thread's stack), + /// (results on the stack) or an error status (error value on top; the stack is not unwound). + /// + /// The coroutine thread. To start it: push the function, then arguments. + /// The thread that resumes, or null. + /// Number of arguments (start) or of values handed to the pending yield (continue). + /// Stack: -? +?. Raises: never (errors inside the coroutine come back as the status). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_resume(lua_State* L, lua_State* from, int narg) + { + return s_table.lua_resume(L, from, narg); + } - /// - /// int lua_status (lua_State *L). for a normal or finished thread, - /// for a suspended one, or the error status that killed it. - /// - /// The thread. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_status(lua_State* L) - { - return s_table.lua_status(L); - } + /// + /// int lua_status (lua_State *L). for a normal or finished thread, + /// for a suspended one, or the error status that killed it. + /// + /// The thread. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_status(lua_State* L) + { + return s_table.lua_status(L); + } - /// - /// int lua_isyieldable (lua_State *L). 1 when the running code of could yield (it is - /// inside a coroutine and no C boundary forbids it), else 0. - /// - /// The thread. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_isyieldable(lua_State* L) - { - return s_table.lua_isyieldable(L); - } + /// + /// int lua_isyieldable (lua_State *L). 1 when the running code of could yield (it is + /// inside a coroutine and no C boundary forbids it), else 0. + /// + /// The thread. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_isyieldable(lua_State* L) + { + return s_table.lua_isyieldable(L); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_resume; - internal delegate* unmanaged[Cdecl] lua_status; - internal delegate* unmanaged[Cdecl] lua_isyieldable; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_resume; + internal delegate* unmanaged[Cdecl] lua_status; + internal delegate* unmanaged[Cdecl] lua_isyieldable; - private void LoadCoroutines(ref ExportResolver exports) - { - lua_resume = (delegate* unmanaged[Cdecl])exports.Resolve("lua_resume"); - lua_status = (delegate* unmanaged[Cdecl])exports.Resolve("lua_status"); - lua_isyieldable = (delegate* unmanaged[Cdecl])exports.Resolve("lua_isyieldable"); - } - } + private void LoadCoroutines(ref ExportResolver exports) + { + lua_resume = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_resume"); + lua_status = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_status"); + lua_isyieldable = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_isyieldable"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Debug.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Debug.cs index 746799bb..a919d46a 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Debug.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Debug.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,212 +7,214 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "Debug API". public static unsafe partial class LuaApi { - /// - /// int lua_getstack (lua_State *L, int level, lua_Debug *ar). Identifies the activation record at a call - /// level (0 = the running function, 1 = its caller, ...) for later calls. Returns 0 when - /// the level is deeper than the stack. - /// - /// The state. - /// Call level. - /// Caller-owned record; only its private part is written. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_getstack(lua_State* L, int level, lua_Debug* ar) - { - return s_table.lua_getstack(L, level, ar); - } - - /// - /// int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar). Fills the fields of - /// selected by the letters of ("n", "S", "l", "u", "t"; "f" pushes the function, "L" pushes - /// a table of valid lines). A leading ">" takes the function from the top of the stack (and pops it) instead of - /// from . Returns 0 on an invalid option letter. - /// - /// The state. - /// NUL-terminated option letters. - /// Record prepared by or received by a hook (not needed with ">"). - /// - /// Stack: -(0|1) +(0|1|2). Raises: any, which is how the Lua 5.3 manual classifies it (marker e). The only - /// raising path in the 5.3 sources is the allocation of the "L" table, so in practice it behaves like "memory" - /// with "L" and like "never" without it; the manual stays the contract. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_getinfo(lua_State* L, byte* what, lua_Debug* ar) - { - return s_table.lua_getinfo(L, what, ar); - } - - /// - /// const char *lua_getlocal (lua_State *L, const lua_Debug *ar, int n). Pushes the value of local number - /// of an active function and returns its name; with a null it only - /// returns the parameter name of the function on top of the stack. Null (nothing pushed) when there is no such local. - /// - /// The state. - /// Activation record, or null. - /// 1-based local index. - /// Stack: -0 +(0|1). Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_getlocal(lua_State* L, lua_Debug* ar, int n) - { - return s_table.lua_getlocal(L, ar, n); - } - - /// - /// const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n). Pops a value into local number - /// and returns its name; null when there is no such local (the value is still popped). - /// - /// The state. - /// Activation record. - /// 1-based local index. - /// Stack: -(0|1) +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_setlocal(lua_State* L, lua_Debug* ar, int n) - { - return s_table.lua_setlocal(L, ar, n); - } - - /// - /// const char *lua_getupvalue (lua_State *L, int funcindex, int n). Pushes upvalue number - /// of a closure and returns its name ("" for C closures); null (nothing pushed) when out of - /// range. - /// - /// The state. - /// Valid index of the closure. - /// 1-based upvalue index. - /// Stack: -0 +(0|1). Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_getupvalue(lua_State* L, int funcindex, int n) - { - return s_table.lua_getupvalue(L, funcindex, n); - } - - /// - /// const char *lua_setupvalue (lua_State *L, int funcindex, int n). Pops a value into upvalue number - /// of a closure and returns its name; null (nothing popped) when out of range. - /// - /// The state. - /// Valid index of the closure. - /// 1-based upvalue index. - /// Stack: -(0|1) +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_setupvalue(lua_State* L, int funcindex, int n) - { - return s_table.lua_setupvalue(L, funcindex, n); - } - - /// - /// void *lua_upvalueid (lua_State *L, int fidx, int n). Identity of an upvalue: two closures share an - /// upvalue exactly when the ids are equal. - /// - /// The state. - /// Valid index of the closure. - /// 1-based upvalue index; must be in range. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void* lua_upvalueid(lua_State* L, int fidx, int n) - { - return s_table.lua_upvalueid(L, fidx, n); - } - - /// - /// void lua_upvaluejoin (lua_State *L, int fidx1, int n1, int fidx2, int n2). Makes upvalue - /// of the first Lua closure refer to upvalue of the second. - /// - /// The state. - /// Valid index of the closure that is changed. - /// 1-based upvalue index in the first closure. - /// Valid index of the closure that owns the shared upvalue. - /// 1-based upvalue index in the second closure. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_upvaluejoin(lua_State* L, int fidx1, int n1, int fidx2, int n2) - { - s_table.lua_upvaluejoin(L, fidx1, n1, fidx2, n2); - } - - /// - /// void lua_sethook (lua_State *L, lua_Hook func, int mask, int count). Installs a debug hook on this - /// thread; a null function or a zero mask removes it. - /// - /// The thread. - /// - /// Hook: for managed code a static [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - /// method that lets nothing escape. - /// - /// Combination of LUA_MASK* bits. - /// Instruction interval for . - /// - /// Stack: -0 +0. Raises: never. The hook runs on the thread that executes the Lua code, inside the interpreter - /// loop. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_sethook(lua_State* L, lua_Hook func, int mask, int count) - { - s_table.lua_sethook(L, func, mask, count); - } - - /// lua_Hook lua_gethook (lua_State *L). The installed hook, or null. - /// The thread. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Hook lua_gethook(lua_State* L) - { - return s_table.lua_gethook(L); - } - - /// int lua_gethookmask (lua_State *L). The installed hook mask. - /// The thread. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_gethookmask(lua_State* L) - { - return s_table.lua_gethookmask(L); - } - - /// int lua_gethookcount (lua_State *L). The installed hook count. - /// The thread. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_gethookcount(lua_State* L) - { - return s_table.lua_gethookcount(L); - } - - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_getstack; - internal delegate* unmanaged[Cdecl] lua_getinfo; - internal delegate* unmanaged[Cdecl] lua_getlocal; - internal delegate* unmanaged[Cdecl] lua_setlocal; - internal delegate* unmanaged[Cdecl] lua_getupvalue; - internal delegate* unmanaged[Cdecl] lua_setupvalue; - internal delegate* unmanaged[Cdecl] lua_upvalueid; - internal delegate* unmanaged[Cdecl] lua_upvaluejoin; - internal delegate* unmanaged[Cdecl] lua_sethook; - internal delegate* unmanaged[Cdecl] lua_gethook; - internal delegate* unmanaged[Cdecl] lua_gethookmask; - internal delegate* unmanaged[Cdecl] lua_gethookcount; - - private void LoadDebug(ref ExportResolver exports) - { - lua_getstack = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_getstack"); - lua_getinfo = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_getinfo"); - lua_getlocal = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_getlocal"); - lua_setlocal = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_setlocal"); - lua_getupvalue = (delegate* unmanaged[Cdecl])exports.Resolve("lua_getupvalue"); - lua_setupvalue = (delegate* unmanaged[Cdecl])exports.Resolve("lua_setupvalue"); - lua_upvalueid = (delegate* unmanaged[Cdecl])exports.Resolve("lua_upvalueid"); - lua_upvaluejoin = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_upvaluejoin"); - lua_sethook = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_sethook"); - lua_gethook = (delegate* unmanaged[Cdecl])exports.Resolve("lua_gethook"); - lua_gethookmask = (delegate* unmanaged[Cdecl])exports.Resolve("lua_gethookmask"); - lua_gethookcount = (delegate* unmanaged[Cdecl])exports.Resolve("lua_gethookcount"); - } - } + /// + /// int lua_getstack (lua_State *L, int level, lua_Debug *ar). Identifies the activation record at a call + /// level (0 = the running function, 1 = its caller, ...) for later calls. Returns 0 when + /// the level is deeper than the stack. + /// + /// The state. + /// Call level. + /// Caller-owned record; only its private part is written. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_getstack(lua_State* L, int level, lua_Debug* ar) + { + return s_table.lua_getstack(L, level, ar); + } + + /// + /// int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar). Fills the fields of + /// selected by the letters of ("n", "S", "l", "u", "t"; "f" pushes the function, "L" pushes + /// a table of valid lines). A leading ">" takes the function from the top of the stack (and pops it) instead of + /// from . Returns 0 on an invalid option letter. + /// + /// The state. + /// NUL-terminated option letters. + /// Record prepared by or received by a hook (not needed with ">"). + /// + /// Stack: -(0|1) +(0|1|2). Raises: any, which is how the Lua 5.3 manual classifies it (marker e). The only + /// raising path in the 5.3 sources is the allocation of the "L" table, so in practice it behaves like "memory" + /// with "L" and like "never" without it; the manual stays the contract. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_getinfo(lua_State* L, byte* what, lua_Debug* ar) + { + return s_table.lua_getinfo(L, what, ar); + } + + /// + /// const char *lua_getlocal (lua_State *L, const lua_Debug *ar, int n). Pushes the value of local number + /// of an active function and returns its name; with a null it only + /// returns the parameter name of the function on top of the stack. Null (nothing pushed) when there is no such local. + /// + /// The state. + /// Activation record, or null. + /// 1-based local index. + /// Stack: -0 +(0|1). Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_getlocal(lua_State* L, lua_Debug* ar, int n) + { + return s_table.lua_getlocal(L, ar, n); + } + + /// + /// const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n). Pops a value into local number + /// and returns its name; null when there is no such local (the value is still popped). + /// + /// The state. + /// Activation record. + /// 1-based local index. + /// Stack: -(0|1) +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_setlocal(lua_State* L, lua_Debug* ar, int n) + { + return s_table.lua_setlocal(L, ar, n); + } + + /// + /// const char *lua_getupvalue (lua_State *L, int funcindex, int n). Pushes upvalue number + /// of a closure and returns its name ("" for C closures); null (nothing pushed) when out of + /// range. + /// + /// The state. + /// Valid index of the closure. + /// 1-based upvalue index. + /// Stack: -0 +(0|1). Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_getupvalue(lua_State* L, int funcindex, int n) + { + return s_table.lua_getupvalue(L, funcindex, n); + } + + /// + /// const char *lua_setupvalue (lua_State *L, int funcindex, int n). Pops a value into upvalue number + /// of a closure and returns its name; null (nothing popped) when out of range. + /// + /// The state. + /// Valid index of the closure. + /// 1-based upvalue index. + /// Stack: -(0|1) +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_setupvalue(lua_State* L, int funcindex, int n) + { + return s_table.lua_setupvalue(L, funcindex, n); + } + + /// + /// void *lua_upvalueid (lua_State *L, int fidx, int n). Identity of an upvalue: two closures share an + /// upvalue exactly when the ids are equal. + /// + /// The state. + /// Valid index of the closure. + /// 1-based upvalue index; must be in range. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void* lua_upvalueid(lua_State* L, int fidx, int n) + { + return s_table.lua_upvalueid(L, fidx, n); + } + + /// + /// void lua_upvaluejoin (lua_State *L, int fidx1, int n1, int fidx2, int n2). Makes upvalue + /// of the first Lua closure refer to upvalue of the second. + /// + /// The state. + /// Valid index of the closure that is changed. + /// 1-based upvalue index in the first closure. + /// Valid index of the closure that owns the shared upvalue. + /// 1-based upvalue index in the second closure. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_upvaluejoin(lua_State* L, int fidx1, int n1, int fidx2, int n2) + { + s_table.lua_upvaluejoin(L, fidx1, n1, fidx2, n2); + } + + /// + /// void lua_sethook (lua_State *L, lua_Hook func, int mask, int count). Installs a debug hook on this + /// thread; a null function or a zero mask removes it. + /// + /// The thread. + /// + /// Hook: for managed code a static [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + /// method that lets nothing escape. + /// + /// Combination of LUA_MASK* bits. + /// Instruction interval for . + /// + /// Stack: -0 +0. Raises: never. The hook runs on the thread that executes the Lua code, inside the interpreter + /// loop. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_sethook(lua_State* L, lua_Hook func, int mask, int count) + { + s_table.lua_sethook(L, func, mask, count); + } + + /// lua_Hook lua_gethook (lua_State *L). The installed hook, or null. + /// The thread. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Hook lua_gethook(lua_State* L) + { + return s_table.lua_gethook(L); + } + + /// int lua_gethookmask (lua_State *L). The installed hook mask. + /// The thread. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_gethookmask(lua_State* L) + { + return s_table.lua_gethookmask(L); + } + + /// int lua_gethookcount (lua_State *L). The installed hook count. + /// The thread. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_gethookcount(lua_State* L) + { + return s_table.lua_gethookcount(L); + } + + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_getstack; + internal delegate* unmanaged[Cdecl] lua_getinfo; + internal delegate* unmanaged[Cdecl] lua_getlocal; + internal delegate* unmanaged[Cdecl] lua_setlocal; + internal delegate* unmanaged[Cdecl] lua_getupvalue; + internal delegate* unmanaged[Cdecl] lua_setupvalue; + internal delegate* unmanaged[Cdecl] lua_upvalueid; + internal delegate* unmanaged[Cdecl] lua_upvaluejoin; + internal delegate* unmanaged[Cdecl] lua_sethook; + internal delegate* unmanaged[Cdecl] lua_gethook; + internal delegate* unmanaged[Cdecl] lua_gethookmask; + internal delegate* unmanaged[Cdecl] lua_gethookcount; + + private void LoadDebug(ref ExportResolver exports) + { + lua_getstack = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getstack"); + lua_getinfo = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getinfo"); + lua_getlocal = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getlocal"); + lua_setlocal = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setlocal"); + lua_getupvalue = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getupvalue"); + lua_setupvalue = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setupvalue"); + lua_upvalueid = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_upvalueid"); + lua_upvaluejoin = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_upvaluejoin"); + lua_sethook = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_sethook"); + lua_gethook = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_gethook"); + lua_gethookmask = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_gethookmask"); + lua_gethookcount = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_gethookcount"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Get.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Get.cs index 1eac521d..5e7a7c19 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Get.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Get.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,188 +7,190 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "get functions (Lua -> stack)". The int results are the type tag of the pushed value. public static unsafe partial class LuaApi { - /// - /// int lua_getglobal (lua_State *L, const char *name). Pushes the value of a global and returns its type - /// tag. - /// - /// The state. - /// NUL-terminated global name. - /// Stack: -0 +1. Raises: any (an __index metamethod on the globals table; memory for the key string). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_getglobal(lua_State* L, byte* name) - { - return s_table.lua_getglobal(L, name); - } + /// + /// int lua_getglobal (lua_State *L, const char *name). Pushes the value of a global and returns its type + /// tag. + /// + /// The state. + /// NUL-terminated global name. + /// Stack: -0 +1. Raises: any (an __index metamethod on the globals table; memory for the key string). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_getglobal(lua_State* L, byte* name) + { + return s_table.lua_getglobal(L, name); + } - /// - /// int lua_gettable (lua_State *L, int idx). Pops a key and pushes t[key] for the value at - /// ; returns the type tag of the result. - /// - /// The state. - /// Valid index of the indexed value. - /// Stack: -1 +1. Raises: any (__index; indexing a value that cannot be indexed). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_gettable(lua_State* L, int idx) - { - return s_table.lua_gettable(L, idx); - } + /// + /// int lua_gettable (lua_State *L, int idx). Pops a key and pushes t[key] for the value at + /// ; returns the type tag of the result. + /// + /// The state. + /// Valid index of the indexed value. + /// Stack: -1 +1. Raises: any (__index; indexing a value that cannot be indexed). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_gettable(lua_State* L, int idx) + { + return s_table.lua_gettable(L, idx); + } - /// - /// int lua_getfield (lua_State *L, int idx, const char *k). Pushes t[k] for the value at - /// ; returns the type tag of the result. - /// - /// The state. - /// Valid index of the indexed value. - /// NUL-terminated field name. - /// - /// Stack: -0 +1. Raises: any (__index, which is how every Cheat Engine object answers; indexing a value - /// that cannot be indexed). - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_getfield(lua_State* L, int idx, byte* k) - { - return s_table.lua_getfield(L, idx, k); - } + /// + /// int lua_getfield (lua_State *L, int idx, const char *k). Pushes t[k] for the value at + /// ; returns the type tag of the result. + /// + /// The state. + /// Valid index of the indexed value. + /// NUL-terminated field name. + /// + /// Stack: -0 +1. Raises: any (__index, which is how every Cheat Engine object answers; indexing a value + /// that cannot be indexed). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_getfield(lua_State* L, int idx, byte* k) + { + return s_table.lua_getfield(L, idx, k); + } - /// - /// int lua_geti (lua_State *L, int idx, lua_Integer n). Pushes t[n] for the value at - /// ; returns the type tag of the result. - /// - /// The state. - /// Valid index of the indexed value. - /// Integer key. - /// Stack: -0 +1. Raises: any (__index; indexing a value that cannot be indexed). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_geti(lua_State* L, int idx, lua_Integer n) - { - return s_table.lua_geti(L, idx, n); - } + /// + /// int lua_geti (lua_State *L, int idx, lua_Integer n). Pushes t[n] for the value at + /// ; returns the type tag of the result. + /// + /// The state. + /// Valid index of the indexed value. + /// Integer key. + /// Stack: -0 +1. Raises: any (__index; indexing a value that cannot be indexed). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_geti(lua_State* L, int idx, lua_Integer n) + { + return s_table.lua_geti(L, idx, n); + } - /// - /// int lua_rawget (lua_State *L, int idx). Like without metamethods. The value - /// at must be a table. - /// - /// The state. - /// Valid index of a table. - /// Stack: -1 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_rawget(lua_State* L, int idx) - { - return s_table.lua_rawget(L, idx); - } + /// + /// int lua_rawget (lua_State *L, int idx). Like without metamethods. The value + /// at must be a table. + /// + /// The state. + /// Valid index of a table. + /// Stack: -1 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_rawget(lua_State* L, int idx) + { + return s_table.lua_rawget(L, idx); + } - /// - /// int lua_rawgeti (lua_State *L, int idx, lua_Integer n). Pushes t[n] without metamethods; returns - /// its type tag. This is how a registry reference is resolved. - /// - /// The state. - /// Valid index of a table (or ). - /// Integer key. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_rawgeti(lua_State* L, int idx, lua_Integer n) - { - return s_table.lua_rawgeti(L, idx, n); - } + /// + /// int lua_rawgeti (lua_State *L, int idx, lua_Integer n). Pushes t[n] without metamethods; returns + /// its type tag. This is how a registry reference is resolved. + /// + /// The state. + /// Valid index of a table (or ). + /// Integer key. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_rawgeti(lua_State* L, int idx, lua_Integer n) + { + return s_table.lua_rawgeti(L, idx, n); + } - /// - /// int lua_rawgetp (lua_State *L, int idx, const void *p). Pushes t[p] where the key is the light - /// userdata , without metamethods; returns its type tag. - /// - /// The state. - /// Valid index of a table. - /// Pointer used as key (the address of a static is the usual unique key). - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_rawgetp(lua_State* L, int idx, void* p) - { - return s_table.lua_rawgetp(L, idx, p); - } + /// + /// int lua_rawgetp (lua_State *L, int idx, const void *p). Pushes t[p] where the key is the light + /// userdata , without metamethods; returns its type tag. + /// + /// The state. + /// Valid index of a table. + /// Pointer used as key (the address of a static is the usual unique key). + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_rawgetp(lua_State* L, int idx, void* p) + { + return s_table.lua_rawgetp(L, idx, p); + } - /// - /// void lua_createtable (lua_State *L, int narr, int nrec). Pushes a new empty table with preallocated - /// space. - /// - /// The state. - /// Expected number of sequence elements. - /// Expected number of other elements. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_createtable(lua_State* L, int narr, int nrec) - { - s_table.lua_createtable(L, narr, nrec); - } + /// + /// void lua_createtable (lua_State *L, int narr, int nrec). Pushes a new empty table with preallocated + /// space. + /// + /// The state. + /// Expected number of sequence elements. + /// Expected number of other elements. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_createtable(lua_State* L, int narr, int nrec) + { + s_table.lua_createtable(L, narr, nrec); + } - /// - /// void *lua_newuserdata (lua_State *L, size_t sz). Allocates a block of bytes owned by - /// Lua, pushes the full userdata that represents it, and returns the block address. - /// - /// The state. - /// Block size in bytes. - /// - /// Stack: -0 +1. Raises: memory. The block is not zeroed, never moves, and is freed by Lua's collector after an - /// optional __gc metamethod. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void* lua_newuserdata(lua_State* L, size_t sz) - { - return s_table.lua_newuserdata(L, sz); - } + /// + /// void *lua_newuserdata (lua_State *L, size_t sz). Allocates a block of bytes owned by + /// Lua, pushes the full userdata that represents it, and returns the block address. + /// + /// The state. + /// Block size in bytes. + /// + /// Stack: -0 +1. Raises: memory. The block is not zeroed, never moves, and is freed by Lua's collector after an + /// optional __gc metamethod. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void* lua_newuserdata(lua_State* L, size_t sz) + { + return s_table.lua_newuserdata(L, sz); + } - /// - /// int lua_getmetatable (lua_State *L, int objindex). Pushes the metatable of the value and returns 1; - /// returns 0 and pushes nothing when it has none. - /// - /// The state. - /// Acceptable index of the value. - /// Stack: -0 +(0|1). Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_getmetatable(lua_State* L, int objindex) - { - return s_table.lua_getmetatable(L, objindex); - } + /// + /// int lua_getmetatable (lua_State *L, int objindex). Pushes the metatable of the value and returns 1; + /// returns 0 and pushes nothing when it has none. + /// + /// The state. + /// Acceptable index of the value. + /// Stack: -0 +(0|1). Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_getmetatable(lua_State* L, int objindex) + { + return s_table.lua_getmetatable(L, objindex); + } - /// - /// int lua_getuservalue (lua_State *L, int idx). Pushes the Lua value attached to a full userdata; returns - /// its type tag. - /// - /// The state. - /// Valid index of a full userdata. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_getuservalue(lua_State* L, int idx) - { - return s_table.lua_getuservalue(L, idx); - } + /// + /// int lua_getuservalue (lua_State *L, int idx). Pushes the Lua value attached to a full userdata; returns + /// its type tag. + /// + /// The state. + /// Valid index of a full userdata. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_getuservalue(lua_State* L, int idx) + { + return s_table.lua_getuservalue(L, idx); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_getglobal; - internal delegate* unmanaged[Cdecl] lua_gettable; - internal delegate* unmanaged[Cdecl] lua_getfield; - internal delegate* unmanaged[Cdecl] lua_geti; - internal delegate* unmanaged[Cdecl] lua_rawget; - internal delegate* unmanaged[Cdecl] lua_rawgeti; - internal delegate* unmanaged[Cdecl] lua_rawgetp; - internal delegate* unmanaged[Cdecl] lua_createtable; - internal delegate* unmanaged[Cdecl] lua_newuserdata; - internal delegate* unmanaged[Cdecl] lua_getmetatable; - internal delegate* unmanaged[Cdecl] lua_getuservalue; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_getglobal; + internal delegate* unmanaged[Cdecl] lua_gettable; + internal delegate* unmanaged[Cdecl] lua_getfield; + internal delegate* unmanaged[Cdecl] lua_geti; + internal delegate* unmanaged[Cdecl] lua_rawget; + internal delegate* unmanaged[Cdecl] lua_rawgeti; + internal delegate* unmanaged[Cdecl] lua_rawgetp; + internal delegate* unmanaged[Cdecl] lua_createtable; + internal delegate* unmanaged[Cdecl] lua_newuserdata; + internal delegate* unmanaged[Cdecl] lua_getmetatable; + internal delegate* unmanaged[Cdecl] lua_getuservalue; - private void LoadGet(ref ExportResolver exports) - { - lua_getglobal = (delegate* unmanaged[Cdecl])exports.Resolve("lua_getglobal"); - lua_gettable = (delegate* unmanaged[Cdecl])exports.Resolve("lua_gettable"); - lua_getfield = (delegate* unmanaged[Cdecl])exports.Resolve("lua_getfield"); - lua_geti = (delegate* unmanaged[Cdecl])exports.Resolve("lua_geti"); - lua_rawget = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawget"); - lua_rawgeti = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawgeti"); - lua_rawgetp = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawgetp"); - lua_createtable = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_createtable"); - lua_newuserdata = (delegate* unmanaged[Cdecl])exports.Resolve("lua_newuserdata"); - lua_getmetatable = (delegate* unmanaged[Cdecl])exports.Resolve("lua_getmetatable"); - lua_getuservalue = (delegate* unmanaged[Cdecl])exports.Resolve("lua_getuservalue"); - } - } + private void LoadGet(ref ExportResolver exports) + { + lua_getglobal = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getglobal"); + lua_gettable = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_gettable"); + lua_getfield = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getfield"); + lua_geti = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_geti"); + lua_rawget = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawget"); + lua_rawgeti = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawgeti"); + lua_rawgetp = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawgetp"); + lua_createtable = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_createtable"); + lua_newuserdata = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_newuserdata"); + lua_getmetatable = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getmetatable"); + lua_getuservalue = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getuservalue"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Libraries.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Libraries.cs index a95bdf45..c78321bc 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Libraries.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Libraries.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -8,105 +9,105 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // default build only ships a stub for it. public static unsafe partial class LuaApi { - /// - /// int luaopen_base (lua_State *L) as a value for (module name "_G"): the - /// basic functions. - /// - public static lua_CFunction luaopen_base => s_table.luaopen_base; + /// + /// int luaopen_base (lua_State *L) as a value for (module name "_G"): the + /// basic functions. + /// + public static lua_CFunction luaopen_base => s_table.luaopen_base; - /// - /// int luaopen_coroutine (lua_State *L) as a value for ( - /// ). - /// - public static lua_CFunction luaopen_coroutine => s_table.luaopen_coroutine; + /// + /// int luaopen_coroutine (lua_State *L) as a value for ( + /// ). + /// + public static lua_CFunction luaopen_coroutine => s_table.luaopen_coroutine; - /// - /// int luaopen_table (lua_State *L) as a value for ( - /// ). - /// - public static lua_CFunction luaopen_table => s_table.luaopen_table; + /// + /// int luaopen_table (lua_State *L) as a value for ( + /// ). + /// + public static lua_CFunction luaopen_table => s_table.luaopen_table; - /// - /// int luaopen_io (lua_State *L) as a value for ( - /// ). Gives scripts file access. - /// - public static lua_CFunction luaopen_io => s_table.luaopen_io; + /// + /// int luaopen_io (lua_State *L) as a value for ( + /// ). Gives scripts file access. + /// + public static lua_CFunction luaopen_io => s_table.luaopen_io; - /// - /// int luaopen_os (lua_State *L) as a value for ( - /// ). Gives scripts process and file-system access. - /// - public static lua_CFunction luaopen_os => s_table.luaopen_os; + /// + /// int luaopen_os (lua_State *L) as a value for ( + /// ). Gives scripts process and file-system access. + /// + public static lua_CFunction luaopen_os => s_table.luaopen_os; - /// - /// int luaopen_string (lua_State *L) as a value for ( - /// ). - /// - public static lua_CFunction luaopen_string => s_table.luaopen_string; + /// + /// int luaopen_string (lua_State *L) as a value for ( + /// ). + /// + public static lua_CFunction luaopen_string => s_table.luaopen_string; - /// - /// int luaopen_utf8 (lua_State *L) as a value for ( - /// ). - /// - public static lua_CFunction luaopen_utf8 => s_table.luaopen_utf8; + /// + /// int luaopen_utf8 (lua_State *L) as a value for ( + /// ). + /// + public static lua_CFunction luaopen_utf8 => s_table.luaopen_utf8; - /// - /// int luaopen_math (lua_State *L) as a value for ( - /// ). - /// - public static lua_CFunction luaopen_math => s_table.luaopen_math; + /// + /// int luaopen_math (lua_State *L) as a value for ( + /// ). + /// + public static lua_CFunction luaopen_math => s_table.luaopen_math; - /// - /// int luaopen_debug (lua_State *L) as a value for ( - /// ). - /// - public static lua_CFunction luaopen_debug => s_table.luaopen_debug; + /// + /// int luaopen_debug (lua_State *L) as a value for ( + /// ). + /// + public static lua_CFunction luaopen_debug => s_table.luaopen_debug; - /// - /// int luaopen_package (lua_State *L) as a value for ( - /// ). Gives scripts require and native library loading. - /// - public static lua_CFunction luaopen_package => s_table.luaopen_package; + /// + /// int luaopen_package (lua_State *L) as a value for ( + /// ). Gives scripts require and native library loading. + /// + public static lua_CFunction luaopen_package => s_table.luaopen_package; - /// - /// void luaL_openlibs (lua_State *L). Opens every standard library into the state, as the stand-alone - /// interpreter does. - /// - /// A state the caller created. - /// Stack: -0 +0. Raises: any (in practice memory only). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void luaL_openlibs(lua_State* L) - { - s_table.luaL_openlibs(L); - } + /// + /// void luaL_openlibs (lua_State *L). Opens every standard library into the state, as the stand-alone + /// interpreter does. + /// + /// A state the caller created. + /// Stack: -0 +0. Raises: any (in practice memory only). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void luaL_openlibs(lua_State* L) + { + s_table.luaL_openlibs(L); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] luaL_openlibs; - internal lua_CFunction luaopen_base; - internal lua_CFunction luaopen_coroutine; - internal lua_CFunction luaopen_table; - internal lua_CFunction luaopen_io; - internal lua_CFunction luaopen_os; - internal lua_CFunction luaopen_string; - internal lua_CFunction luaopen_utf8; - internal lua_CFunction luaopen_math; - internal lua_CFunction luaopen_debug; - internal lua_CFunction luaopen_package; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] luaL_openlibs; + internal lua_CFunction luaopen_base; + internal lua_CFunction luaopen_coroutine; + internal lua_CFunction luaopen_table; + internal lua_CFunction luaopen_io; + internal lua_CFunction luaopen_os; + internal lua_CFunction luaopen_string; + internal lua_CFunction luaopen_utf8; + internal lua_CFunction luaopen_math; + internal lua_CFunction luaopen_debug; + internal lua_CFunction luaopen_package; - private void LoadLibraries(ref ExportResolver exports) - { - luaL_openlibs = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_openlibs"); - luaopen_base = (lua_CFunction)exports.Resolve("luaopen_base"); - luaopen_coroutine = (lua_CFunction)exports.Resolve("luaopen_coroutine"); - luaopen_table = (lua_CFunction)exports.Resolve("luaopen_table"); - luaopen_io = (lua_CFunction)exports.Resolve("luaopen_io"); - luaopen_os = (lua_CFunction)exports.Resolve("luaopen_os"); - luaopen_string = (lua_CFunction)exports.Resolve("luaopen_string"); - luaopen_utf8 = (lua_CFunction)exports.Resolve("luaopen_utf8"); - luaopen_math = (lua_CFunction)exports.Resolve("luaopen_math"); - luaopen_debug = (lua_CFunction)exports.Resolve("luaopen_debug"); - luaopen_package = (lua_CFunction)exports.Resolve("luaopen_package"); - } - } + private void LoadLibraries(ref ExportResolver exports) + { + luaL_openlibs = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_openlibs"); + luaopen_base = (lua_CFunction) exports.Resolve("luaopen_base"); + luaopen_coroutine = (lua_CFunction) exports.Resolve("luaopen_coroutine"); + luaopen_table = (lua_CFunction) exports.Resolve("luaopen_table"); + luaopen_io = (lua_CFunction) exports.Resolve("luaopen_io"); + luaopen_os = (lua_CFunction) exports.Resolve("luaopen_os"); + luaopen_string = (lua_CFunction) exports.Resolve("luaopen_string"); + luaopen_utf8 = (lua_CFunction) exports.Resolve("luaopen_utf8"); + luaopen_math = (lua_CFunction) exports.Resolve("luaopen_math"); + luaopen_debug = (lua_CFunction) exports.Resolve("luaopen_debug"); + luaopen_package = (lua_CFunction) exports.Resolve("luaopen_package"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Macros.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Macros.cs index 4106cf75..377da089 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Macros.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Macros.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -9,266 +10,266 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // C int because that is their ABI). lua_yield is absent because lua_yieldk is not bound. public static unsafe partial class LuaApi { - /// lua_upvalueindex(i): pseudo-index of upvalue (1-based) of the running C closure. - /// Upvalue number, 1 to 255. - /// Pure arithmetic: usable without a bound table. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_upvalueindex(int i) - { - return LUA_REGISTRYINDEX - i; - } + /// lua_upvalueindex(i): pseudo-index of upvalue (1-based) of the running C closure. + /// Upvalue number, 1 to 255. + /// Pure arithmetic: usable without a bound table. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_upvalueindex(int i) + { + return LUA_REGISTRYINDEX - i; + } - /// lua_call(L,n,r): without continuation. Unprotected: see the warning there. - /// The state. - /// Number of arguments. - /// Number of results, or . - /// Stack: -(n+1) +r. Raises: any. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_call(lua_State* L, int n, int r) - { - lua_callk(L, n, r, 0, null); - } + /// lua_call(L,n,r): without continuation. Unprotected: see the warning there. + /// The state. + /// Number of arguments. + /// Number of results, or . + /// Stack: -(n+1) +r. Raises: any. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_call(lua_State* L, int n, int r) + { + lua_callk(L, n, r, 0, null); + } - /// lua_pcall(L,n,r,f): without continuation. - /// The state. - /// Number of arguments. - /// Number of results, or . - /// 0, or the stack index of a message handler. - /// Stack: -(n+1) +(r|1). Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_pcall(lua_State* L, int n, int r, int f) - { - return lua_pcallk(L, n, r, f, 0, null); - } + /// lua_pcall(L,n,r,f): without continuation. + /// The state. + /// Number of arguments. + /// Number of results, or . + /// 0, or the stack index of a message handler. + /// Stack: -(n+1) +(r|1). Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_pcall(lua_State* L, int n, int r, int f) + { + return lua_pcallk(L, n, r, f, 0, null); + } - /// - /// lua_getextraspace(L): address of the bytes of scratch memory that precede - /// the state. A new thread starts with a copy of the main thread's area. - /// - /// The state. - /// Pure arithmetic. Never write there for a state that belongs to Cheat Engine: the host may use the area itself. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void* lua_getextraspace(lua_State* L) - { - return (byte*)L - sizeof(void*); - } + /// + /// lua_getextraspace(L): address of the bytes of scratch memory that precede + /// the state. A new thread starts with a copy of the main thread's area. + /// + /// The state. + /// Pure arithmetic. Never write there for a state that belongs to Cheat Engine: the host may use the area itself. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void* lua_getextraspace(lua_State* L) + { + return (byte*) L - sizeof(void*); + } - /// lua_tonumber(L,i): without the success flag (0 is ambiguous). - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Number lua_tonumber(lua_State* L, int i) - { - return lua_tonumberx(L, i, null); - } + /// lua_tonumber(L,i): without the success flag (0 is ambiguous). + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Number lua_tonumber(lua_State* L, int i) + { + return lua_tonumberx(L, i, null); + } - /// lua_tointeger(L,i): without the success flag (0 is ambiguous). - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Integer lua_tointeger(lua_State* L, int i) - { - return lua_tointegerx(L, i, null); - } + /// lua_tointeger(L,i): without the success flag (0 is ambiguous). + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Integer lua_tointeger(lua_State* L, int i) + { + return lua_tointegerx(L, i, null); + } - /// lua_pop(L,n): drops the top elements. - /// The state. - /// Number of elements. - /// Stack: -n +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pop(lua_State* L, int n) - { - lua_settop(L, -n - 1); - } + /// lua_pop(L,n): drops the top elements. + /// The state. + /// Number of elements. + /// Stack: -n +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pop(lua_State* L, int n) + { + lua_settop(L, -n - 1); + } - /// lua_newtable(L): pushes a new empty table without preallocation. - /// The state. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_newtable(lua_State* L) - { - lua_createtable(L, 0, 0); - } + /// lua_newtable(L): pushes a new empty table without preallocation. + /// The state. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_newtable(lua_State* L) + { + lua_createtable(L, 0, 0); + } - /// lua_register(L,n,f): assigns the C function to the global . - /// The state. - /// NUL-terminated global name. - /// The function; see for the rules a managed one must follow. - /// Stack: -0 +0. Raises: any (through ). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_register(lua_State* L, byte* n, lua_CFunction f) - { - lua_pushcfunction(L, f); - lua_setglobal(L, n); - } + /// lua_register(L,n,f): assigns the C function to the global . + /// The state. + /// NUL-terminated global name. + /// The function; see for the rules a managed one must follow. + /// Stack: -0 +0. Raises: any (through ). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_register(lua_State* L, byte* n, lua_CFunction f) + { + lua_pushcfunction(L, f); + lua_setglobal(L, n); + } - /// lua_pushcfunction(L,f): pushes a C function without upvalues (nothing is allocated). - /// The state. - /// The function; see for the rules a managed one must follow. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushcfunction(lua_State* L, lua_CFunction f) - { - lua_pushcclosure(L, f, 0); - } + /// lua_pushcfunction(L,f): pushes a C function without upvalues (nothing is allocated). + /// The state. + /// The function; see for the rules a managed one must follow. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushcfunction(lua_State* L, lua_CFunction f) + { + lua_pushcclosure(L, f, 0); + } - /// lua_isfunction(L,n): whether the value is a function (Lua or C). - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_isfunction(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TFUNCTION; - } + /// lua_isfunction(L,n): whether the value is a function (Lua or C). + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_isfunction(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TFUNCTION; + } - /// lua_istable(L,n): whether the value is a table. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_istable(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TTABLE; - } + /// lua_istable(L,n): whether the value is a table. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_istable(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TTABLE; + } - /// lua_islightuserdata(L,n): whether the value is a light userdata. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_islightuserdata(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TLIGHTUSERDATA; - } + /// lua_islightuserdata(L,n): whether the value is a light userdata. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_islightuserdata(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TLIGHTUSERDATA; + } - /// lua_isnil(L,n): whether the value is nil. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_isnil(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TNIL; - } + /// lua_isnil(L,n): whether the value is nil. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_isnil(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TNIL; + } - /// lua_isboolean(L,n): whether the value is a boolean. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_isboolean(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TBOOLEAN; - } + /// lua_isboolean(L,n): whether the value is a boolean. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_isboolean(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TBOOLEAN; + } - /// lua_isthread(L,n): whether the value is a thread. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_isthread(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TTHREAD; - } + /// lua_isthread(L,n): whether the value is a thread. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_isthread(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TTHREAD; + } - /// lua_isnone(L,n): whether the index is beyond the top of the frame. - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_isnone(lua_State* L, int n) - { - return lua_type(L, n) == LUA_TNONE; - } + /// lua_isnone(L,n): whether the index is beyond the top of the frame. + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_isnone(lua_State* L, int n) + { + return lua_type(L, n) == LUA_TNONE; + } - /// lua_isnoneornil(L,n): whether the index is beyond the top or holds nil (an absent optional argument). - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool lua_isnoneornil(lua_State* L, int n) - { - return lua_type(L, n) <= 0; - } + /// lua_isnoneornil(L,n): whether the index is beyond the top or holds nil (an absent optional argument). + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool lua_isnoneornil(lua_State* L, int n) + { + return lua_type(L, n) <= 0; + } - /// - /// lua_pushliteral(L,s): pushes a string whose length is known without scanning. The C macro takes a string - /// literal; the managed equivalent of that is a "..."u8 literal, but any span works (NULs included). - /// - /// The state. - /// The bytes; copied by Lua during the call. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushliteral(lua_State* L, ReadOnlySpan s) - { - // An empty span pins to null; Lua is handed a valid address anyway, whatever it does with a zero length. - byte empty = 0; - fixed (byte* p = s) - { - _ = lua_pushlstring(L, p is null ? &empty : p, (size_t)s.Length); - } - } + /// + /// lua_pushliteral(L,s): pushes a string whose length is known without scanning. The C macro takes a string + /// literal; the managed equivalent of that is a "..."u8 literal, but any span works (NULs included). + /// + /// The state. + /// The bytes; copied by Lua during the call. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushliteral(lua_State* L, ReadOnlySpan s) + { + // An empty span pins to null; Lua is handed a valid address anyway, whatever it does with a zero length. + byte empty = 0; + fixed (byte* p = s) + { + _ = lua_pushlstring(L, p is null ? &empty : p, (size_t) s.Length); + } + } - /// - /// lua_pushglobaltable(L): pushes the globals table; the result is the macro's value, the type tag ( - /// ). - /// - /// The state. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_pushglobaltable(lua_State* L) - { - return lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS); - } + /// + /// lua_pushglobaltable(L): pushes the globals table; the result is the macro's value, the type tag ( + /// ). + /// + /// The state. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_pushglobaltable(lua_State* L) + { + return lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS); + } - /// - /// lua_tostring(L,i): without the length; only good for text known to contain - /// no NUL. - /// - /// The state. - /// Acceptable index. - /// Stack: -0 +0. Raises: memory. Same in-place conversion and pointer lifetime as . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_tostring(lua_State* L, int i) - { - return lua_tolstring(L, i, null); - } + /// + /// lua_tostring(L,i): without the length; only good for text known to contain + /// no NUL. + /// + /// The state. + /// Acceptable index. + /// Stack: -0 +0. Raises: memory. Same in-place conversion and pointer lifetime as . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_tostring(lua_State* L, int i) + { + return lua_tolstring(L, i, null); + } - /// - /// lua_insert(L,idx): moves the top element down to , shifting the elements above - /// it up. - /// - /// The state. - /// Valid stack index (not a pseudo-index). - /// Stack: -1 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_insert(lua_State* L, int idx) - { - lua_rotate(L, idx, 1); - } + /// + /// lua_insert(L,idx): moves the top element down to , shifting the elements above + /// it up. + /// + /// The state. + /// Valid stack index (not a pseudo-index). + /// Stack: -1 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_insert(lua_State* L, int idx) + { + lua_rotate(L, idx, 1); + } - /// lua_remove(L,idx): removes the element at , shifting the elements above it down. - /// The state. - /// Valid stack index (not a pseudo-index). - /// Stack: -1 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_remove(lua_State* L, int idx) - { - lua_rotate(L, idx, -1); - lua_pop(L, 1); - } + /// lua_remove(L,idx): removes the element at , shifting the elements above it down. + /// The state. + /// Valid stack index (not a pseudo-index). + /// Stack: -1 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_remove(lua_State* L, int idx) + { + lua_rotate(L, idx, -1); + lua_pop(L, 1); + } - /// lua_replace(L,idx): pops the top element into the slot ; nothing shifts. - /// The state. - /// Valid index. - /// Stack: -1 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_replace(lua_State* L, int idx) - { - lua_copy(L, -1, idx); - lua_pop(L, 1); - } + /// lua_replace(L,idx): pops the top element into the slot ; nothing shifts. + /// The state. + /// Valid index. + /// Stack: -1 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_replace(lua_State* L, int idx) + { + lua_copy(L, -1, idx); + lua_pop(L, 1); + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Misc.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Misc.cs index d5a99b6b..284022f4 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Misc.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Misc.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,79 +7,80 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h garbage collection, table traversal and allocator access. public static unsafe partial class LuaApi { - /// - /// int lua_gc (lua_State *L, int what, int data). Controls the garbage collector; the meaning of - /// and of the result depends on the LUA_GC* command. - /// - /// The state. - /// A LUA_GC* command. - /// Command argument (step size, pause, multiplier), otherwise 0. - /// Stack: -0 +0. Raises: memory. A collection runs __gc metamethods, which may be managed callbacks. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_gc(lua_State* L, int what, int data) - { - return s_table.lua_gc(L, what, data); - } + /// + /// int lua_gc (lua_State *L, int what, int data). Controls the garbage collector; the meaning of + /// and of the result depends on the LUA_GC* command. + /// + /// The state. + /// A LUA_GC* command. + /// Command argument (step size, pause, multiplier), otherwise 0. + /// Stack: -0 +0. Raises: memory. A collection runs __gc metamethods, which may be managed callbacks. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_gc(lua_State* L, int what, int data) + { + return s_table.lua_gc(L, what, data); + } - /// - /// int lua_next (lua_State *L, int idx). Table traversal step: pops a key and pushes the next key-value - /// pair of the table at (returns non-zero), or pushes nothing at the end (returns 0). Start - /// with a nil key. - /// - /// The state. - /// Valid index of a table; use an absolute index, the loop pushes and pops. - /// - /// Stack: -1 +(2|0). Raises: any (only when the popped key is not a key of the table). During the traversal pop - /// the value and keep the key, do not assign to absent fields, and never call on a key - /// that is not already a string: the in-place conversion confuses the next step. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_next(lua_State* L, int idx) - { - return s_table.lua_next(L, idx); - } + /// + /// int lua_next (lua_State *L, int idx). Table traversal step: pops a key and pushes the next key-value + /// pair of the table at (returns non-zero), or pushes nothing at the end (returns 0). Start + /// with a nil key. + /// + /// The state. + /// Valid index of a table; use an absolute index, the loop pushes and pops. + /// + /// Stack: -1 +(2|0). Raises: any (only when the popped key is not a key of the table). During the traversal pop + /// the value and keep the key, do not assign to absent fields, and never call on a key + /// that is not already a string: the in-place conversion confuses the next step. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_next(lua_State* L, int idx) + { + return s_table.lua_next(L, idx); + } - /// - /// lua_Alloc lua_getallocf (lua_State *L, void **ud). The allocator of the state and, through - /// when not null, its opaque pointer. - /// - /// The state. - /// Null, or receives the allocator's opaque pointer. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Alloc lua_getallocf(lua_State* L, void** ud) - { - return s_table.lua_getallocf(L, ud); - } + /// + /// lua_Alloc lua_getallocf (lua_State *L, void **ud). The allocator of the state and, through + /// when not null, its opaque pointer. + /// + /// The state. + /// Null, or receives the allocator's opaque pointer. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Alloc lua_getallocf(lua_State* L, void** ud) + { + return s_table.lua_getallocf(L, ud); + } - /// - /// void lua_setallocf (lua_State *L, lua_Alloc f, void *ud). Replaces the allocator of the state. The new - /// one must be able to free and resize blocks handed out by the old one. - /// - /// The state. - /// New allocator. - /// Opaque pointer passed to . - /// Stack: -0 +0. Raises: never. Never on a state that belongs to Cheat Engine. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_setallocf(lua_State* L, lua_Alloc f, void* ud) - { - s_table.lua_setallocf(L, f, ud); - } + /// + /// void lua_setallocf (lua_State *L, lua_Alloc f, void *ud). Replaces the allocator of the state. The new + /// one must be able to free and resize blocks handed out by the old one. + /// + /// The state. + /// New allocator. + /// Opaque pointer passed to . + /// Stack: -0 +0. Raises: never. Never on a state that belongs to Cheat Engine. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_setallocf(lua_State* L, lua_Alloc f, void* ud) + { + s_table.lua_setallocf(L, f, ud); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_gc; - internal delegate* unmanaged[Cdecl] lua_next; - internal delegate* unmanaged[Cdecl] lua_getallocf; - internal delegate* unmanaged[Cdecl] lua_setallocf; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_gc; + internal delegate* unmanaged[Cdecl] lua_next; + internal delegate* unmanaged[Cdecl] lua_getallocf; + internal delegate* unmanaged[Cdecl] lua_setallocf; - private void LoadMisc(ref ExportResolver exports) - { - lua_gc = (delegate* unmanaged[Cdecl])exports.Resolve("lua_gc"); - lua_next = (delegate* unmanaged[Cdecl])exports.Resolve("lua_next"); - lua_getallocf = (delegate* unmanaged[Cdecl])exports.Resolve("lua_getallocf"); - lua_setallocf = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_setallocf"); - } - } + private void LoadMisc(ref ExportResolver exports) + { + lua_gc = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_gc"); + lua_next = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_next"); + lua_getallocf = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_getallocf"); + lua_setallocf = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setallocf"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Operators.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Operators.cs index 8d7d251b..e2e1a899 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Operators.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Operators.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,108 +7,108 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "comparison and arithmetic functions" plus the value operators of the "miscellaneous" block. public static unsafe partial class LuaApi { - /// - /// void lua_arith (lua_State *L, int op). Applies an arithmetic or bitwise operator to the top one or two - /// values (second operand on top), with Lua semantics, and replaces them with the result. - /// - /// The state. - /// A LUA_OP* arithmetic operator. - /// - /// Stack: -(2|1) +1. Raises: any (metamethods; also for plain operands, for example a bitwise operator on a - /// non-integral float). - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_arith(lua_State* L, int op) - { - s_table.lua_arith(L, op); - } + /// + /// void lua_arith (lua_State *L, int op). Applies an arithmetic or bitwise operator to the top one or two + /// values (second operand on top), with Lua semantics, and replaces them with the result. + /// + /// The state. + /// A LUA_OP* arithmetic operator. + /// + /// Stack: -(2|1) +1. Raises: any (metamethods; also for plain operands, for example a bitwise operator on a + /// non-integral float). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_arith(lua_State* L, int op) + { + s_table.lua_arith(L, op); + } - /// - /// int lua_rawequal (lua_State *L, int idx1, int idx2). 1 when the two values are primitively equal (no - /// __eq), 0 otherwise or when an index is not valid. - /// - /// The state. - /// Acceptable index of the first value. - /// Acceptable index of the second value. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_rawequal(lua_State* L, int idx1, int idx2) - { - return s_table.lua_rawequal(L, idx1, idx2); - } + /// + /// int lua_rawequal (lua_State *L, int idx1, int idx2). 1 when the two values are primitively equal (no + /// __eq), 0 otherwise or when an index is not valid. + /// + /// The state. + /// Acceptable index of the first value. + /// Acceptable index of the second value. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_rawequal(lua_State* L, int idx1, int idx2) + { + return s_table.lua_rawequal(L, idx1, idx2); + } - /// - /// int lua_compare (lua_State *L, int idx1, int idx2, int op). 1 when the comparison holds with Lua - /// semantics, 0 otherwise or when an index is not valid. - /// - /// The state. - /// Acceptable index of the left operand. - /// Acceptable index of the right operand. - /// , or . - /// Stack: -0 +0. Raises: any (metamethods; ordering values of different types). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_compare(lua_State* L, int idx1, int idx2, int op) - { - return s_table.lua_compare(L, idx1, idx2, op); - } + /// + /// int lua_compare (lua_State *L, int idx1, int idx2, int op). 1 when the comparison holds with Lua + /// semantics, 0 otherwise or when an index is not valid. + /// + /// The state. + /// Acceptable index of the left operand. + /// Acceptable index of the right operand. + /// , or . + /// Stack: -0 +0. Raises: any (metamethods; ordering values of different types). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_compare(lua_State* L, int idx1, int idx2, int op) + { + return s_table.lua_compare(L, idx1, idx2, op); + } - /// - /// void lua_concat (lua_State *L, int n). Concatenates the top values into one; 0 - /// pushes the empty string, 1 leaves the stack as is. - /// - /// The state. - /// Number of values. - /// Stack: -n +1. Raises: any (__concat, non-concatenable operands, memory). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_concat(lua_State* L, int n) - { - s_table.lua_concat(L, n); - } + /// + /// void lua_concat (lua_State *L, int n). Concatenates the top values into one; 0 + /// pushes the empty string, 1 leaves the stack as is. + /// + /// The state. + /// Number of values. + /// Stack: -n +1. Raises: any (__concat, non-concatenable operands, memory). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_concat(lua_State* L, int n) + { + s_table.lua_concat(L, n); + } - /// - /// void lua_len (lua_State *L, int idx). Pushes the length of the value with Lua semantics (# - /// operator, may call __len). - /// - /// The state. - /// Valid index. - /// Stack: -0 +1. Raises: any. is the non-raising alternative. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_len(lua_State* L, int idx) - { - s_table.lua_len(L, idx); - } + /// + /// void lua_len (lua_State *L, int idx). Pushes the length of the value with Lua semantics (# + /// operator, may call __len). + /// + /// The state. + /// Valid index. + /// Stack: -0 +1. Raises: any. is the non-raising alternative. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_len(lua_State* L, int idx) + { + s_table.lua_len(L, idx); + } - /// - /// size_t lua_stringtonumber (lua_State *L, const char *s). Parses a NUL-terminated numeral with the lexer's - /// rules and pushes the number. Returns the string size including the NUL on success, 0 (nothing pushed) on failure. - /// - /// The state. - /// NUL-terminated text. - /// Stack: -0 +(0|1). Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static size_t lua_stringtonumber(lua_State* L, byte* s) - { - return s_table.lua_stringtonumber(L, s); - } + /// + /// size_t lua_stringtonumber (lua_State *L, const char *s). Parses a NUL-terminated numeral with the lexer's + /// rules and pushes the number. Returns the string size including the NUL on success, 0 (nothing pushed) on failure. + /// + /// The state. + /// NUL-terminated text. + /// Stack: -0 +(0|1). Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static size_t lua_stringtonumber(lua_State* L, byte* s) + { + return s_table.lua_stringtonumber(L, s); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_arith; - internal delegate* unmanaged[Cdecl] lua_rawequal; - internal delegate* unmanaged[Cdecl] lua_compare; - internal delegate* unmanaged[Cdecl] lua_concat; - internal delegate* unmanaged[Cdecl] lua_len; - internal delegate* unmanaged[Cdecl] lua_stringtonumber; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_arith; + internal delegate* unmanaged[Cdecl] lua_rawequal; + internal delegate* unmanaged[Cdecl] lua_compare; + internal delegate* unmanaged[Cdecl] lua_concat; + internal delegate* unmanaged[Cdecl] lua_len; + internal delegate* unmanaged[Cdecl] lua_stringtonumber; - private void LoadOperators(ref ExportResolver exports) - { - lua_arith = (delegate* unmanaged[Cdecl])exports.Resolve("lua_arith"); - lua_rawequal = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawequal"); - lua_compare = (delegate* unmanaged[Cdecl])exports.Resolve("lua_compare"); - lua_concat = (delegate* unmanaged[Cdecl])exports.Resolve("lua_concat"); - lua_len = (delegate* unmanaged[Cdecl])exports.Resolve("lua_len"); - lua_stringtonumber = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_stringtonumber"); - } - } + private void LoadOperators(ref ExportResolver exports) + { + lua_arith = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_arith"); + lua_rawequal = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawequal"); + lua_compare = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_compare"); + lua_concat = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_concat"); + lua_len = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_len"); + lua_stringtonumber = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_stringtonumber"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Push.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Push.cs index fe7946ab..b21df87a 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Push.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Push.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -7,149 +8,149 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // cannot be expressed as a blittable function pointer. public static unsafe partial class LuaApi { - /// void lua_pushnil (lua_State *L). Pushes nil. - /// The state. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushnil(lua_State* L) - { - s_table.lua_pushnil(L); - } + /// void lua_pushnil (lua_State *L). Pushes nil. + /// The state. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushnil(lua_State* L) + { + s_table.lua_pushnil(L); + } - /// void lua_pushnumber (lua_State *L, lua_Number n). Pushes a float. - /// The state. - /// The value; it keeps the float subtype even when it is integral. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushnumber(lua_State* L, lua_Number n) - { - s_table.lua_pushnumber(L, n); - } + /// void lua_pushnumber (lua_State *L, lua_Number n). Pushes a float. + /// The state. + /// The value; it keeps the float subtype even when it is integral. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushnumber(lua_State* L, lua_Number n) + { + s_table.lua_pushnumber(L, n); + } - /// void lua_pushinteger (lua_State *L, lua_Integer n). Pushes a 64-bit integer. - /// The state. - /// The value. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushinteger(lua_State* L, lua_Integer n) - { - s_table.lua_pushinteger(L, n); - } + /// void lua_pushinteger (lua_State *L, lua_Integer n). Pushes a 64-bit integer. + /// The state. + /// The value. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushinteger(lua_State* L, lua_Integer n) + { + s_table.lua_pushinteger(L, n); + } - /// - /// const char *lua_pushlstring (lua_State *L, const char *s, size_t len). Pushes a copy of - /// bytes, which may contain NULs. Returns a pointer to Lua's internal copy. - /// - /// The state. - /// First byte; only read during the call. May be null only when is 0. - /// Number of bytes. - /// - /// Stack: -0 +1. Raises: memory. Lua does not interpret the bytes: the encoding is the caller's contract (UTF-8 - /// in this SDK). - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_pushlstring(lua_State* L, byte* s, size_t len) - { - return s_table.lua_pushlstring(L, s, len); - } + /// + /// const char *lua_pushlstring (lua_State *L, const char *s, size_t len). Pushes a copy of + /// bytes, which may contain NULs. Returns a pointer to Lua's internal copy. + /// + /// The state. + /// First byte; only read during the call. May be null only when is 0. + /// Number of bytes. + /// + /// Stack: -0 +1. Raises: memory. Lua does not interpret the bytes: the encoding is the caller's contract (UTF-8 + /// in this SDK). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_pushlstring(lua_State* L, byte* s, size_t len) + { + return s_table.lua_pushlstring(L, s, len); + } - /// - /// const char *lua_pushstring (lua_State *L, const char *s). Pushes a copy of a NUL-terminated string, or - /// nil when is null. Returns a pointer to Lua's internal copy (null for nil). - /// - /// The state. - /// NUL-terminated bytes (a u8 literal qualifies), or null. - /// Stack: -0 +1. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static byte* lua_pushstring(lua_State* L, byte* s) - { - return s_table.lua_pushstring(L, s); - } + /// + /// const char *lua_pushstring (lua_State *L, const char *s). Pushes a copy of a NUL-terminated string, or + /// nil when is null. Returns a pointer to Lua's internal copy (null for nil). + /// + /// The state. + /// NUL-terminated bytes (a u8 literal qualifies), or null. + /// Stack: -0 +1. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte* lua_pushstring(lua_State* L, byte* s) + { + return s_table.lua_pushstring(L, s); + } - /// - /// void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n). Pops values and - /// pushes a C function that owns them as upvalues (reachable through ). - /// - /// The state. - /// - /// The function: the address of a static [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] method - /// for managed code. It must never let an exception escape and must never call a raising API. - /// - /// Number of upvalues, 0 to 255. With 0 the result is a light C function and nothing is allocated. - /// Stack: -n +1. Raises: memory (only when is greater than 0). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushcclosure(lua_State* L, lua_CFunction fn, int n) - { - s_table.lua_pushcclosure(L, fn, n); - } + /// + /// void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n). Pops values and + /// pushes a C function that owns them as upvalues (reachable through ). + /// + /// The state. + /// + /// The function: the address of a static [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] method + /// for managed code. It must never let an exception escape and must never call a raising API. + /// + /// Number of upvalues, 0 to 255. With 0 the result is a light C function and nothing is allocated. + /// Stack: -n +1. Raises: memory (only when is greater than 0). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushcclosure(lua_State* L, lua_CFunction fn, int n) + { + s_table.lua_pushcclosure(L, fn, n); + } - /// - /// void lua_pushboolean (lua_State *L, int b). Pushes when is - /// non-zero, - /// else . - /// - /// The state. - /// C truth value. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushboolean(lua_State* L, int b) - { - s_table.lua_pushboolean(L, b); - } + /// + /// void lua_pushboolean (lua_State *L, int b). Pushes when is + /// non-zero, + /// else . + /// + /// The state. + /// C truth value. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushboolean(lua_State* L, int b) + { + s_table.lua_pushboolean(L, b); + } - /// - /// void lua_pushlightuserdata (lua_State *L, void *p). Pushes a bare pointer value. Lua never dereferences - /// or frees it; equal pointers are equal values. - /// - /// The state. - /// Any pointer-sized value. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushlightuserdata(lua_State* L, void* p) - { - s_table.lua_pushlightuserdata(L, p); - } + /// + /// void lua_pushlightuserdata (lua_State *L, void *p). Pushes a bare pointer value. Lua never dereferences + /// or frees it; equal pointers are equal values. + /// + /// The state. + /// Any pointer-sized value. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushlightuserdata(lua_State* L, void* p) + { + s_table.lua_pushlightuserdata(L, p); + } - /// - /// int lua_pushthread (lua_State *L). Pushes the thread itself. Returns 1 when it is - /// the main thread of its state. - /// - /// The state. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_pushthread(lua_State* L) - { - return s_table.lua_pushthread(L); - } + /// + /// int lua_pushthread (lua_State *L). Pushes the thread itself. Returns 1 when it is + /// the main thread of its state. + /// + /// The state. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_pushthread(lua_State* L) + { + return s_table.lua_pushthread(L); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_pushnil; - internal delegate* unmanaged[Cdecl] lua_pushnumber; - internal delegate* unmanaged[Cdecl] lua_pushinteger; - internal delegate* unmanaged[Cdecl] lua_pushlstring; - internal delegate* unmanaged[Cdecl] lua_pushstring; - internal delegate* unmanaged[Cdecl] lua_pushcclosure; - internal delegate* unmanaged[Cdecl] lua_pushboolean; - internal delegate* unmanaged[Cdecl] lua_pushlightuserdata; - internal delegate* unmanaged[Cdecl] lua_pushthread; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_pushnil; + internal delegate* unmanaged[Cdecl] lua_pushnumber; + internal delegate* unmanaged[Cdecl] lua_pushinteger; + internal delegate* unmanaged[Cdecl] lua_pushlstring; + internal delegate* unmanaged[Cdecl] lua_pushstring; + internal delegate* unmanaged[Cdecl] lua_pushcclosure; + internal delegate* unmanaged[Cdecl] lua_pushboolean; + internal delegate* unmanaged[Cdecl] lua_pushlightuserdata; + internal delegate* unmanaged[Cdecl] lua_pushthread; - private void LoadPush(ref ExportResolver exports) - { - lua_pushnil = (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushnil"); - lua_pushnumber = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushnumber"); - lua_pushinteger = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushinteger"); - lua_pushlstring = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushlstring"); - lua_pushstring = (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushstring"); - lua_pushcclosure = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushcclosure"); - lua_pushboolean = (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushboolean"); - lua_pushlightuserdata = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushlightuserdata"); - lua_pushthread = (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushthread"); - } - } + private void LoadPush(ref ExportResolver exports) + { + lua_pushnil = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushnil"); + lua_pushnumber = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushnumber"); + lua_pushinteger = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushinteger"); + lua_pushlstring = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushlstring"); + lua_pushstring = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushstring"); + lua_pushcclosure = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushcclosure"); + lua_pushboolean = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushboolean"); + lua_pushlightuserdata = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushlightuserdata"); + lua_pushthread = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushthread"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Set.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Set.cs index 6836756f..2d234128 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Set.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Set.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,151 +7,151 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "set functions (stack -> Lua)". public static unsafe partial class LuaApi { - /// void lua_setglobal (lua_State *L, const char *name). Pops a value and assigns it to a global. - /// The state. - /// NUL-terminated global name. - /// Stack: -1 +0. Raises: any (a __newindex metamethod on the globals table; memory). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_setglobal(lua_State* l, byte* name) - { - s_table.lua_setglobal(l, name); - } + /// void lua_setglobal (lua_State *L, const char *name). Pops a value and assigns it to a global. + /// The state. + /// NUL-terminated global name. + /// Stack: -1 +0. Raises: any (a __newindex metamethod on the globals table; memory). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_setglobal(lua_State* l, byte* name) + { + s_table.lua_setglobal(l, name); + } - /// - /// void lua_settable (lua_State *L, int idx). Does t[key] = value for the value at - /// , with the value on top and the key below it; pops both. - /// - /// The state. - /// Valid index of the indexed value. - /// Stack: -2 +0. Raises: any (__newindex; nil or NaN key; memory). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_settable(lua_State* l, int idx) - { - s_table.lua_settable(l, idx); - } + /// + /// void lua_settable (lua_State *L, int idx). Does t[key] = value for the value at + /// , with the value on top and the key below it; pops both. + /// + /// The state. + /// Valid index of the indexed value. + /// Stack: -2 +0. Raises: any (__newindex; nil or NaN key; memory). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_settable(lua_State* l, int idx) + { + s_table.lua_settable(l, idx); + } - /// - /// void lua_setfield (lua_State *L, int idx, const char *k). Does t[k] = value with the value on - /// top; pops it. - /// - /// The state. - /// Valid index of the indexed value. - /// NUL-terminated field name. - /// - /// Stack: -1 +0. Raises: any (__newindex, which is how Cheat Engine object properties are written; - /// memory). - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_setfield(lua_State* l, int idx, byte* k) - { - s_table.lua_setfield(l, idx, k); - } + /// + /// void lua_setfield (lua_State *L, int idx, const char *k). Does t[k] = value with the value on + /// top; pops it. + /// + /// The state. + /// Valid index of the indexed value. + /// NUL-terminated field name. + /// + /// Stack: -1 +0. Raises: any (__newindex, which is how Cheat Engine object properties are written; + /// memory). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_setfield(lua_State* l, int idx, byte* k) + { + s_table.lua_setfield(l, idx, k); + } - /// - /// void lua_seti (lua_State *L, int idx, lua_Integer n). Does t[n] = value with the value on top; - /// pops it. - /// - /// The state. - /// Valid index of the indexed value. - /// Integer key. - /// Stack: -1 +0. Raises: any (__newindex; memory). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_seti(lua_State* l, int idx, lua_Integer n) - { - s_table.lua_seti(l, idx, n); - } + /// + /// void lua_seti (lua_State *L, int idx, lua_Integer n). Does t[n] = value with the value on top; + /// pops it. + /// + /// The state. + /// Valid index of the indexed value. + /// Integer key. + /// Stack: -1 +0. Raises: any (__newindex; memory). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_seti(lua_State* l, int idx, lua_Integer n) + { + s_table.lua_seti(l, idx, n); + } - /// - /// void lua_rawset (lua_State *L, int idx). Like without metamethods. The - /// value at must be a table. - /// - /// The state. - /// Valid index of a table. - /// Stack: -2 +0. Raises: memory (and on a nil or NaN key). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_rawset(lua_State* l, int idx) - { - s_table.lua_rawset(l, idx); - } + /// + /// void lua_rawset (lua_State *L, int idx). Like without metamethods. The + /// value at must be a table. + /// + /// The state. + /// Valid index of a table. + /// Stack: -2 +0. Raises: memory (and on a nil or NaN key). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_rawset(lua_State* l, int idx) + { + s_table.lua_rawset(l, idx); + } - /// - /// void lua_rawseti (lua_State *L, int idx, lua_Integer n). Does t[n] = value without metamethods, - /// with the value on top; pops it. - /// - /// The state. - /// Valid index of a table. - /// Integer key. - /// Stack: -1 +0. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_rawseti(lua_State* l, int idx, lua_Integer n) - { - s_table.lua_rawseti(l, idx, n); - } + /// + /// void lua_rawseti (lua_State *L, int idx, lua_Integer n). Does t[n] = value without metamethods, + /// with the value on top; pops it. + /// + /// The state. + /// Valid index of a table. + /// Integer key. + /// Stack: -1 +0. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_rawseti(lua_State* l, int idx, lua_Integer n) + { + s_table.lua_rawseti(l, idx, n); + } - /// - /// void lua_rawsetp (lua_State *L, int idx, const void *p). Does t[p] = value where the key is the - /// light userdata , without metamethods; pops the value. - /// - /// The state. - /// Valid index of a table. - /// Pointer used as key. - /// Stack: -1 +0. Raises: memory. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_rawsetp(lua_State* l, int idx, void* p) - { - s_table.lua_rawsetp(l, idx, p); - } + /// + /// void lua_rawsetp (lua_State *L, int idx, const void *p). Does t[p] = value where the key is the + /// light userdata , without metamethods; pops the value. + /// + /// The state. + /// Valid index of a table. + /// Pointer used as key. + /// Stack: -1 +0. Raises: memory. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_rawsetp(lua_State* l, int idx, void* p) + { + s_table.lua_rawsetp(l, idx, p); + } - /// - /// int lua_setmetatable (lua_State *L, int objindex). Pops a table or nil and makes it the metatable of - /// the value at . The result carries no information in 5.3 (always 1). - /// - /// The state. - /// Acceptable index of the value. - /// Stack: -1 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_setmetatable(lua_State* l, int objindex) - { - return s_table.lua_setmetatable(l, objindex); - } + /// + /// int lua_setmetatable (lua_State *L, int objindex). Pops a table or nil and makes it the metatable of + /// the value at . The result carries no information in 5.3 (always 1). + /// + /// The state. + /// Acceptable index of the value. + /// Stack: -1 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_setmetatable(lua_State* l, int objindex) + { + return s_table.lua_setmetatable(l, objindex); + } - /// - /// void lua_setuservalue (lua_State *L, int idx). Pops a value and attaches it to the full userdata at - /// . - /// - /// The state. - /// Valid index of a full userdata. - /// Stack: -1 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_setuservalue(lua_State* l, int idx) - { - s_table.lua_setuservalue(l, idx); - } + /// + /// void lua_setuservalue (lua_State *L, int idx). Pops a value and attaches it to the full userdata at + /// . + /// + /// The state. + /// Valid index of a full userdata. + /// Stack: -1 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_setuservalue(lua_State* l, int idx) + { + s_table.lua_setuservalue(l, idx); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_setglobal; - internal delegate* unmanaged[Cdecl] lua_settable; - internal delegate* unmanaged[Cdecl] lua_setfield; - internal delegate* unmanaged[Cdecl] lua_seti; - internal delegate* unmanaged[Cdecl] lua_rawset; - internal delegate* unmanaged[Cdecl] lua_rawseti; - internal delegate* unmanaged[Cdecl] lua_rawsetp; - internal delegate* unmanaged[Cdecl] lua_setmetatable; - internal delegate* unmanaged[Cdecl] lua_setuservalue; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_setglobal; + internal delegate* unmanaged[Cdecl] lua_settable; + internal delegate* unmanaged[Cdecl] lua_setfield; + internal delegate* unmanaged[Cdecl] lua_seti; + internal delegate* unmanaged[Cdecl] lua_rawset; + internal delegate* unmanaged[Cdecl] lua_rawseti; + internal delegate* unmanaged[Cdecl] lua_rawsetp; + internal delegate* unmanaged[Cdecl] lua_setmetatable; + internal delegate* unmanaged[Cdecl] lua_setuservalue; - private void LoadSet(ref ExportResolver exports) - { - lua_setglobal = (delegate* unmanaged[Cdecl])exports.Resolve("lua_setglobal"); - lua_settable = (delegate* unmanaged[Cdecl])exports.Resolve("lua_settable"); - lua_setfield = (delegate* unmanaged[Cdecl])exports.Resolve("lua_setfield"); - lua_seti = (delegate* unmanaged[Cdecl])exports.Resolve("lua_seti"); - lua_rawset = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawset"); - lua_rawseti = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawseti"); - lua_rawsetp = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rawsetp"); - lua_setmetatable = (delegate* unmanaged[Cdecl])exports.Resolve("lua_setmetatable"); - lua_setuservalue = (delegate* unmanaged[Cdecl])exports.Resolve("lua_setuservalue"); - } - } + private void LoadSet(ref ExportResolver exports) + { + lua_setglobal = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setglobal"); + lua_settable = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_settable"); + lua_setfield = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setfield"); + lua_seti = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_seti"); + lua_rawset = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawset"); + lua_rawseti = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawseti"); + lua_rawsetp = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rawsetp"); + lua_setmetatable = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setmetatable"); + lua_setuservalue = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_setuservalue"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Stack.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Stack.cs index 479f6fe3..af37749e 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Stack.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Stack.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,133 +7,133 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "basic stack manipulation". public static unsafe partial class LuaApi { - /// - /// int lua_absindex (lua_State *L, int idx). Converts an acceptable index into an absolute one, which stays - /// valid while values are pushed above it. Pseudo-indices are returned unchanged. - /// - /// The state. - /// Acceptable index (positive, negative or pseudo). - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_absindex(lua_State* l, int idx) - { - return s_table.lua_absindex(l, idx); - } + /// + /// int lua_absindex (lua_State *L, int idx). Converts an acceptable index into an absolute one, which stays + /// valid while values are pushed above it. Pseudo-indices are returned unchanged. + /// + /// The state. + /// Acceptable index (positive, negative or pseudo). + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_absindex(lua_State* l, int idx) + { + return s_table.lua_absindex(l, idx); + } - /// - /// int lua_gettop (lua_State *L). Index of the top element, which is also the number of elements of the - /// current frame. - /// - /// The state. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_gettop(lua_State* l) - { - return s_table.lua_gettop(l); - } + /// + /// int lua_gettop (lua_State *L). Index of the top element, which is also the number of elements of the + /// current frame. + /// + /// The state. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_gettop(lua_State* l) + { + return s_table.lua_gettop(l); + } - /// - /// void lua_settop (lua_State *L, int idx). Sets the top: grows the frame with nils or drops the elements - /// above . 0 empties the frame. - /// - /// The state. - /// New top as an acceptable index; negative values count from the current top. - /// - /// Stack: -? +?. Raises: never. Growing beyond the space guaranteed by is undefined - /// behaviour. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_settop(lua_State* l, int idx) - { - s_table.lua_settop(l, idx); - } + /// + /// void lua_settop (lua_State *L, int idx). Sets the top: grows the frame with nils or drops the elements + /// above . 0 empties the frame. + /// + /// The state. + /// New top as an acceptable index; negative values count from the current top. + /// + /// Stack: -? +?. Raises: never. Growing beyond the space guaranteed by is undefined + /// behaviour. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_settop(lua_State* l, int idx) + { + s_table.lua_settop(l, idx); + } - /// void lua_pushvalue (lua_State *L, int idx). Pushes a copy of the element at . - /// The state. - /// Valid index. - /// Stack: -0 +1. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_pushvalue(lua_State* l, int idx) - { - s_table.lua_pushvalue(l, idx); - } + /// void lua_pushvalue (lua_State *L, int idx). Pushes a copy of the element at . + /// The state. + /// Valid index. + /// Stack: -0 +1. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_pushvalue(lua_State* l, int idx) + { + s_table.lua_pushvalue(l, idx); + } - /// - /// void lua_rotate (lua_State *L, int idx, int n). Rotates the elements between and - /// the top by positions towards the top (negative: towards the bottom). - /// - /// The state. - /// Valid stack index (not a pseudo-index) where the rotated segment starts. - /// Positions; its absolute value must not exceed the segment length. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_rotate(lua_State* l, int idx, int n) - { - s_table.lua_rotate(l, idx, n); - } + /// + /// void lua_rotate (lua_State *L, int idx, int n). Rotates the elements between and + /// the top by positions towards the top (negative: towards the bottom). + /// + /// The state. + /// Valid stack index (not a pseudo-index) where the rotated segment starts. + /// Positions; its absolute value must not exceed the segment length. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_rotate(lua_State* l, int idx, int n) + { + s_table.lua_rotate(l, idx, n); + } - /// - /// void lua_copy (lua_State *L, int fromidx, int toidx). Overwrites the slot - /// with the value at ; nothing moves. - /// - /// The state. - /// Valid source index. - /// Valid destination index. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_copy(lua_State* l, int fromidx, int toidx) - { - s_table.lua_copy(l, fromidx, toidx); - } + /// + /// void lua_copy (lua_State *L, int fromidx, int toidx). Overwrites the slot + /// with the value at ; nothing moves. + /// + /// The state. + /// Valid source index. + /// Valid destination index. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_copy(lua_State* l, int fromidx, int toidx) + { + s_table.lua_copy(l, fromidx, toidx); + } - /// - /// int lua_checkstack (lua_State *L, int n). Ensures room for more pushes. Returns 0 - /// when the stack cannot grow (limit or allocation failure), non-zero otherwise. - /// - /// The state. - /// Extra slots wanted. - /// Stack: -0 +0. Raises: never. A C function starts with free slots. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int lua_checkstack(lua_State* l, int n) - { - return s_table.lua_checkstack(l, n); - } + /// + /// int lua_checkstack (lua_State *L, int n). Ensures room for more pushes. Returns 0 + /// when the stack cannot grow (limit or allocation failure), non-zero otherwise. + /// + /// The state. + /// Extra slots wanted. + /// Stack: -0 +0. Raises: never. A C function starts with free slots. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int lua_checkstack(lua_State* l, int n) + { + return s_table.lua_checkstack(l, n); + } - /// - /// void lua_xmove (lua_State *from, lua_State *to, int n). Pops values from one thread - /// and pushes them onto another thread of the same global state. - /// - /// Source thread. - /// Destination thread; must share its global state with . - /// Number of values. - /// Stack: -? +? (across two stacks). Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_xmove(lua_State* from, lua_State* to, int n) - { - s_table.lua_xmove(from, to, n); - } + /// + /// void lua_xmove (lua_State *from, lua_State *to, int n). Pops values from one thread + /// and pushes them onto another thread of the same global state. + /// + /// Source thread. + /// Destination thread; must share its global state with . + /// Number of values. + /// Stack: -? +? (across two stacks). Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_xmove(lua_State* from, lua_State* to, int n) + { + s_table.lua_xmove(from, to, n); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_absindex; - internal delegate* unmanaged[Cdecl] lua_gettop; - internal delegate* unmanaged[Cdecl] lua_settop; - internal delegate* unmanaged[Cdecl] lua_pushvalue; - internal delegate* unmanaged[Cdecl] lua_rotate; - internal delegate* unmanaged[Cdecl] lua_copy; - internal delegate* unmanaged[Cdecl] lua_checkstack; - internal delegate* unmanaged[Cdecl] lua_xmove; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_absindex; + internal delegate* unmanaged[Cdecl] lua_gettop; + internal delegate* unmanaged[Cdecl] lua_settop; + internal delegate* unmanaged[Cdecl] lua_pushvalue; + internal delegate* unmanaged[Cdecl] lua_rotate; + internal delegate* unmanaged[Cdecl] lua_copy; + internal delegate* unmanaged[Cdecl] lua_checkstack; + internal delegate* unmanaged[Cdecl] lua_xmove; - private void LoadStack(ref ExportResolver exports) - { - lua_absindex = (delegate* unmanaged[Cdecl])exports.Resolve("lua_absindex"); - lua_gettop = (delegate* unmanaged[Cdecl])exports.Resolve("lua_gettop"); - lua_settop = (delegate* unmanaged[Cdecl])exports.Resolve("lua_settop"); - lua_pushvalue = (delegate* unmanaged[Cdecl])exports.Resolve("lua_pushvalue"); - lua_rotate = (delegate* unmanaged[Cdecl])exports.Resolve("lua_rotate"); - lua_copy = (delegate* unmanaged[Cdecl])exports.Resolve("lua_copy"); - lua_checkstack = (delegate* unmanaged[Cdecl])exports.Resolve("lua_checkstack"); - lua_xmove = (delegate* unmanaged[Cdecl])exports.Resolve("lua_xmove"); - } - } + private void LoadStack(ref ExportResolver exports) + { + lua_absindex = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_absindex"); + lua_gettop = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_gettop"); + lua_settop = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_settop"); + lua_pushvalue = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_pushvalue"); + lua_rotate = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_rotate"); + lua_copy = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_copy"); + lua_checkstack = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_checkstack"); + lua_xmove = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_xmove"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.State.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.State.cs index 4e036c3f..81aa0424 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.State.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.State.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Interop.Api; @@ -6,104 +7,104 @@ namespace CheatEngine.SDK.Lua.Interop.Api; // lua.h "state manipulation" + luaL_newstate. public static unsafe partial class LuaApi { - /// - /// lua_State *lua_newstate (lua_Alloc f, void *ud). Creates an independent state that allocates through - /// . Returns null when the first allocations fail. - /// - /// - /// Allocator with realloc-like semantics; must stay callable until - /// returns. - /// - /// Opaque pointer passed back to every call of . - /// Stack: n/a. Raises: never. The caller owns the state and must close it on the thread that uses it. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_State* lua_newstate(lua_Alloc f, void* ud) - { - return s_table.lua_newstate(f, ud); - } + /// + /// lua_State *lua_newstate (lua_Alloc f, void *ud). Creates an independent state that allocates through + /// . Returns null when the first allocations fail. + /// + /// + /// Allocator with realloc-like semantics; must stay callable until + /// returns. + /// + /// Opaque pointer passed back to every call of . + /// Stack: n/a. Raises: never. The caller owns the state and must close it on the thread that uses it. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_State* lua_newstate(lua_Alloc f, void* ud) + { + return s_table.lua_newstate(f, ud); + } - /// - /// void lua_close (lua_State *L). Destroys a main state: runs pending __gc metamethods and frees all - /// memory. Only for states the caller created; never for a state that belongs to Cheat Engine. - /// - /// A main state (not a coroutine thread). - /// Stack: n/a. Raises: never. Every pointer previously obtained from the state dangles afterwards. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void lua_close(lua_State* L) - { - s_table.lua_close(L); - } + /// + /// void lua_close (lua_State *L). Destroys a main state: runs pending __gc metamethods and frees all + /// memory. Only for states the caller created; never for a state that belongs to Cheat Engine. + /// + /// A main state (not a coroutine thread). + /// Stack: n/a. Raises: never. Every pointer previously obtained from the state dangles afterwards. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void lua_close(lua_State* L) + { + s_table.lua_close(L); + } - /// - /// lua_State *lua_newthread (lua_State *L). Creates a coroutine thread that shares the globals and registry - /// of , pushes it, and returns it. - /// - /// The state. - /// - /// Stack: -0 +1. Raises: memory. The thread is garbage collected: keep it referenced (stack, registry) while in - /// use. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_State* lua_newthread(lua_State* L) - { - return s_table.lua_newthread(L); - } + /// + /// lua_State *lua_newthread (lua_State *L). Creates a coroutine thread that shares the globals and registry + /// of , pushes it, and returns it. + /// + /// The state. + /// + /// Stack: -0 +1. Raises: memory. The thread is garbage collected: keep it referenced (stack, registry) while in + /// use. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_State* lua_newthread(lua_State* L) + { + return s_table.lua_newthread(L); + } - /// - /// lua_CFunction lua_atpanic (lua_State *L, lua_CFunction panicf). Installs the function Lua calls when an - /// error is raised outside any protected call, and returns the previous one. - /// - /// The state. - /// New panic function; when it returns, Lua aborts the process. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_CFunction lua_atpanic(lua_State* L, lua_CFunction panicf) - { - return s_table.lua_atpanic(L, panicf); - } + /// + /// lua_CFunction lua_atpanic (lua_State *L, lua_CFunction panicf). Installs the function Lua calls when an + /// error is raised outside any protected call, and returns the previous one. + /// + /// The state. + /// New panic function; when it returns, Lua aborts the process. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_CFunction lua_atpanic(lua_State* L, lua_CFunction panicf) + { + return s_table.lua_atpanic(L, panicf); + } - /// - /// const lua_Number *lua_version (lua_State *L). Address of the version number stored in the state's global - /// state, or of the library's own copy when is null. The value is - /// for 5.3. - /// - /// The state, or null. - /// Stack: -0 +0. Raises: never. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_Number* lua_version(lua_State* L) - { - return s_table.lua_version(L); - } + /// + /// const lua_Number *lua_version (lua_State *L). Address of the version number stored in the state's global + /// state, or of the library's own copy when is null. The value is + /// for 5.3. + /// + /// The state, or null. + /// Stack: -0 +0. Raises: never. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_Number* lua_version(lua_State* L) + { + return s_table.lua_version(L); + } - /// - /// lua_State *luaL_newstate (void). Creates a state with the C allocator of the Lua library and a panic - /// function that prints to stderr. Returns null on allocation failure. No library is opened. - /// - /// Stack: n/a. Raises: never. The caller owns the state and must it. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static lua_State* luaL_newstate() - { - return s_table.luaL_newstate(); - } + /// + /// lua_State *luaL_newstate (void). Creates a state with the C allocator of the Lua library and a panic + /// function that prints to stderr. Returns null on allocation failure. No library is opened. + /// + /// Stack: n/a. Raises: never. The caller owns the state and must it. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static lua_State* luaL_newstate() + { + return s_table.luaL_newstate(); + } - internal partial struct Table - { - internal delegate* unmanaged[Cdecl] lua_newstate; - internal delegate* unmanaged[Cdecl] lua_close; - internal delegate* unmanaged[Cdecl] lua_newthread; - internal delegate* unmanaged[Cdecl] lua_atpanic; - internal delegate* unmanaged[Cdecl] lua_version; - internal delegate* unmanaged[Cdecl] luaL_newstate; + internal partial struct Table + { + internal delegate* unmanaged[Cdecl] lua_newstate; + internal delegate* unmanaged[Cdecl] lua_close; + internal delegate* unmanaged[Cdecl] lua_newthread; + internal delegate* unmanaged[Cdecl] lua_atpanic; + internal delegate* unmanaged[Cdecl] lua_version; + internal delegate* unmanaged[Cdecl] luaL_newstate; - private void LoadState(ref ExportResolver exports) - { - lua_newstate = (delegate* unmanaged[Cdecl])exports.Resolve("lua_newstate"); - lua_close = (delegate* unmanaged[Cdecl])exports.Resolve("lua_close"); - lua_newthread = (delegate* unmanaged[Cdecl])exports.Resolve("lua_newthread"); - lua_atpanic = - (delegate* unmanaged[Cdecl])exports.Resolve("lua_atpanic"); - lua_version = (delegate* unmanaged[Cdecl])exports.Resolve("lua_version"); - luaL_newstate = (delegate* unmanaged[Cdecl])exports.Resolve("luaL_newstate"); - } - } + private void LoadState(ref ExportResolver exports) + { + lua_newstate = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_newstate"); + lua_close = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_close"); + lua_newthread = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_newthread"); + lua_atpanic = + (delegate* unmanaged[Cdecl]) exports.Resolve("lua_atpanic"); + lua_version = (delegate* unmanaged[Cdecl]) exports.Resolve("lua_version"); + luaL_newstate = (delegate* unmanaged[Cdecl]) exports.Resolve("luaL_newstate"); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Table.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Table.cs index 369708a7..30a70ae2 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Table.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.Table.cs @@ -4,34 +4,34 @@ namespace CheatEngine.SDK.Lua.Interop.Api; public static partial class LuaApi { - /// - /// The function-pointer table. One field per export, named like the export; the fields and the code that fills - /// them live next to the forwarding methods, in the partial file of their area. - /// - /// - /// LayoutKind.Auto because native code never sees the struct and it is declared across several files, where - /// a sequential layout would have no defined field order (CS0282). A struct rather than loose static fields so - /// that a bind attempt fills a local copy and publishes it only when it is complete. - /// - [StructLayout(LayoutKind.Auto)] - internal partial struct Table - { - /// Resolves every export of the table; names that are not found are recorded by . - internal void Load(ref ExportResolver exports) - { - LoadState(ref exports); - LoadStack(ref exports); - LoadAccess(ref exports); - LoadOperators(ref exports); - LoadPush(ref exports); - LoadGet(ref exports); - LoadSet(ref exports); - LoadCalls(ref exports); - LoadCoroutines(ref exports); - LoadMisc(ref exports); - LoadDebug(ref exports); - LoadAuxiliary(ref exports); - LoadLibraries(ref exports); - } - } + /// + /// The function-pointer table. One field per export, named like the export; the fields and the code that fills + /// them live next to the forwarding methods, in the partial file of their area. + /// + /// + /// LayoutKind.Auto because native code never sees the struct and it is declared across several files, where + /// a sequential layout would have no defined field order (CS0282). A struct rather than loose static fields so + /// that a bind attempt fills a local copy and publishes it only when it is complete. + /// + [StructLayout(LayoutKind.Auto)] + internal partial struct Table + { + /// Resolves every export of the table; names that are not found are recorded by . + internal void Load(ref ExportResolver exports) + { + LoadState(ref exports); + LoadStack(ref exports); + LoadAccess(ref exports); + LoadOperators(ref exports); + LoadPush(ref exports); + LoadGet(ref exports); + LoadSet(ref exports); + LoadCalls(ref exports); + LoadCoroutines(ref exports); + LoadMisc(ref exports); + LoadDebug(ref exports); + LoadAuxiliary(ref exports); + LoadLibraries(ref exports); + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.cs index 7bd6f9a2..79cb0423 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApi.cs @@ -59,129 +59,135 @@ namespace CheatEngine.SDK.Lua.Interop.Api; /// public static partial class LuaApi { - private const string NullModuleMessage = "The module handle is zero: pass the handle of a loaded Lua 5.3 library."; - - private static readonly Lock s_gate = new(); - - // Written once under s_gate, before s_module is published; never written again. - private static Table s_table; - private static nint s_module; - - /// Whether the function-pointer table is bound. Safe to read from any thread. - public static bool IsInitialized => Volatile.Read(ref s_module) != 0; - - /// The module the table is bound to, or zero while unbound. Safe to read from any thread. - public static nint ModuleHandle => Volatile.Read(ref s_module); - - /// - /// Binds the table to the exports of . All or nothing: on failure the table is left - /// exactly as it was. Idempotent for the same module; thread-safe. - /// - /// - /// Handle of a loaded native module that exports the Lua 5.3 API (an HMODULE on Windows), as returned by - /// or NativeLibrary.Load. The module must stay - /// loaded - /// for the rest of the process: the table keeps raw addresses into it. Passing a value that is not a module handle - /// is undefined behaviour. - /// - /// is zero. - /// The module lacks at least one export; the message names all of them. - /// The table is already bound to a different module. - public static void Initialize(nint moduleHandle) - { - var result = Bind(moduleHandle, out var failure); - switch (result) - { - case LuaApiBindResult.Bound: - return; - case LuaApiBindResult.NullModule: - throw new ArgumentException(failure, nameof(moduleHandle)); - case LuaApiBindResult.MissingExports: - throw new EntryPointNotFoundException(failure); - default: - throw new InvalidOperationException(failure); - } - } - - /// - /// Non-throwing form of : binds the table to the exports of - /// , all or nothing. Idempotent for the same module; thread-safe. - /// - /// See . Zero is reported as a failure, not thrown. - /// - /// Null on success. Otherwise a message for a log or a test skip reason: it names every missing export, or the - /// module the table is already bound to. - /// - /// True when the table is bound to when the call returns. - public static bool TryInitialize(nint moduleHandle, [NotNullWhen(false)] out string? failure) - { - var bound = Bind(moduleHandle, out var message) == LuaApiBindResult.Bound; - failure = bound ? null : message ?? "The Lua API table could not be bound."; - return bound; - } - - /// - /// Checks, without binding or changing anything, which exports of the table lacks. - /// Usable before or after initialization, from any thread. - /// - /// - /// Handle of a loaded native module; a value that is not a module handle is undefined - /// behaviour. - /// - /// The missing export names in table order; empty when the module can be bound. - /// is zero. - public static IReadOnlyList GetMissingExports(nint moduleHandle) - { - if (moduleHandle == 0) throw new ArgumentException(NullModuleMessage, nameof(moduleHandle)); - - ExportResolver exports = new(moduleHandle); - Table scratch = default; - scratch.Load(ref exports); - return exports.Missing; - } - - private static LuaApiBindResult Bind(nint moduleHandle, out string? failure) - { - failure = null; - if (moduleHandle == 0) - { - failure = NullModuleMessage; - return LuaApiBindResult.NullModule; - } - - lock (s_gate) - { - if (s_module == moduleHandle) return LuaApiBindResult.Bound; - - // Fill a local table first: a module that turns out to be incomplete must not leave a half-written table - // behind, and a process that is already bound must not be disturbed at all. - ExportResolver exports = new(moduleHandle); - Table candidate = default; - candidate.Load(ref exports); - if (exports.Missing.Count != 0) - { - failure = DescribeMissing(moduleHandle, exports.Missing, exports.Requested); - return LuaApiBindResult.MissingExports; - } - - if (s_module != 0) - { - failure = string.Create( - CultureInfo.InvariantCulture, - $"The Lua API table is already bound to module 0x{s_module:X} and cannot be rebound to module 0x{moduleHandle:X}: one process uses one Lua library."); - return LuaApiBindResult.BoundToAnotherModule; - } - - s_table = candidate; - Volatile.Write(ref s_module, moduleHandle); - return LuaApiBindResult.Bound; - } - } - - private static string DescribeMissing(nint moduleHandle, IReadOnlyList missing, int requested) - { - return string.Create( - CultureInfo.InvariantCulture, - $"Module 0x{moduleHandle:X} is not a usable Lua 5.3 library: {missing.Count} of {requested} required exports are missing ({string.Join(", ", missing)})."); - } + private const string NullModuleMessage = "The module handle is zero: pass the handle of a loaded Lua 5.3 library."; + + private static readonly Lock s_gate = new(); + + // Written once under s_gate, before s_module is published; never written again. + private static Table s_table; + private static nint s_module; + + /// Whether the function-pointer table is bound. Safe to read from any thread. + public static bool IsInitialized => Volatile.Read(ref s_module) != 0; + + /// The module the table is bound to, or zero while unbound. Safe to read from any thread. + public static nint ModuleHandle => Volatile.Read(ref s_module); + + /// + /// Binds the table to the exports of . All or nothing: on failure the table is left + /// exactly as it was. Idempotent for the same module; thread-safe. + /// + /// + /// Handle of a loaded native module that exports the Lua 5.3 API (an HMODULE on Windows), as returned by + /// or NativeLibrary.Load. The module must stay + /// loaded + /// for the rest of the process: the table keeps raw addresses into it. Passing a value that is not a module handle + /// is undefined behaviour. + /// + /// is zero. + /// The module lacks at least one export; the message names all of them. + /// The table is already bound to a different module. + public static void Initialize(nint moduleHandle) + { + LuaApiBindResult result = Bind(moduleHandle, out string? failure); + switch (result) + { + case LuaApiBindResult.Bound: + return; + case LuaApiBindResult.NullModule: + throw new ArgumentException(failure, nameof(moduleHandle)); + case LuaApiBindResult.MissingExports: + throw new EntryPointNotFoundException(failure); + default: + throw new InvalidOperationException(failure); + } + } + + /// + /// Non-throwing form of : binds the table to the exports of + /// , all or nothing. Idempotent for the same module; thread-safe. + /// + /// See . Zero is reported as a failure, not thrown. + /// + /// Null on success. Otherwise a message for a log or a test skip reason: it names every missing export, or the + /// module the table is already bound to. + /// + /// True when the table is bound to when the call returns. + public static bool TryInitialize(nint moduleHandle, [NotNullWhen(false)] out string? failure) + { + bool bound = Bind(moduleHandle, out string? message) == LuaApiBindResult.Bound; + failure = bound ? null : message ?? "The Lua API table could not be bound."; + return bound; + } + + /// + /// Checks, without binding or changing anything, which exports of the table lacks. + /// Usable before or after initialization, from any thread. + /// + /// + /// Handle of a loaded native module; a value that is not a module handle is undefined + /// behaviour. + /// + /// The missing export names in table order; empty when the module can be bound. + /// is zero. + public static IReadOnlyList GetMissingExports(nint moduleHandle) + { + if (moduleHandle == 0) + { + throw new ArgumentException(NullModuleMessage, nameof(moduleHandle)); + } + + ExportResolver exports = new(moduleHandle); + Table scratch = default; + scratch.Load(ref exports); + return exports.Missing; + } + + private static LuaApiBindResult Bind(nint moduleHandle, out string? failure) + { + failure = null; + if (moduleHandle == 0) + { + failure = NullModuleMessage; + return LuaApiBindResult.NullModule; + } + + lock (s_gate) + { + if (s_module == moduleHandle) + { + return LuaApiBindResult.Bound; + } + + // Fill a local table first: a module that turns out to be incomplete must not leave a half-written table + // behind, and a process that is already bound must not be disturbed at all. + ExportResolver exports = new(moduleHandle); + Table candidate = default; + candidate.Load(ref exports); + if (exports.Missing.Count != 0) + { + failure = DescribeMissing(moduleHandle, exports.Missing, exports.Requested); + return LuaApiBindResult.MissingExports; + } + + if (s_module != 0) + { + failure = string.Create( + CultureInfo.InvariantCulture, + $"The Lua API table is already bound to module 0x{s_module:X} and cannot be rebound to module 0x{moduleHandle:X}: one process uses one Lua library."); + return LuaApiBindResult.BoundToAnotherModule; + } + + s_table = candidate; + Volatile.Write(ref s_module, moduleHandle); + return LuaApiBindResult.Bound; + } + } + + private static string DescribeMissing(nint moduleHandle, IReadOnlyList missing, int requested) + { + return string.Create( + CultureInfo.InvariantCulture, + $"Module 0x{moduleHandle:X} is not a usable Lua 5.3 library: {missing.Count} of {requested} required exports are missing ({string.Join(", ", missing)})."); + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApiBindResult.cs b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApiBindResult.cs index ecf5bafe..01c5b1b8 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApiBindResult.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Api/LuaApiBindResult.cs @@ -3,15 +3,15 @@ namespace CheatEngine.SDK.Lua.Interop.Api; /// Outcome of one attempt to bind the function-pointer table; the caller maps it to an exception or a message. internal enum LuaApiBindResult { - /// The table is bound to the requested module (now, or already before the call). - Bound, + /// The table is bound to the requested module (now, or already before the call). + Bound, - /// The module handle was zero. - NullModule, + /// The module handle was zero. + NullModule, - /// The module lacks at least one export of the table; nothing was changed. - MissingExports, + /// The module lacks at least one export of the table; nothing was changed. + MissingExports, - /// The table is already bound to another module; nothing was changed. - BoundToAnotherModule + /// The table is already bound to another module; nothing was changed. + BoundToAnotherModule } diff --git a/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs b/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs index 728f123e..0a23d4bc 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs @@ -13,11 +13,11 @@ global using unsafe lua_Alloc = delegate* unmanaged[Cdecl]; global using unsafe lua_CFunction = delegate* unmanaged[Cdecl]; global using unsafe lua_Hook = - delegate* unmanaged[Cdecl]< - CheatEngine.SDK.Lua.Interop.Types.lua_State*, CheatEngine.SDK.Lua.Interop.Types.lua_Debug*, void>; + delegate* unmanaged[Cdecl]< + CheatEngine.SDK.Lua.Interop.Types.lua_State*, CheatEngine.SDK.Lua.Interop.Types.lua_Debug*, void>; global using unsafe lua_KFunction = - delegate* unmanaged[Cdecl]; + delegate* unmanaged[Cdecl]; global using unsafe lua_Reader = - delegate* unmanaged[Cdecl]; + delegate* unmanaged[Cdecl]; global using unsafe lua_Writer = - delegate* unmanaged[Cdecl]; + delegate* unmanaged[Cdecl]; diff --git a/libs/CheatEngine.SDK.Lua.Interop/Loading/LuaModule.cs b/libs/CheatEngine.SDK.Lua.Interop/Loading/LuaModule.cs index 05c249b8..d431191f 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Loading/LuaModule.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Loading/LuaModule.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using System.Runtime.Versioning; + using CheatEngine.SDK.Lua.Interop.Api; namespace CheatEngine.SDK.Lua.Interop.Loading; @@ -32,57 +33,63 @@ namespace CheatEngine.SDK.Lua.Interop.Loading; /// public static unsafe partial class LuaModule { - /// File name of the Lua 5.3 library inside a 64-bit Cheat Engine process. - public const string CheatEngine64ModuleName = "lua53-64.dll"; + /// File name of the Lua 5.3 library inside a 64-bit Cheat Engine process. + public const string CheatEngine64ModuleName = "lua53-64.dll"; - /// - /// Looks for a module that is already loaded in the current process. Thread-safe; does not run any code of the - /// module. - /// - /// - /// Module file name, compared case-insensitively against the loaded modules (".dll" is assumed when there is no - /// extension), or a full path to disambiguate. With a bare name and several loaded modules of that name, which one - /// is returned is unspecified. - /// - /// - /// The module handle (usable with NativeLibrary.GetExport and ), or zero. - /// Each successful call adds one loader reference to the module: keep it for the life of the process, which is - /// what a bound needs, or balance it with NativeLibrary.Free. - /// - /// - /// True when the module is loaded. False when it is not, and always on a non-Windows platform, where this lookup - /// is not implemented. - /// - /// is null. - /// is empty. - public static bool TryGetLoaded(string moduleName, out nint moduleHandle) - { - ArgumentException.ThrowIfNullOrEmpty(moduleName); + /// + /// Looks for a module that is already loaded in the current process. Thread-safe; does not run any code of the + /// module. + /// + /// + /// Module file name, compared case-insensitively against the loaded modules (".dll" is assumed when there is no + /// extension), or a full path to disambiguate. With a bare name and several loaded modules of that name, which one + /// is returned is unspecified. + /// + /// + /// The module handle (usable with NativeLibrary.GetExport and ), or zero. + /// Each successful call adds one loader reference to the module: keep it for the life of the process, which is + /// what a bound needs, or balance it with NativeLibrary.Free. + /// + /// + /// True when the module is loaded. False when it is not, and always on a non-Windows platform, where this lookup + /// is not implemented. + /// + /// is null. + /// is empty. + public static bool TryGetLoaded(string moduleName, out nint moduleHandle) + { + ArgumentException.ThrowIfNullOrEmpty(moduleName); - moduleHandle = 0; - if (!OperatingSystem.IsWindows()) return false; + moduleHandle = 0; + if (!OperatingSystem.IsWindows()) + { + return false; + } - nint handle = 0; - fixed (char* name = moduleName) - { - if (GetModuleHandleExW(0, name, &handle) == 0) return false; - } + nint handle = 0; + fixed (char* name = moduleName) + { + if (GetModuleHandleExW(0, name, &handle) == 0) + { + return false; + } + } - moduleHandle = handle; - return handle != 0; - } + moduleHandle = handle; + return handle != 0; + } - /// for . - /// The module handle, or zero. - /// True when the current process has Cheat Engine's 64-bit Lua library loaded. - public static bool TryGetLoaded(out nint moduleHandle) - { - return TryGetLoaded(CheatEngine64ModuleName, out moduleHandle); - } + /// for . + /// The module handle, or zero. + /// True when the current process has Cheat Engine's 64-bit Lua library loaded. + public static bool TryGetLoaded(out nint moduleHandle) + { + return TryGetLoaded(CheatEngine64ModuleName, out moduleHandle); + } - // BOOL GetModuleHandleExW(DWORD dwFlags, LPCWSTR lpModuleName, HMODULE* phModule); dwFlags = 0: counted handle. - [LibraryImport("kernel32", EntryPoint = "GetModuleHandleExW")] - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [SupportedOSPlatform("windows")] - private static partial int GetModuleHandleExW(uint dwFlags, char* lpModuleName, nint* phModule); + // BOOL GetModuleHandleExW(DWORD dwFlags, LPCWSTR lpModuleName, HMODULE* phModule); dwFlags = 0: counted handle. + [LibraryImport("kernel32", EntryPoint = "GetModuleHandleExW")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows")] + private static partial int GetModuleHandleExW(uint dwFlags, char* lpModuleName, nint* phModule); } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Protected/BridgeBinding.cs b/libs/CheatEngine.SDK.Lua.Interop/Protected/BridgeBinding.cs index 3f5b5787..bca64d5d 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Protected/BridgeBinding.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/BridgeBinding.cs @@ -2,6 +2,13 @@ namespace CheatEngine.SDK.Lua.Interop.Protected; internal sealed class BridgeBinding(nint module, LuaProtectedExports exports) { - internal nint Module { get; } = module; - internal LuaProtectedExports Exports { get; } = exports; + internal nint Module + { + get; + } = module; + + internal LuaProtectedExports Exports + { + get; + } = exports; } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs index db2d9441..ec24718c 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs @@ -8,45 +8,45 @@ namespace CheatEngine.SDK.Lua.Interop.Protected; [StructLayout(LayoutKind.Explicit, Size = Size)] internal struct LuaBridgeContract { - internal const uint ExpectedMagic = 0x4345534B; - internal const uint ExpectedLegacyAbiVersion = 1; - internal const ushort ExpectedMajor = 1; - internal const ushort MinimumMinor = 1; - internal const int Size = 32; + internal const uint ExpectedMagic = 0x4345534B; + internal const uint ExpectedLegacyAbiVersion = 1; + internal const ushort ExpectedMajor = 1; + internal const ushort MinimumMinor = 1; + internal const int Size = 32; - [FieldOffset(0)] internal uint Magic; + [FieldOffset(0)] internal uint Magic; - [FieldOffset(4)] internal uint ContractSize; + [FieldOffset(4)] internal uint ContractSize; - [FieldOffset(8)] internal ulong SupportedOperations; + [FieldOffset(8)] internal ulong SupportedOperations; - [FieldOffset(16)] internal uint ExportTableSize; + [FieldOffset(16)] internal uint ExportTableSize; - [FieldOffset(20)] internal ushort AbiMajor; + [FieldOffset(20)] internal ushort AbiMajor; - [FieldOffset(22)] internal ushort AbiMinor; + [FieldOffset(22)] internal ushort AbiMinor; - [FieldOffset(24)] internal byte PointerSize; + [FieldOffset(24)] internal byte PointerSize; - [FieldOffset(25)] internal byte LuaIntegerSize; + [FieldOffset(25)] internal byte LuaIntegerSize; - [FieldOffset(26)] internal byte SizeTSize; + [FieldOffset(26)] internal byte SizeTSize; - [FieldOffset(27)] internal byte Reserved; + [FieldOffset(27)] internal byte Reserved; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal readonly bool IsCompatible() - { - return Magic == ExpectedMagic && - ContractSize == (uint)Unsafe.SizeOf() && - AbiMajor == ExpectedMajor && - AbiMinor >= MinimumMinor && - PointerSize == (byte)lua_KContext.Size && - LuaIntegerSize == (byte)Unsafe.SizeOf() && - SizeTSize == (byte)Unsafe.SizeOf() && - ExportTableSize == (uint)Unsafe.SizeOf() && - Reserved == 0 && - (SupportedOperations & LuaProtectedOperationContract.RequiredBitmap) == - LuaProtectedOperationContract.RequiredBitmap; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly bool IsCompatible() + { + return Magic == ExpectedMagic && + ContractSize == (uint) Unsafe.SizeOf() && + AbiMajor == ExpectedMajor && + AbiMinor >= MinimumMinor && + PointerSize == (byte) lua_KContext.Size && + LuaIntegerSize == (byte) Unsafe.SizeOf() && + SizeTSize == (byte) Unsafe.SizeOf() && + ExportTableSize == (uint) Unsafe.SizeOf() && + Reserved == 0 && + (SupportedOperations & LuaProtectedOperationContract.RequiredBitmap) == + LuaProtectedOperationContract.RequiredBitmap; + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedApi.cs b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedApi.cs index 91716c81..f36c785f 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedApi.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedApi.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Types; @@ -11,312 +12,373 @@ namespace CheatEngine.SDK.Lua.Interop.Protected; // The bridge is intentionally internal. Higher layers own the synchronization and stack contracts; // this type only transfers one operation through Lua's native protected boundary. [SuppressMessage("Meziantou.Analyzer", "MA0182", - Justification = - "CheatEngine.SDK.Lua consumes this internal bridge through InternalsVisibleTo; the analyzer does not follow that cross-assembly use.")] + Justification = + "CheatEngine.SDK.Lua consumes this internal bridge through InternalsVisibleTo; the analyzer does not follow that cross-assembly use.")] internal static unsafe partial class LuaProtectedApi { - // Native bridge could not reserve even its one closure slot; unlike Lua statuses, no error object was pushed. - internal const int NoErrorStatus = -100; - private const string BridgeLibrary = "cheatengine-sdk-lua-bridge"; - - private static readonly string[] s_requiredBridgeExports = - [ - "cheatengine_sdk_lua_protected", - "cheatengine_sdk_lua_bridge_abi_version", - "cheatengine_sdk_lua_bridge_get_contract", - "cheatengine_sdk_lua_bridge_source_fingerprint", - ]; - - private static readonly Lock s_gate = new(); - private static BridgeBinding? s_binding; - - internal static int PushBytes(lua_State* state, ReadOnlySpan bytes) - { - fixed (byte* data = bytes) - { - return Invoke(state, LuaProtectedOperation.PushBytes, 0, data, (nuint)bytes.Length, - 0, 0); - } - } - - internal static int PushByteTable(lua_State* state, ReadOnlySpan bytes) - { - fixed (byte* data = bytes) - { - return Invoke(state, LuaProtectedOperation.PushByteTable, 0, data, (nuint)bytes.Length, - 0, 0); - } - } - - internal static int CreateTable(lua_State* state, int arrayCapacity, int recordCapacity) - { - ArgumentOutOfRangeException.ThrowIfNegative(arrayCapacity); - ArgumentOutOfRangeException.ThrowIfNegative(recordCapacity); - return Invoke(state, LuaProtectedOperation.CreateTable, 0, data: null, 0, - arrayCapacity, recordCapacity); - } - - internal static int NewUserdata(lua_State* state, nuint bytes) - { - return Invoke(state, LuaProtectedOperation.NewUserdata, 0, data: null, bytes, 0, - 0); - } - - internal static int PushClosure(lua_State* state, nint function, int upvalues) - { - if (function == 0) - throw new ArgumentException("The Lua C function must not be zero.", nameof(function)); - if ((uint)upvalues > byte.MaxValue) - throw new ArgumentOutOfRangeException(nameof(upvalues), - "Lua 5.3 supports between zero and 255 closure upvalues."); - - return Invoke(state, LuaProtectedOperation.PushClosure, upvalues, (void*)function, 0, - upvalues, 0); - } - - internal static int PushHostObject(lua_State* state, nint hostObjectPusher, nint nativeObject) - { - if (hostObjectPusher == 0) - throw new ArgumentException("The host-object pusher must not be zero.", nameof(hostObjectPusher)); - - return Invoke(state, LuaProtectedOperation.PushHostObject, 0, (void*)hostObjectPusher, - 0, nativeObject, 0); - } - - internal static int RawSet(lua_State* state, int tableIndex) - { - var binding = ValidateInvocation(state, 2); - var top = PushTableBeforeInputs(state, tableIndex, 2); - try - { - return Invoke(binding, state, LuaProtectedOperation.RawSet, 3, data: null, 0, 1, 0); - } - catch (Exception) - { - RollbackTableInput(state, top, 2); - throw; - } - } - - internal static int RawSetI(lua_State* state, int tableIndex, lua_Integer key) - { - var binding = ValidateInvocation(state, 1); - var top = PushTableBeforeInputs(state, tableIndex, 1); - try - { - return Invoke(binding, state, LuaProtectedOperation.RawSetIndex, 2, data: null, 0, 1, (nint)key); - } - catch (Exception) - { - RollbackTableInput(state, top, 1); - throw; - } - } - - internal static int RawSetP(lua_State* state, int tableIndex, nint key) - { - var binding = ValidateInvocation(state, 1); - var top = PushTableBeforeInputs(state, tableIndex, 1); - try - { - return Invoke(binding, state, LuaProtectedOperation.RawSetPointer, 2, (void*)key, 0, 1, 0); - } - catch (Exception) - { - RollbackTableInput(state, top, 1); - throw; - } - } - - internal static int TryCreatePrivateRef(lua_State* state, nint stableKey, out int reference) - { - var status = Invoke(state, LuaProtectedOperation.CreateReference, 1, GetPrivateReferenceKey(stableKey), 0, 0, - 0); - if (status == LuaApi.LUA_OK) - { - reference = checked((int)LuaApi.lua_tointegerx(state, -1, null)); - LuaApi.lua_settop(state, -2); - } - else - { - reference = LuaApi.LUA_NOREF; - } - - return status; - } - - internal static int PushPrivateRef(lua_State* state, nint stableKey, int reference) - { - return Invoke(state, LuaProtectedOperation.PushReference, 0, GetPrivateReferenceKey(stableKey), 0, reference, - 0); - } - - internal static int UnrefPrivate(lua_State* state, nint stableKey, int reference) - { - return Invoke(state, LuaProtectedOperation.ReleaseReference, 0, GetPrivateReferenceKey(stableKey), 0, reference, - 0); - } - - private static int Invoke(lua_State* state, LuaProtectedOperation operation, int inputCount, void* data, nuint size, - nint first, nint second) - { - var binding = ValidateInvocation(state, inputCount); - return Invoke(binding, state, operation, inputCount, data, size, first, second); - } - - private static int Invoke(BridgeBinding binding, lua_State* state, LuaProtectedOperation operation, int inputCount, - void* data, nuint size, nint first, nint second) - { - if (!LuaProtectedOperationContract.IsDefined(operation)) - throw new ArgumentOutOfRangeException(nameof(operation)); - - var exports = binding.Exports; - var status = - cheatengine_sdk_lua_protected(state, in exports, (int)operation, inputCount, data, size, first, second); - if (status == NoErrorStatus) - throw new InvalidOperationException( - "Lua could not reserve a stack slot for the protected operation; the stack is unchanged."); - return status; - } - - private static BridgeBinding ValidateInvocation(lua_State* state, int inputCount) - { - if (state is null) - throw new ArgumentNullException(nameof(state)); - ArgumentOutOfRangeException.ThrowIfNegative(inputCount); - - var binding = EnsureLoaded(); - if (inputCount > LuaApi.lua_gettop(state)) - throw new InvalidOperationException( - "The Lua stack does not contain the inputs required by the protected operation."); - return binding; - } - - private static int PushTableBeforeInputs(lua_State* state, int tableIndex, int inputCount) - { - var top = LuaApi.lua_gettop(state); - var tableValueIndex = GetValidTableIndex(tableIndex, top); - if (LuaApi.lua_checkstack(state, 2) == 0) - throw new InvalidOperationException( - "Lua could not reserve stack slots for the protected operation; the stack is unchanged."); - // A closure upvalue is a valid Lua table index, but the protected bridge is a separate C closure and cannot - // address the caller's upvalues itself. Copy it onto the ordinary stack before entering that bridge. - LuaApi.lua_pushvalue(state, tableValueIndex); - LuaApi.lua_rotate(state, -(inputCount + 1), 1); - return top; - } - - private static void RollbackTableInput(lua_State* state, int top, int inputCount) - { - // [.. table key value] -> [.. key value table] -> [.. key value]. - LuaApi.lua_rotate(state, -(inputCount + 1), -1); - LuaApi.lua_settop(state, top); - } - - private static int GetValidTableIndex(int tableIndex, int top) - { - if (tableIndex == LuaApi.LUA_REGISTRYINDEX) return tableIndex; - if (tableIndex < LuaApi.LUA_REGISTRYINDEX) - { - var upvalue = (long)LuaApi.LUA_REGISTRYINDEX - tableIndex; - if (upvalue is >= 1 and <= byte.MaxValue) return tableIndex; - - throw new ArgumentOutOfRangeException(nameof(tableIndex), - "The table index is not a valid Lua closure upvalue."); - } - - if (tableIndex == 0) - throw new ArgumentOutOfRangeException(nameof(tableIndex), - "The table index must be a valid stack index, LUA_REGISTRYINDEX, or a closure upvalue."); - - var absoluteIndex = tableIndex > 0 ? tableIndex : (long)top + tableIndex + 1; - if (absoluteIndex < 1 || absoluteIndex > top) - throw new ArgumentOutOfRangeException(nameof(tableIndex), - "The table index is outside the current Lua stack."); - return (int)absoluteIndex; - } - - private static void* GetPrivateReferenceKey(nint stableKey) - { - if (stableKey == 0) - throw new ArgumentException("The private-reference key must not be zero.", nameof(stableKey)); - return (void*)stableKey; - } - - private static BridgeBinding EnsureLoaded() - { - var module = LuaApi.ModuleHandle; - if (module == 0) throw new InvalidOperationException("Bind LuaApi before invoking protected Lua operations."); - var binding = Volatile.Read(ref s_binding); - if (binding?.Module == module) return binding; - lock (s_gate) - { - binding = s_binding; - if (binding?.Module == module) return binding; - ValidateBridgeContract(); - ValidateRequiredExports(); - binding = new BridgeBinding(module, LuaProtectedExports.Create(module)); - Volatile.Write(ref s_binding, binding); - return binding; - } - } - - [LibraryImport(BridgeLibrary, EntryPoint = "cheatengine_sdk_lua_protected")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int cheatengine_sdk_lua_protected(lua_State* state, in LuaProtectedExports exports, - int operation, int inputCount, void* data, nuint size, nint first, nint second); - - [LibraryImport(BridgeLibrary, EntryPoint = "cheatengine_sdk_lua_bridge_get_contract")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int cheatengine_sdk_lua_bridge_get_contract(out LuaBridgeContract contract, - nuint contractSize); - - [LibraryImport(BridgeLibrary, EntryPoint = "cheatengine_sdk_lua_bridge_abi_version")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial uint cheatengine_sdk_lua_bridge_abi_version(); - - private static void ValidateBridgeContract() - { - LuaBridgeContract contract; - try - { - if (cheatengine_sdk_lua_bridge_abi_version() != LuaBridgeContract.ExpectedLegacyAbiVersion) - throw new InvalidOperationException( - "cheatengine-sdk-lua-bridge.dll has an incompatible legacy ABI version."); - - if (cheatengine_sdk_lua_bridge_get_contract(out contract, (nuint)Unsafe.SizeOf()) == 0) - throw new InvalidOperationException( - "cheatengine-sdk-lua-bridge.dll rejected the managed contract buffer."); - } - catch (EntryPointNotFoundException exception) - { - throw new InvalidOperationException( - "cheatengine-sdk-lua-bridge.dll is outdated: the versioned contract export is missing. Restore or rebuild the matching CheatEngine.SDK package.", - exception); - } - - if (!contract.IsCompatible()) - throw new InvalidOperationException( - "cheatengine-sdk-lua-bridge.dll has an incompatible ABI contract. Restore or rebuild the matching CheatEngine.SDK package."); - } - - private static void ValidateRequiredExports() - { - nint module = 0; - try - { - module = NativeLibrary.Load( - BridgeLibrary, - typeof(LuaProtectedApi).Assembly, - DllImportSearchPath.AssemblyDirectory); - for (var i = 0; i < s_requiredBridgeExports.Length; i++) - if (!NativeLibrary.TryGetExport(module, s_requiredBridgeExports[i], out _)) - throw new InvalidOperationException( - $"cheatengine-sdk-lua-bridge.dll is missing required export '{s_requiredBridgeExports[i]}'. Restore or rebuild the matching CheatEngine.SDK package."); - } - finally - { - if (module != 0) NativeLibrary.Free(module); - } - } + // Native bridge could not reserve even its one closure slot; unlike Lua statuses, no error object was pushed. + internal const int NoErrorStatus = -100; + private const string BridgeLibrary = "cheatengine-sdk-lua-bridge"; + + private static readonly string[] s_requiredBridgeExports = + [ + "cheatengine_sdk_lua_protected", + "cheatengine_sdk_lua_bridge_abi_version", + "cheatengine_sdk_lua_bridge_get_contract", + "cheatengine_sdk_lua_bridge_source_fingerprint" + ]; + + private static readonly Lock s_gate = new(); + private static BridgeBinding? s_binding; + + internal static int PushBytes(lua_State* state, ReadOnlySpan bytes) + { + fixed (byte* data = bytes) + { + return Invoke(state, LuaProtectedOperation.PushBytes, 0, data, (nuint) bytes.Length, + 0, 0); + } + } + + internal static int PushByteTable(lua_State* state, ReadOnlySpan bytes) + { + fixed (byte* data = bytes) + { + return Invoke(state, LuaProtectedOperation.PushByteTable, 0, data, (nuint) bytes.Length, + 0, 0); + } + } + + internal static int CreateTable(lua_State* state, int arrayCapacity, int recordCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegative(arrayCapacity); + ArgumentOutOfRangeException.ThrowIfNegative(recordCapacity); + return Invoke(state, LuaProtectedOperation.CreateTable, 0, null, 0, + arrayCapacity, recordCapacity); + } + + internal static int NewUserdata(lua_State* state, nuint bytes) + { + return Invoke(state, LuaProtectedOperation.NewUserdata, 0, null, bytes, 0, + 0); + } + + internal static int PushClosure(lua_State* state, nint function, int upvalues) + { + if (function == 0) + { + throw new ArgumentException("The Lua C function must not be zero.", nameof(function)); + } + + if ((uint) upvalues > byte.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(upvalues), + "Lua 5.3 supports between zero and 255 closure upvalues."); + } + + return Invoke(state, LuaProtectedOperation.PushClosure, upvalues, (void*) function, 0, + upvalues, 0); + } + + internal static int PushHostObject(lua_State* state, nint hostObjectPusher, nint nativeObject) + { + if (hostObjectPusher == 0) + { + throw new ArgumentException("The host-object pusher must not be zero.", nameof(hostObjectPusher)); + } + + return Invoke(state, LuaProtectedOperation.PushHostObject, 0, (void*) hostObjectPusher, + 0, nativeObject, 0); + } + + internal static int RawSet(lua_State* state, int tableIndex) + { + BridgeBinding binding = ValidateInvocation(state, 2); + int top = PushTableBeforeInputs(state, tableIndex, 2); + try + { + return Invoke(binding, state, LuaProtectedOperation.RawSet, 3, null, 0, 1, 0); + } + catch (Exception) + { + RollbackTableInput(state, top, 2); + throw; + } + } + + internal static int RawSetI(lua_State* state, int tableIndex, lua_Integer key) + { + BridgeBinding binding = ValidateInvocation(state, 1); + int top = PushTableBeforeInputs(state, tableIndex, 1); + try + { + return Invoke(binding, state, LuaProtectedOperation.RawSetIndex, 2, null, 0, 1, (nint) key); + } + catch (Exception) + { + RollbackTableInput(state, top, 1); + throw; + } + } + + internal static int RawSetP(lua_State* state, int tableIndex, nint key) + { + BridgeBinding binding = ValidateInvocation(state, 1); + int top = PushTableBeforeInputs(state, tableIndex, 1); + try + { + return Invoke(binding, state, LuaProtectedOperation.RawSetPointer, 2, (void*) key, 0, 1, 0); + } + catch (Exception) + { + RollbackTableInput(state, top, 1); + throw; + } + } + + internal static int TryCreatePrivateRef(lua_State* state, nint stableKey, out int reference) + { + int status = Invoke(state, LuaProtectedOperation.CreateReference, 1, GetPrivateReferenceKey(stableKey), 0, 0, + 0); + if (status == LuaApi.LUA_OK) + { + reference = checked((int) LuaApi.lua_tointegerx(state, -1, null)); + LuaApi.lua_settop(state, -2); + } + else + { + reference = LuaApi.LUA_NOREF; + } + + return status; + } + + internal static int PushPrivateRef(lua_State* state, nint stableKey, int reference) + { + return Invoke(state, LuaProtectedOperation.PushReference, 0, GetPrivateReferenceKey(stableKey), 0, reference, + 0); + } + + internal static int UnrefPrivate(lua_State* state, nint stableKey, int reference) + { + return Invoke(state, LuaProtectedOperation.ReleaseReference, 0, GetPrivateReferenceKey(stableKey), 0, reference, + 0); + } + + private static int Invoke(lua_State* state, LuaProtectedOperation operation, int inputCount, void* data, nuint size, + nint first, nint second) + { + BridgeBinding binding = ValidateInvocation(state, inputCount); + return Invoke(binding, state, operation, inputCount, data, size, first, second); + } + + private static int Invoke(BridgeBinding binding, lua_State* state, LuaProtectedOperation operation, int inputCount, + void* data, nuint size, nint first, nint second) + { + if (!LuaProtectedOperationContract.IsDefined(operation)) + { + throw new ArgumentOutOfRangeException(nameof(operation)); + } + + LuaProtectedExports exports = binding.Exports; + int status = + cheatengine_sdk_lua_protected(state, in exports, (int) operation, inputCount, data, size, first, second); + if (status == NoErrorStatus) + { + throw new InvalidOperationException( + "Lua could not reserve a stack slot for the protected operation; the stack is unchanged."); + } + + return status; + } + + private static BridgeBinding ValidateInvocation(lua_State* state, int inputCount) + { + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(inputCount); + + BridgeBinding binding = EnsureLoaded(); + if (inputCount > LuaApi.lua_gettop(state)) + { + throw new InvalidOperationException( + "The Lua stack does not contain the inputs required by the protected operation."); + } + + return binding; + } + + private static int PushTableBeforeInputs(lua_State* state, int tableIndex, int inputCount) + { + int top = LuaApi.lua_gettop(state); + int tableValueIndex = GetValidTableIndex(tableIndex, top); + if (LuaApi.lua_checkstack(state, 2) == 0) + { + throw new InvalidOperationException( + "Lua could not reserve stack slots for the protected operation; the stack is unchanged."); + } + + // A closure upvalue is a valid Lua table index, but the protected bridge is a separate C closure and cannot + // address the caller's upvalues itself. Copy it onto the ordinary stack before entering that bridge. + LuaApi.lua_pushvalue(state, tableValueIndex); + LuaApi.lua_rotate(state, -(inputCount + 1), 1); + return top; + } + + private static void RollbackTableInput(lua_State* state, int top, int inputCount) + { + // [.. table key value] -> [.. key value table] -> [.. key value]. + LuaApi.lua_rotate(state, -(inputCount + 1), -1); + LuaApi.lua_settop(state, top); + } + + private static int GetValidTableIndex(int tableIndex, int top) + { + if (tableIndex == LuaApi.LUA_REGISTRYINDEX) + { + return tableIndex; + } + + if (tableIndex < LuaApi.LUA_REGISTRYINDEX) + { + long upvalue = (long) LuaApi.LUA_REGISTRYINDEX - tableIndex; + if (upvalue is >= 1 and <= byte.MaxValue) + { + return tableIndex; + } + + throw new ArgumentOutOfRangeException(nameof(tableIndex), + "The table index is not a valid Lua closure upvalue."); + } + + if (tableIndex == 0) + { + throw new ArgumentOutOfRangeException(nameof(tableIndex), + "The table index must be a valid stack index, LUA_REGISTRYINDEX, or a closure upvalue."); + } + + long absoluteIndex = tableIndex > 0 ? tableIndex : (long) top + tableIndex + 1; + if (absoluteIndex < 1 || absoluteIndex > top) + { + throw new ArgumentOutOfRangeException(nameof(tableIndex), + "The table index is outside the current Lua stack."); + } + + return (int) absoluteIndex; + } + + private static void* GetPrivateReferenceKey(nint stableKey) + { + if (stableKey == 0) + { + throw new ArgumentException("The private-reference key must not be zero.", nameof(stableKey)); + } + + return (void*) stableKey; + } + + private static BridgeBinding EnsureLoaded() + { + IntPtr module = LuaApi.ModuleHandle; + if (module == 0) + { + throw new InvalidOperationException("Bind LuaApi before invoking protected Lua operations."); + } + + BridgeBinding? binding = Volatile.Read(ref s_binding); + if (binding?.Module == module) + { + return binding; + } + + lock (s_gate) + { + binding = s_binding; + if (binding?.Module == module) + { + return binding; + } + + ValidateBridgeContract(); + ValidateRequiredExports(); + binding = new BridgeBinding(module, LuaProtectedExports.Create(module)); + Volatile.Write(ref s_binding, binding); + return binding; + } + } + + [LibraryImport(BridgeLibrary, EntryPoint = "cheatengine_sdk_lua_protected")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial int cheatengine_sdk_lua_protected(lua_State* state, in LuaProtectedExports exports, + int operation, int inputCount, void* data, nuint size, nint first, nint second); + + [LibraryImport(BridgeLibrary, EntryPoint = "cheatengine_sdk_lua_bridge_get_contract")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial int cheatengine_sdk_lua_bridge_get_contract(out LuaBridgeContract contract, + nuint contractSize); + + [LibraryImport(BridgeLibrary, EntryPoint = "cheatengine_sdk_lua_bridge_abi_version")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial uint cheatengine_sdk_lua_bridge_abi_version(); + + private static void ValidateBridgeContract() + { + LuaBridgeContract contract; + try + { + if (cheatengine_sdk_lua_bridge_abi_version() != LuaBridgeContract.ExpectedLegacyAbiVersion) + { + throw new InvalidOperationException( + "cheatengine-sdk-lua-bridge.dll has an incompatible legacy ABI version."); + } + + if (cheatengine_sdk_lua_bridge_get_contract(out contract, (nuint) Unsafe.SizeOf()) == 0) + { + throw new InvalidOperationException( + "cheatengine-sdk-lua-bridge.dll rejected the managed contract buffer."); + } + } + catch (EntryPointNotFoundException exception) + { + throw new InvalidOperationException( + "cheatengine-sdk-lua-bridge.dll is outdated: the versioned contract export is missing. Restore or rebuild the matching CheatEngine.SDK package.", + exception); + } + + if (!contract.IsCompatible()) + { + throw new InvalidOperationException( + "cheatengine-sdk-lua-bridge.dll has an incompatible ABI contract. Restore or rebuild the matching CheatEngine.SDK package."); + } + } + + private static void ValidateRequiredExports() + { + nint module = 0; + try + { + module = NativeLibrary.Load( + BridgeLibrary, + typeof(LuaProtectedApi).Assembly, + DllImportSearchPath.AssemblyDirectory); + for (int i = 0; i < s_requiredBridgeExports.Length; i++) + { + if (!NativeLibrary.TryGetExport(module, s_requiredBridgeExports[i], out _)) + { + throw new InvalidOperationException( + $"cheatengine-sdk-lua-bridge.dll is missing required export '{s_requiredBridgeExports[i]}'. Restore or rebuild the matching CheatEngine.SDK package."); + } + } + } + finally + { + if (module != 0) + { + NativeLibrary.Free(module); + } + } + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs index 6f087fd4..f405541d 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs @@ -5,53 +5,53 @@ namespace CheatEngine.SDK.Lua.Interop.Protected; [StructLayout(LayoutKind.Sequential, Pack = 8)] internal readonly struct LuaProtectedExports { - private readonly nint _getTop, - _setTop, - _checkStack, - _rotate, - _pushLString, - _pushInteger, - _createTable, - _newUserdata, - _pushClosure, - _pushLightUserdata, - _rawSet, - _rawSetI, - _rawSetP, - _rawGetP, - _rawGetI, - _type, - _pCallK, - _error, - _lRef, - _lUnref; + private readonly nint _getTop, + _setTop, + _checkStack, + _rotate, + _pushLString, + _pushInteger, + _createTable, + _newUserdata, + _pushClosure, + _pushLightUserdata, + _rawSet, + _rawSetI, + _rawSetP, + _rawGetP, + _rawGetI, + _type, + _pCallK, + _error, + _lRef, + _lUnref; - private LuaProtectedExports(nint getTop, nint setTop, nint checkStack, nint rotate, nint pushLString, - nint pushInteger, nint createTable, nint newUserdata, nint pushClosure, nint pushLightUserdata, nint rawSet, - nint rawSetI, nint rawSetP, nint rawGetP, nint rawGetI, nint type, nint pCallK, nint error, nint lRef, - nint lUnref) - { - (_getTop, _setTop, _checkStack, _rotate, _pushLString, _pushInteger, _createTable, _newUserdata, _pushClosure, - _pushLightUserdata, _rawSet, _rawSetI, _rawSetP, _rawGetP, _rawGetI, _type, _pCallK, _error, _lRef, - _lUnref) = (getTop, setTop, checkStack, rotate, pushLString, pushInteger, createTable, newUserdata, - pushClosure, pushLightUserdata, rawSet, rawSetI, rawSetP, rawGetP, rawGetI, type, pCallK, error, lRef, - lUnref); - } + private LuaProtectedExports(nint getTop, nint setTop, nint checkStack, nint rotate, nint pushLString, + nint pushInteger, nint createTable, nint newUserdata, nint pushClosure, nint pushLightUserdata, nint rawSet, + nint rawSetI, nint rawSetP, nint rawGetP, nint rawGetI, nint type, nint pCallK, nint error, nint lRef, + nint lUnref) + { + (_getTop, _setTop, _checkStack, _rotate, _pushLString, _pushInteger, _createTable, _newUserdata, _pushClosure, + _pushLightUserdata, _rawSet, _rawSetI, _rawSetP, _rawGetP, _rawGetI, _type, _pCallK, _error, _lRef, + _lUnref) = (getTop, setTop, checkStack, rotate, pushLString, pushInteger, createTable, newUserdata, + pushClosure, pushLightUserdata, rawSet, rawSetI, rawSetP, rawGetP, rawGetI, type, pCallK, error, lRef, + lUnref); + } - internal static LuaProtectedExports Create(nint module) - { - return new LuaProtectedExports(Export(module, "lua_gettop"), Export(module, "lua_settop"), - Export(module, "lua_checkstack"), - Export(module, "lua_rotate"), Export(module, "lua_pushlstring"), Export(module, "lua_pushinteger"), - Export(module, "lua_createtable"), Export(module, "lua_newuserdata"), Export(module, "lua_pushcclosure"), - Export(module, "lua_pushlightuserdata"), Export(module, "lua_rawset"), Export(module, "lua_rawseti"), - Export(module, "lua_rawsetp"), Export(module, "lua_rawgetp"), Export(module, "lua_rawgeti"), - Export(module, "lua_type"), Export(module, "lua_pcallk"), Export(module, "lua_error"), - Export(module, "luaL_ref"), Export(module, "luaL_unref")); - } + internal static LuaProtectedExports Create(nint module) + { + return new LuaProtectedExports(Export(module, "lua_gettop"), Export(module, "lua_settop"), + Export(module, "lua_checkstack"), + Export(module, "lua_rotate"), Export(module, "lua_pushlstring"), Export(module, "lua_pushinteger"), + Export(module, "lua_createtable"), Export(module, "lua_newuserdata"), Export(module, "lua_pushcclosure"), + Export(module, "lua_pushlightuserdata"), Export(module, "lua_rawset"), Export(module, "lua_rawseti"), + Export(module, "lua_rawsetp"), Export(module, "lua_rawgetp"), Export(module, "lua_rawgeti"), + Export(module, "lua_type"), Export(module, "lua_pcallk"), Export(module, "lua_error"), + Export(module, "luaL_ref"), Export(module, "luaL_unref")); + } - private static nint Export(nint module, string name) - { - return NativeLibrary.GetExport(module, name); - } + private static nint Export(nint module, string name) + { + return NativeLibrary.GetExport(module, name); + } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Types/luaL_Reg.cs b/libs/CheatEngine.SDK.Lua.Interop/Types/luaL_Reg.cs index a1cbc811..f1514b26 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Types/luaL_Reg.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Types/luaL_Reg.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Api; namespace CheatEngine.SDK.Lua.Interop.Types; @@ -16,9 +17,9 @@ namespace CheatEngine.SDK.Lua.Interop.Types; [StructLayout(LayoutKind.Sequential)] public unsafe struct luaL_Reg { - /// NUL-terminated field name, null in the terminating entry. - public byte* name; + /// NUL-terminated field name, null in the terminating entry. + public byte* name; - /// The C function, null in the terminating entry. - public lua_CFunction func; + /// The C function, null in the terminating entry. + public lua_CFunction func; } diff --git a/libs/CheatEngine.SDK.Lua.Interop/Types/lua_Debug.cs b/libs/CheatEngine.SDK.Lua.Interop/Types/lua_Debug.cs index fc8d262c..e71e347b 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Types/lua_Debug.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Types/lua_Debug.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Api; namespace CheatEngine.SDK.Lua.Interop.Types; @@ -20,48 +21,48 @@ namespace CheatEngine.SDK.Lua.Interop.Types; [StructLayout(LayoutKind.Sequential)] public unsafe struct lua_Debug { - /// Hook event code (LUA_HOOK*); only meaningful inside a hook. - public int @event; + /// Hook event code (LUA_HOOK*); only meaningful inside a hook. + public int @event; - /// ("n") A plausible name for the function, or null when none can be found. - public byte* name; + /// ("n") A plausible name for the function, or null when none can be found. + public byte* name; - /// ("n") How was found: "global", "local", "method", "field", "upvalue" or "". - public byte* namewhat; + /// ("n") How was found: "global", "local", "method", "field", "upvalue" or "". + public byte* namewhat; - /// ("S") "Lua", "C" or "main". - public byte* what; + /// ("S") "Lua", "C" or "main". + public byte* what; - /// ("S") Chunk name: "@file", "=custom" or the source text itself. - public byte* source; + /// ("S") Chunk name: "@file", "=custom" or the source text itself. + public byte* source; - /// ("l") Line being executed, or -1 when unavailable. - public int currentline; + /// ("l") Line being executed, or -1 when unavailable. + public int currentline; - /// ("S") First line of the function definition. - public int linedefined; + /// ("S") First line of the function definition. + public int linedefined; - /// ("S") Last line of the function definition. - public int lastlinedefined; + /// ("S") Last line of the function definition. + public int lastlinedefined; - /// ("u") Number of upvalues. - public byte nups; + /// ("u") Number of upvalues. + public byte nups; - /// ("u") Number of fixed parameters (0 for C functions). - public byte nparams; + /// ("u") Number of fixed parameters (0 for C functions). + public byte nparams; - /// ("u") Non-zero when the function is vararg (always for C functions). - public sbyte isvararg; + /// ("u") Non-zero when the function is vararg (always for C functions). + public sbyte isvararg; - /// ("t") Non-zero when the function was entered through a tail call. - public sbyte istailcall; + /// ("t") Non-zero when the function was entered through a tail call. + public sbyte istailcall; - /// ("S") Printable, NUL-terminated form of for messages. - [SuppressMessage("Meziantou.Analyzer", "MA0189", - Justification = - "This fixed buffer is the public Lua 5.3 ABI field; replacing it with an InlineArray would change the C-shaped source contract.")] - public fixed byte short_src[LuaApi.LUA_IDSIZE]; + /// ("S") Printable, NUL-terminated form of for messages. + [SuppressMessage("Meziantou.Analyzer", "MA0189", + Justification = + "This fixed buffer is the public Lua 5.3 ABI field; replacing it with an InlineArray would change the C-shaped source contract.")] + public fixed byte short_src[LuaApi.LUA_IDSIZE]; - /// Private to Lua (the active CallInfo). Written by ; never touch it. - public void* i_ci; + /// Private to Lua (the active CallInfo). Written by ; never touch it. + public void* i_ci; } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs index 685d5eda..5f9217e3 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Protected; using CheatEngine.SDK.Lua.Interop.Types; @@ -9,6 +10,7 @@ using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Callbacks; @@ -52,297 +54,353 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// public abstract class LuaCallback : IDisposable { - // Deterministic publication-race seam used only by the SDK's friend test assembly. Callback creation is cold, so - // its volatile read is deliberately kept out of callback invocation hot paths. - internal static Action? BeforeRegistryAddForTesting; - - // Deterministic disposal-race seam used only by the SDK's friend test assembly. It runs after an atomic admission - // refusal is observed, outside the runtime gate, so tests can let a failed transition reopen admission first. - internal static Action? DisposeAdmissionRefusedForTesting; - - private readonly LuaRef _closure; - private readonly LuaStateIdentity _identity; - private readonly LuaRef _wrapped; - private GCHandle _handle; - private bool _released; - - private protected LuaCallback(GCHandle handle, LuaRef closure, LuaRef wrapped) - { - _handle = handle; - _closure = closure; - _wrapped = wrapped; - _identity = wrapped.Identity; - } - - /// - /// Gets a value indicating whether the callback has been released (explicitly, or by - /// ). - /// - public bool IsReleased => Volatile.Read(ref _released); - - /// - /// Gets a value indicating whether the callback can still be pushed: not released, and created in the current - /// . - /// - public bool IsCurrent => !IsReleased && _identity == LuaRuntime.CurrentStateIdentity && _wrapped.IsCurrent; - - /// Gets the managed state object, untyped; after release. - public object? StateObject - { - get - { - lock (LuaCallbackRegistry.Gate) - { - return IsReleased || !_handle.IsAllocated ? null : _handle.Target; - } - } - } - - internal LuaCallback? Next { get; set; } - - internal LuaCallback? Previous { get; set; } - - internal bool IsLinked { get; set; } - - /// - /// with the state acquired from . While an attached runtime is - /// closing admission, lifecycle cleanup retains ownership and neutralizes the closure; when it is detached this - /// method abandons the handle instead. - /// - public void Dispose() - { - var result = LuaRuntime.TryAcquireOperationForCallbackDispose(out var operation); - if (result == LuaRuntime.LuaCallbackDisposeOperationResult.Acquired) - { - using (operation) - { - Release(operation.State); - } - - GC.SuppressFinalize(this); - return; - } - - if (result == LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed) - { - // This result was observed atomically with the closed gate. A later failed Detach can reopen admission, - // but cannot make it safe to abandon the closure before a transition-owned state neutralizes its upvalue. - Volatile.Read(ref DisposeAdmissionRefusedForTesting)?.Invoke(); - GC.SuppressFinalize(this); - return; - } - - Release(default); - GC.SuppressFinalize(this); - } - - /// - /// Creates a callback: the C closure over with as its upvalue, - /// wrapped by the error-channel closure. Stack: +0 on success; +1 (the error value) on failure. - /// - /// - /// The state's type, a class; the thunk reads it back with - /// . - /// - /// The state to build on; the calling thread's. - /// The managed lua_CFunction. - /// The object to carry; kept alive by the callback until release. - /// The callback on success; on failure. - /// The status of installing the helpers or running the wrapper; on success. - /// is the null function. - /// is . - /// - /// Allocates the handle, two s, the callback object and, inside Lua, two closures and two - /// registry slots: a registration-time cost, never per call. - /// - public static LuaStatus TryCreate(LuaState state, LuaNativeFunction thunk, TState stateObject, - out LuaCallback? callback) - where TState : class - { - if (thunk.IsNull) throw new ArgumentException("The thunk is the null function.", nameof(thunk)); - - ArgumentNullException.ThrowIfNull(stateObject); - using var operation = LuaRuntime.EnterStateOperation(state); - return TryCreateCore(state, thunk, stateObject, out callback); - } - - private static unsafe LuaStatus TryCreateCore(LuaState state, LuaNativeFunction thunk, TState stateObject, - out LuaCallback? callback) - where TState : class - { - callback = null; - var l = state.Pointer; - var top = state.Top; - - GCHandle handle = new(stateObject); - LuaRef? closure = null; - LuaRef? wrapped = null; - var transferred = false; - try - { - // Upvalue 1 remains the user state consumed by LuaThunk.TryGetState. Upvalue 2 is the original user - // callback pointer; the SDK-owned dispatcher holds the lifecycle lease around that native call. - lua_pushlightuserdata(l, (void*)GCHandle.ToIntPtr(handle)); - lua_pushlightuserdata(l, (void*)thunk.Address); - var status = new LuaStatus(LuaProtectedApi.PushClosure(l, - (nint)(delegate* unmanaged[Cdecl])&Dispatch, 2)); - if (!status.IsOk) return status; - - lua_pushvalue(l, -1); - status = state.TryCreateRef(out closure); - if (!status.IsOk) return state.KeepProtectedError(top, status); - - // [closure] -> [closure wrap] -> [wrap closure] -> [wrapped] - status = LuaHelpers.Push(l, LuaHelper.Wrap); - if (!status.IsOk) return state.KeepProtectedError(top, status); - - lua_rotate(l, -2, 1); - status = state.TryCall(1, 1); - if (!status.IsOk) return status; - - status = state.TryCreateRef(out wrapped); - if (!status.IsOk) return status; - - LuaCallback created = new(handle, closure!, wrapped!); - Volatile.Read(ref BeforeRegistryAddForTesting)?.Invoke(); - LuaCallbackRegistry.Add(created); - callback = created; - transferred = true; - return LuaStatus.Ok; - } - finally - { - if (!transferred) - { - closure?.Release(state); - wrapped?.Release(state); - if (handle.IsAllocated) handle.Dispose(); - } - } - } - - /// - /// Pushes the Lua-callable function (the wrapped closure). Stack: +1 on , +0 when the - /// callback is released or stale. - /// - /// The state to push on; the calling thread's. - /// when the function was pushed. - public bool TryPush(LuaState state) - { - using var operation = LuaRuntime.EnterStateOperation(state); - return !IsReleased && state.TryPushRef(_wrapped); - } - - /// - /// Assigns the Lua-callable function to a global under protection (). - /// Stack: +0 on success, +1 (the error value) on failure. - /// - /// The state to register on; the calling thread's. - /// The global name, UTF-8. - /// - /// The status of the assignment; with a message on the stack when the - /// callback is released or stale ( is ), reported the same way as - /// any other failed protected operation rather than thrown. - /// - public LuaStatus TryRegister(LuaState state, ReadOnlySpan globalName) - { - using var operation = LuaRuntime.EnterStateOperation(state); - if (TryPush(state)) return state.TrySetGlobal(globalName); - var status = state.TryPushString( - "the callback has been released, or was created in an earlier host attachment or Lua state generation"u8); - return status.IsOk ? LuaStatus.RuntimeError : status; - } - - /// - /// Neutralizes the closure, frees the managed state and gives the registry slots back; see the type remarks for - /// what happens when the closure cannot be reached. Idempotent. - /// - /// - /// A state of the Lua universe the callback was created in; the calling thread's. May be - /// , which forces the abandon path. - /// - public void Release(LuaState state) - { - using var operation = state.IsNull ? default : LuaRuntime.EnterStateOperation(state); - lock (LuaCallbackRegistry.Gate) - { - ReleaseUnderGate(state); - } - } - - // Private so that Release, which holds the gate, is the only way in: the flag test, GCHandle.Dispose (not thread - // safe) and the unlink all depend on it. - private unsafe void ReleaseUnderGate(LuaState state) - { - if (_released) - { - LuaCallbackRegistry.Remove(this); - return; - } - - Volatile.Write(ref _released, true); - var neutralized = false; - if (!state.IsNull && state.TryPushRef(_closure)) - { - var l = state.Pointer; - lua_pushlightuserdata(l, null); - _ = lua_setupvalue(l, -2, 1); - lua_settop(l, -2); - neutralized = true; - } - - try - { - _closure.Release(state); - } - finally - { - try - { - _wrapped.Release(state); - } - finally - { - if (neutralized && _handle.IsAllocated) _handle.Dispose(); - LuaCallbackRegistry.Remove(this); - } - } - } - - // This is the only unmanaged entry point for stateful LuaCallback instances. The user thunk remains a cdecl - // function pointer stored in upvalue 2, while upvalue 1 deliberately retains the historical GCHandle - // contract consumed by LuaThunk.TryGetState. A callback that begins before Detach closes admission keeps a lease; - // a callback that begins after that boundary reports an ordinary Lua error and never enters plugin code. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int Dispatch(lua_State* pointer) - { - LuaState state = new(pointer); - try - { - // Release changes this exact upvalue to a null light userdata before it frees the GCHandle. Check it before - // admitting the thunk so a closure retained by Lua cannot run arbitrary plugin code after release. - if (lua_touserdata(pointer, lua_upvalueindex(1)) is null) - return LuaThunk.Fail(state, "callback released"u8); - - if (!LuaRuntime.TryEnterCallbackOperation(out var operation)) - return LuaThunk.Fail(state, "the Lua runtime is stopping"u8); - - try - { - var thunkAddress = (nint)lua_touserdata(pointer, lua_upvalueindex(2)); - if (thunkAddress == 0) return LuaThunk.Fail(state, "callback thunk is unavailable"u8); - - var thunk = (delegate* unmanaged[Cdecl])thunkAddress; - return thunk((nint)pointer); - } - finally - { - operation.Dispose(); - } - } - catch (Exception exception) - { - return LuaThunk.Fail(state, exception); - } - } + // Deterministic publication-race seam used only by the SDK's friend test assembly. Callback creation is cold, so + // its volatile read is deliberately kept out of callback invocation hot paths. + internal static Action? BeforeRegistryAddForTesting; + + // Deterministic disposal-race seam used only by the SDK's friend test assembly. It runs after an atomic admission + // refusal is observed, outside the runtime gate, so tests can let a failed transition reopen admission first. + internal static Action? DisposeAdmissionRefusedForTesting; + + private readonly LuaRef _closure; + private readonly LuaStateIdentity _identity; + private readonly LuaRef _wrapped; + private GCHandle _handle; + private bool _released; + + private protected LuaCallback(GCHandle handle, LuaRef closure, LuaRef wrapped) + { + _handle = handle; + _closure = closure; + _wrapped = wrapped; + _identity = wrapped.Identity; + } + + /// + /// Gets a value indicating whether the callback has been released (explicitly, or by + /// ). + /// + public bool IsReleased => Volatile.Read(ref _released); + + /// + /// Gets a value indicating whether the callback can still be pushed: not released, and created in the current + /// . + /// + public bool IsCurrent => !IsReleased && _identity == LuaRuntime.CurrentStateIdentity && _wrapped.IsCurrent; + + /// Gets the managed state object, untyped; after release. + public object? StateObject + { + get + { + lock (LuaCallbackRegistry.Gate) + { + return IsReleased || !_handle.IsAllocated ? null : _handle.Target; + } + } + } + + internal LuaCallback? Next + { + get; + set; + } + + internal LuaCallback? Previous + { + get; + set; + } + + internal bool IsLinked + { + get; + set; + } + + /// + /// with the state acquired from . While an attached runtime is + /// closing admission, lifecycle cleanup retains ownership and neutralizes the closure; when it is detached this + /// method abandons the handle instead. + /// + public void Dispose() + { + LuaRuntime.LuaCallbackDisposeOperationResult result = + LuaRuntime.TryAcquireOperationForCallbackDispose(out LuaRuntimeOperation operation); + if (result == LuaRuntime.LuaCallbackDisposeOperationResult.Acquired) + { + using (operation) + { + Release(operation.State); + } + + GC.SuppressFinalize(this); + return; + } + + if (result == LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed) + { + // This result was observed atomically with the closed gate. A later failed Detach can reopen admission, + // but cannot make it safe to abandon the closure before a transition-owned state neutralizes its upvalue. + Volatile.Read(ref DisposeAdmissionRefusedForTesting)?.Invoke(); + GC.SuppressFinalize(this); + return; + } + + Release(default); + GC.SuppressFinalize(this); + } + + /// + /// Creates a callback: the C closure over with as its upvalue, + /// wrapped by the error-channel closure. Stack: +0 on success; +1 (the error value) on failure. + /// + /// + /// The state's type, a class; the thunk reads it back with + /// . + /// + /// The state to build on; the calling thread's. + /// The managed lua_CFunction. + /// The object to carry; kept alive by the callback until release. + /// The callback on success; on failure. + /// The status of installing the helpers or running the wrapper; on success. + /// is the null function. + /// is . + /// + /// Allocates the handle, two s, the callback object and, inside Lua, two closures and two + /// registry slots: a registration-time cost, never per call. + /// + public static LuaStatus TryCreate(LuaState state, LuaNativeFunction thunk, TState stateObject, + out LuaCallback? callback) + where TState : class + { + if (thunk.IsNull) + { + throw new ArgumentException("The thunk is the null function.", nameof(thunk)); + } + + ArgumentNullException.ThrowIfNull(stateObject); + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(state); + return TryCreateCore(state, thunk, stateObject, out callback); + } + + private static unsafe LuaStatus TryCreateCore(LuaState state, LuaNativeFunction thunk, TState stateObject, + out LuaCallback? callback) + where TState : class + { + callback = null; + lua_State* l = state.Pointer; + int top = state.Top; + + GCHandle handle = new(stateObject); + LuaRef? closure = null; + LuaRef? wrapped = null; + bool transferred = false; + try + { + lua_pushlightuserdata(l, (void*) GCHandle.ToIntPtr(handle)); + lua_pushlightuserdata(l, (void*) thunk.Address); + LuaStatus status = new(LuaProtectedApi.PushClosure(l, + (nint) (delegate* unmanaged[Cdecl]) &Dispatch, 2)); + if (!status.IsOk) + { + return status; + } + + lua_pushvalue(l, -1); + status = state.TryCreateRef(out closure); + if (!status.IsOk) + { + return state.KeepProtectedError(top, status); + } + + status = TryCreateWrappedRef(state, l, top, out wrapped); + if (!status.IsOk) + { + return status; + } + + LuaCallback created = new(handle, closure!, wrapped!); + Volatile.Read(ref BeforeRegistryAddForTesting)?.Invoke(); + LuaCallbackRegistry.Add(created); + callback = created; + transferred = true; + return LuaStatus.Ok; + } + finally + { + if (!transferred) + { + closure?.Release(state); + wrapped?.Release(state); + if (handle.IsAllocated) + { + handle.Dispose(); + } + } + } + } + + private static unsafe LuaStatus TryCreateWrappedRef( + LuaState state, + lua_State* l, + int top, + out LuaRef? wrapped) + { + wrapped = null; + LuaStatus status = LuaHelpers.Push(l, LuaHelper.Wrap); + if (!status.IsOk) + { + return state.KeepProtectedError(top, status); + } + + lua_rotate(l, -2, 1); + status = state.TryCall(1, 1); + if (!status.IsOk) + { + return status; + } + + return state.TryCreateRef(out wrapped); + } + + /// + /// Pushes the Lua-callable function (the wrapped closure). Stack: +1 on , +0 when the + /// callback is released or stale. + /// + /// The state to push on; the calling thread's. + /// when the function was pushed. + public bool TryPush(LuaState state) + { + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(state); + return !IsReleased && state.TryPushRef(_wrapped); + } + + /// + /// Assigns the Lua-callable function to a global under protection (). + /// Stack: +0 on success, +1 (the error value) on failure. + /// + /// The state to register on; the calling thread's. + /// The global name, UTF-8. + /// + /// The status of the assignment; with a message on the stack when the + /// callback is released or stale ( is ), reported the same way as + /// any other failed protected operation rather than thrown. + /// + public LuaStatus TryRegister(LuaState state, ReadOnlySpan globalName) + { + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(state); + if (TryPush(state)) + { + return state.TrySetGlobal(globalName); + } + + LuaStatus status = state.TryPushString( + "the callback has been released, or was created in an earlier host attachment or Lua state generation"u8); + return status.IsOk ? LuaStatus.RuntimeError : status; + } + + /// + /// Neutralizes the closure, frees the managed state and gives the registry slots back; see the type remarks for + /// what happens when the closure cannot be reached. Idempotent. + /// + /// + /// A state of the Lua universe the callback was created in; the calling thread's. May be + /// , which forces the abandon path. + /// + public void Release(LuaState state) + { + using LuaRuntimeOperation operation = state.IsNull ? default : LuaRuntime.EnterStateOperation(state); + lock (LuaCallbackRegistry.Gate) + { + ReleaseUnderGate(state); + } + } + + // Private so that Release, which holds the gate, is the only way in: the flag test, GCHandle.Dispose (not thread + // safe) and the unlink all depend on it. + private unsafe void ReleaseUnderGate(LuaState state) + { + if (_released) + { + LuaCallbackRegistry.Remove(this); + return; + } + + Volatile.Write(ref _released, true); + bool neutralized = false; + if (!state.IsNull && state.TryPushRef(_closure)) + { + lua_State* l = state.Pointer; + lua_pushlightuserdata(l, null); + _ = lua_setupvalue(l, -2, 1); + lua_settop(l, -2); + neutralized = true; + } + + try + { + _closure.Release(state); + } + finally + { + try + { + _wrapped.Release(state); + } + finally + { + if (neutralized && _handle.IsAllocated) + { + _handle.Dispose(); + } + + LuaCallbackRegistry.Remove(this); + } + } + } + + // This is the only unmanaged entry point for stateful LuaCallback instances. The user thunk remains a cdecl + // function pointer stored in upvalue 2, while upvalue 1 deliberately retains the historical GCHandle + // contract consumed by LuaThunk.TryGetState. A callback that begins before Detach closes admission keeps a lease; + // a callback that begins after that boundary reports an ordinary Lua error and never enters plugin code. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int Dispatch(lua_State* pointer) + { + LuaState state = new(pointer); + try + { + // Release changes this exact upvalue to a null light userdata before it frees the GCHandle. Check it before + // admitting the thunk so a closure retained by Lua cannot run arbitrary plugin code after release. + if (lua_touserdata(pointer, lua_upvalueindex(1)) is null) + { + return LuaThunk.Fail(state, "callback released"u8); + } + + if (!LuaRuntime.TryEnterCallbackOperation(out LuaRuntimeOperation operation)) + { + return LuaThunk.Fail(state, "the Lua runtime is stopping"u8); + } + + try + { + IntPtr thunkAddress = (nint) lua_touserdata(pointer, lua_upvalueindex(2)); + if (thunkAddress == 0) + { + return LuaThunk.Fail(state, "callback thunk is unavailable"u8); + } + + delegate* unmanaged[Cdecl] thunk = (delegate* unmanaged[Cdecl]) thunkAddress; + return thunk((nint) pointer); + } + finally + { + operation.Dispose(); + } + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs index 3c001222..1b2ae7b5 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs @@ -1,5 +1,7 @@ using System; using System.Threading; + +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -12,86 +14,102 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// internal static class LuaCallbackRegistry { - private static LuaCallback? s_head; + private static LuaCallback? s_head; - // Deterministic cleanup-failure seam used only by the SDK's friend test assembly. It runs after a callback was - // fully released, so a thrown test exception leaves the remaining callbacks linked for a retry. - internal static Action? AfterReleaseForTesting; + // Deterministic cleanup-failure seam used only by the SDK's friend test assembly. It runs after a callback was + // fully released, so a thrown test exception leaves the remaining callbacks linked for a retry. + internal static Action? AfterReleaseForTesting; - /// Serializes callback list changes and handle release. - internal static Lock Gate { get; } = new(); + /// Serializes callback list changes and handle release. + internal static Lock Gate + { + get; + } = new(); - /// Number of live callbacks; for tests and diagnostics. - internal static int Count - { - get - { - lock (Gate) - { - var count = 0; - for (var current = s_head; current is not null; current = current.Next) count++; + /// Number of live callbacks; for tests and diagnostics. + internal static int Count + { + get + { + lock (Gate) + { + int count = 0; + for (LuaCallback? current = s_head; current is not null; current = current.Next) + { + count++; + } - return count; - } - } - } + return count; + } + } + } - internal static void Add(LuaCallback callback) - { - lock (Gate) - { - callback.Next = s_head; - s_head?.Previous = callback; + internal static void Add(LuaCallback callback) + { + lock (Gate) + { + callback.Next = s_head; + s_head?.Previous = callback; - s_head = callback; - callback.IsLinked = true; - } - } + s_head = callback; + callback.IsLinked = true; + } + } - /// - /// Unlinks , or does nothing when it is not linked. Takes the gate itself, and - /// is reentrant, so a caller that already holds it pays next to nothing. - /// - internal static void Remove(LuaCallback callback) - { - lock (Gate) - { - if (!callback.IsLinked) return; + /// + /// Unlinks , or does nothing when it is not linked. Takes the gate itself, and + /// is reentrant, so a caller that already holds it pays next to nothing. + /// + internal static void Remove(LuaCallback callback) + { + lock (Gate) + { + if (!callback.IsLinked) + { + return; + } - if (callback.Previous is null) - s_head = callback.Next; - else - callback.Previous.Next = callback.Next; + if (callback.Previous is null) + { + s_head = callback.Next; + } + else + { + callback.Previous.Next = callback.Next; + } - callback.Next?.Previous = callback.Previous; + callback.Next?.Previous = callback.Previous; - callback.Next = null; - callback.Previous = null; - callback.IsLinked = false; - } - } + callback.Next = null; + callback.Previous = null; + callback.IsLinked = false; + } + } - /// - /// Releases every live callback with a state acquired from on the calling thread. When - /// the provider yields no state the closures cannot be neutralized, and the callbacks are abandoned instead: - /// marked released with their managed state kept alive, which leaks but cannot crash. - /// - internal static unsafe void DetachAll(LuaHostServices services) - { - lock (Gate) - { - if (s_head is null) return; + /// + /// Releases every live callback with a state acquired from on the calling thread. When + /// the provider yields no state the closures cannot be neutralized, and the callbacks are abandoned instead: + /// marked released with their managed state kept alive, which leaks but cannot crash. + /// + internal static unsafe void DetachAll(LuaHostServices services) + { + lock (Gate) + { + if (s_head is null) + { + return; + } - var l = services.Provider(); - LuaState state = new(l); - // Release unlinks the head it is called on, so s_head is re-read on every iteration and the loop - // ends when the list is empty. Keep the explicit re-read: a "condition is always true" IDE quick-fix once - // turned this loop into while (true), which ended every Detach with a NullReferenceException. - for (var head = s_head; head is not null; head = s_head) - { - head.Release(state); - Volatile.Read(ref AfterReleaseForTesting)?.Invoke(); - } - } - } + lua_State* l = services.Provider(); + LuaState state = new(l); + // Release unlinks the head it is called on, so s_head is re-read on every iteration and the loop + // ends when the list is empty. Keep the explicit re-read: a "condition is always true" IDE quick-fix once + // turned this loop into while (true), which ended every Detach with a NullReferenceException. + for (LuaCallback? head = s_head; head is not null; head = s_head) + { + head.Release(state); + Volatile.Read(ref AfterReleaseForTesting)?.Invoke(); + } + } + } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback`1.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback`1.cs index d17224cd..9db8364c 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback`1.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback`1.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.References; namespace CheatEngine.SDK.Lua.Callbacks; @@ -9,13 +10,13 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// /// The state's type, a class. public sealed class LuaCallback : LuaCallback - where TState : class + where TState : class { - internal LuaCallback(GCHandle handle, LuaRef closure, LuaRef wrapped) - : base(handle, closure, wrapped) - { - } + internal LuaCallback(GCHandle handle, LuaRef closure, LuaRef wrapped) + : base(handle, closure, wrapped) + { + } - /// Gets the managed state object; after release. - public TState? State => StateObject as TState; + /// Gets the managed state object; after release. + public TState? State => StateObject as TState; } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs index dfb332ae..ba33fe99 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading; + using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -18,297 +19,347 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// internal sealed class LuaHostSubscription : IDisposable { - [ThreadStatic] private static List? t_dispatchStack; - - private readonly Lock _gate = new(); - private readonly ManualResetEventSlim _callbacksDrained = new(initialState: true); - private readonly ManualResetEventSlim _disposed = new(initialState: false); - private readonly LuaStateIdentity _identity; - - private Action? _callback; - private Action? _unregister; - private int _activeCallbacks; - private int _disposeStarted; - private bool _acceptCallbacks; - private bool _isDisposed; - - private LuaHostSubscription(Action callback, LuaStateIdentity identity) - { - _callback = callback; - _identity = identity; - } - - /// Gets the attachment and state generation that owns this registration. - internal LuaStateIdentity Identity => _identity; - - /// Gets whether teardown has consumed this host registration. - internal bool IsDisposed => Volatile.Read(ref _isDisposed); - - /// Gets the last managed handler failure, which is contained instead of reaching the host callback. - internal Exception? LastCallbackException { get; private set; } - - /// Gets the last unregister failure; ownership remains consumed and the action is never retried. - internal Exception? LastUnregisterException { get; private set; } - - // Deterministic drain seam used only by the SDK's friend test assembly. It runs in the individual owner drain and - // therefore exposes whether a registry caller kept its gate while asking that owner to wait for a callback. - internal static Action? CallbackDrainStartedForTesting; - - internal LuaHostSubscription? Next { get; set; } - - internal LuaHostSubscription? Previous { get; set; } - - internal bool IsLinked { get; set; } - - /// - /// Creates and publishes one owner transactionally. The registrar receives an inert callback and must return an - /// unregister action only after the host has accepted the registration. A return or a - /// thrown registrar must mean that no host subscription requiring cleanup was created. - /// - internal static bool TryRegister(LuaState state, Action callback, - Func?> registrar, out LuaHostSubscription? subscription) - { - ArgumentNullException.ThrowIfNull(callback); - ArgumentNullException.ThrowIfNull(registrar); - - subscription = null; - if (state.IsNull || !LuaRuntime.IsAttached) return false; - - using var operation = LuaRuntime.EnterStateOperation(state); - if (!LuaRuntime.IsAttached) return false; - - LuaHostSubscription candidate = new(callback, LuaRuntime.CurrentStateIdentity); - Action? unregister; - try - { - unregister = registrar(state, candidate.Dispatch); - } - catch - { - candidate.CancelUnregisteredRegistration(); - throw; - } - - if (unregister is null) - { - candidate.CancelUnregisteredRegistration(); - return false; - } - - candidate._unregister = unregister; - if (!LuaHostSubscriptionRegistry.TryAdd(candidate)) - { - candidate.ReleaseUnpublishedRegistration(state); - return false; - } - - subscription = candidate; - return true; - } - - /// - /// Releases this owner with a state borrowed from the active attachment. If a lifecycle transition has already - /// closed operation admission, its registry entry remains owned by that transition instead of attempting a raw - /// state access after teardown started. - /// - public void Dispose() - { - if (IsDispatchingOnCurrentThread()) - throw new InvalidOperationException( - "A host subscription cannot be disposed from its own callback; request teardown after the callback returns."); - - var result = LuaRuntime.TryAcquireOperationForCallbackDispose(out var operation); - if (result == LuaRuntime.LuaCallbackDisposeOperationResult.Acquired) - { - using (operation) - { - ReleaseWithState(operation.State); - } - - return; - } - - if (result == LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed) - { - // The lifecycle transition still owns the linked registration and has the only state allowed to unregister - // it. In particular, do not consume the action here: a failed transition can reopen the old binding. - return; - } - - AbandonWithoutState(); - } - - /// Stops new handler entry and waits for admitted handlers to leave without unregistering the host object. - internal void CloseCallbackAdmissionAndDrain() - { - lock (_gate) - { - _acceptCallbacks = false; - } - - Volatile.Read(ref CallbackDrainStartedForTesting)?.Invoke(); - _callbacksDrained.Wait(); - } - - /// Called only by the lifecycle registry while the old state is still valid. - internal void ReleaseFromLifecycle(LuaState state) - { - ReleaseWithState(state); - } - - private void Dispatch() - { - if (!LuaRuntime.TryEnterCallbackOperation(out var operation)) return; - - using (operation) - { - if (!TryEnterCallback(out var callback)) return; - - var stack = t_dispatchStack ??= []; - stack.Add(this); - try - { - callback(); - } - catch (Exception exception) - { - LastCallbackException = exception; - } - finally - { - stack.RemoveAt(stack.Count - 1); - ExitCallback(); - } - } - } - - private bool TryEnterCallback(out Action callback) - { - lock (_gate) - { - callback = null!; - if (!_acceptCallbacks - || !LuaRuntime.IsAttached - || LuaRuntime.CurrentStateIdentity != _identity - || _callback is null) - return false; - - checked - { - _activeCallbacks++; - } - - _callbacksDrained.Reset(); - callback = _callback; - return true; - } - } - - private void ExitCallback() - { - lock (_gate) - { - if (--_activeCallbacks == 0) _callbacksDrained.Set(); - } - } - - private bool IsDispatchingOnCurrentThread() - { - var stack = t_dispatchStack; - if (stack is null) return false; - - for (var index = stack.Count - 1; index >= 0; index--) - { - if (ReferenceEquals(stack[index], this)) return true; - } - - return false; - } - - private void ReleaseWithState(LuaState state) - { - if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0) - { - _disposed.Wait(); - return; - } - - try - { - CloseCallbackAdmissionAndDrain(); - Action? unregister; - lock (_gate) - { - _callback = null; - unregister = _unregister; - _unregister = null; - } - - if (unregister is not null) - try - { - unregister(state); - } - catch (Exception exception) - { - LastUnregisterException = exception; - } - } - finally - { - LuaHostSubscriptionRegistry.Remove(this); - Volatile.Write(ref _isDisposed, true); - _disposed.Set(); - } - } - - private void ReleaseUnpublishedRegistration(LuaState state) - { - ReleaseWithState(state); - } - - private void CancelUnregisteredRegistration() - { - lock (_gate) - { - _acceptCallbacks = false; - _callback = null; - } - - Volatile.Write(ref _isDisposed, true); - _disposed.Set(); - } - - private void AbandonWithoutState() - { - if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0) - { - _disposed.Wait(); - return; - } - - try - { - CloseCallbackAdmissionAndDrain(); - lock (_gate) - { - _callback = null; - _unregister = null; - } - } - finally - { - LuaHostSubscriptionRegistry.Remove(this); - Volatile.Write(ref _isDisposed, true); - _disposed.Set(); - } - } - - internal void Publish() - { - lock (_gate) - { - _acceptCallbacks = true; - } - } + [ThreadStatic] private static List? t_dispatchStack; + + // Deterministic drain seam used only by the SDK's friend test assembly. It runs in the individual owner drain and + // therefore exposes whether a registry caller kept its gate while asking that owner to wait for a callback. + internal static Action? CallbackDrainStartedForTesting; + private readonly ManualResetEventSlim _callbacksDrained = new(true); + private readonly ManualResetEventSlim _disposed = new(false); + + private readonly Lock _gate = new(); + private bool _acceptCallbacks; + private int _activeCallbacks; + + private Action? _callback; + private int _disposeStarted; + private bool _isDisposed; + private Action? _unregister; + + private LuaHostSubscription(Action callback, LuaStateIdentity identity) + { + _callback = callback; + Identity = identity; + } + + /// Gets the attachment and state generation that owns this registration. + internal LuaStateIdentity Identity + { + get; + } + + /// Gets whether teardown has consumed this host registration. + internal bool IsDisposed => Volatile.Read(ref _isDisposed); + + /// Gets the last managed handler failure, which is contained instead of reaching the host callback. + internal Exception? LastCallbackException + { + get; + private set; + } + + /// Gets the last unregister failure; ownership remains consumed and the action is never retried. + internal Exception? LastUnregisterException + { + get; + private set; + } + + internal LuaHostSubscription? Next + { + get; + set; + } + + internal LuaHostSubscription? Previous + { + get; + set; + } + + internal bool IsLinked + { + get; + set; + } + + /// + /// Releases this owner with a state borrowed from the active attachment. If a lifecycle transition has already + /// closed operation admission, its registry entry remains owned by that transition instead of attempting a raw + /// state access after teardown started. + /// + public void Dispose() + { + if (IsDispatchingOnCurrentThread()) + { + throw new InvalidOperationException( + "A host subscription cannot be disposed from its own callback; request teardown after the callback returns."); + } + + LuaRuntime.LuaCallbackDisposeOperationResult result = + LuaRuntime.TryAcquireOperationForCallbackDispose(out LuaRuntimeOperation operation); + if (result == LuaRuntime.LuaCallbackDisposeOperationResult.Acquired) + { + using (operation) + { + ReleaseWithState(operation.State); + } + + return; + } + + if (result == LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed) + { + // The lifecycle transition still owns the linked registration and has the only state allowed to unregister + // it. In particular, do not consume the action here: a failed transition can reopen the old binding. + return; + } + + AbandonWithoutState(); + } + + /// + /// Creates and publishes one owner transactionally. The registrar receives an inert callback and must return an + /// unregister action only after the host has accepted the registration. A return or a + /// thrown registrar must mean that no host subscription requiring cleanup was created. + /// + internal static bool TryRegister(LuaState state, Action callback, + Func?> registrar, out LuaHostSubscription? subscription) + { + ArgumentNullException.ThrowIfNull(callback); + ArgumentNullException.ThrowIfNull(registrar); + + subscription = null; + if (state.IsNull || !LuaRuntime.IsAttached) + { + return false; + } + + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(state); + if (!LuaRuntime.IsAttached) + { + return false; + } + + LuaHostSubscription candidate = new(callback, LuaRuntime.CurrentStateIdentity); + Action? unregister; + try + { + unregister = registrar(state, candidate.Dispatch); + } + catch + { + candidate.CancelUnregisteredRegistration(); + throw; + } + + if (unregister is null) + { + candidate.CancelUnregisteredRegistration(); + return false; + } + + candidate._unregister = unregister; + if (!LuaHostSubscriptionRegistry.TryAdd(candidate)) + { + candidate.ReleaseUnpublishedRegistration(state); + return false; + } + + subscription = candidate; + return true; + } + + /// Stops new handler entry and waits for admitted handlers to leave without unregistering the host object. + internal void CloseCallbackAdmissionAndDrain() + { + lock (_gate) + { + _acceptCallbacks = false; + } + + Volatile.Read(ref CallbackDrainStartedForTesting)?.Invoke(); + _callbacksDrained.Wait(); + } + + /// Called only by the lifecycle registry while the old state is still valid. + internal void ReleaseFromLifecycle(LuaState state) + { + ReleaseWithState(state); + } + + private void Dispatch() + { + if (!LuaRuntime.TryEnterCallbackOperation(out LuaRuntimeOperation operation)) + { + return; + } + + using (operation) + { + if (!TryEnterCallback(out Action callback)) + { + return; + } + + List stack = t_dispatchStack ??= []; + stack.Add(this); + try + { + callback(); + } + catch (Exception exception) + { + LastCallbackException = exception; + } + finally + { + stack.RemoveAt(stack.Count - 1); + ExitCallback(); + } + } + } + + private bool TryEnterCallback(out Action callback) + { + lock (_gate) + { + callback = null!; + if (!_acceptCallbacks + || !LuaRuntime.IsAttached + || LuaRuntime.CurrentStateIdentity != Identity + || _callback is null) + { + return false; + } + + checked + { + _activeCallbacks++; + } + + _callbacksDrained.Reset(); + callback = _callback; + return true; + } + } + + private void ExitCallback() + { + lock (_gate) + { + if (--_activeCallbacks == 0) + { + _callbacksDrained.Set(); + } + } + } + + private bool IsDispatchingOnCurrentThread() + { + List? stack = t_dispatchStack; + if (stack is null) + { + return false; + } + + for (int index = stack.Count - 1; index >= 0; index--) + { + if (ReferenceEquals(stack[index], this)) + { + return true; + } + } + + return false; + } + + private void ReleaseWithState(LuaState state) + { + if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0) + { + _disposed.Wait(); + return; + } + + try + { + CloseCallbackAdmissionAndDrain(); + Action? unregister; + lock (_gate) + { + _callback = null; + unregister = _unregister; + _unregister = null; + } + + if (unregister is not null) + { + try + { + unregister(state); + } + catch (Exception exception) + { + LastUnregisterException = exception; + } + } + } + finally + { + LuaHostSubscriptionRegistry.Remove(this); + Volatile.Write(ref _isDisposed, true); + _disposed.Set(); + } + } + + private void ReleaseUnpublishedRegistration(LuaState state) + { + ReleaseWithState(state); + } + + private void CancelUnregisteredRegistration() + { + lock (_gate) + { + _acceptCallbacks = false; + _callback = null; + } + + Volatile.Write(ref _isDisposed, true); + _disposed.Set(); + } + + private void AbandonWithoutState() + { + if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0) + { + _disposed.Wait(); + return; + } + + try + { + CloseCallbackAdmissionAndDrain(); + lock (_gate) + { + _callback = null; + _unregister = null; + } + } + finally + { + LuaHostSubscriptionRegistry.Remove(this); + Volatile.Write(ref _isDisposed, true); + _disposed.Set(); + } + } + + internal void Publish() + { + lock (_gate) + { + _acceptCallbacks = true; + } + } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs index 34255d74..98513af0 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading; + using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -12,127 +13,148 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// internal static class LuaHostSubscriptionRegistry { - private static LuaHostSubscription? s_head; - private static bool s_acceptRegistrations; - - // Deterministic pre-disable race seam used only by the SDK's friend test assembly. It runs after registrations are - // closed and before the registry waits for callbacks that were already admitted. - internal static Action? CallbackAdmissionClosedForTesting; - - internal static Lock Gate { get; } = new(); - - internal static int Count - { - get - { - lock (Gate) - { - var count = 0; - for (var current = s_head; current is not null; current = current.Next) count++; - - return count; - } - } - } - - internal static bool TryAdd(LuaHostSubscription subscription) - { - lock (Gate) - { - if (!s_acceptRegistrations - || !LuaRuntime.IsAttached - || LuaRuntime.CurrentStateIdentity != subscription.Identity) - return false; - - subscription.Next = s_head; - s_head?.Previous = subscription; - s_head = subscription; - subscription.IsLinked = true; - subscription.Publish(); - return true; - } - } - - internal static void Remove(LuaHostSubscription subscription) - { - lock (Gate) - { - if (!subscription.IsLinked) return; - - if (subscription.Previous is null) - s_head = subscription.Next; - else - subscription.Previous.Next = subscription.Next; - - subscription.Next?.Previous = subscription.Previous; - subscription.Next = null; - subscription.Previous = null; - subscription.IsLinked = false; - } - } - - /// - /// Closes registration and callback admission before plugin OnDisable destroys plugin-managed state. Host - /// objects remain linked until the later state-owning lifecycle cleanup can unregister them. - /// - internal static void CloseCallbackAdmissionAndDrain() - { - List subscriptions = []; - lock (Gate) - { - s_acceptRegistrations = false; - for (var current = s_head; current is not null; current = current.Next) subscriptions.Add(current); - } - - Volatile.Read(ref CallbackAdmissionClosedForTesting)?.Invoke(); - - // A callback admitted before the boundary can dispose another owner or attempt a rejected registration. Neither - // action may wait behind this drain, so retain only a linked-owner snapshot while holding Gate and wait outside it. - foreach (var subscription in subscriptions) - { - subscription.CloseCallbackAdmissionAndDrain(); - } - } - - /// Closes admission and makes one LIFO unregister attempt for every owner while is valid. - internal static void DetachAll(LuaState state) - { - lock (Gate) - { - s_acceptRegistrations = false; - } - - while (TryTakeHead(out var subscription)) - { - subscription.ReleaseFromLifecycle(state); - } - } - - internal static void OpenRegistrationAdmission() - { - lock (Gate) - { - s_acceptRegistrations = true; - } - } - - private static bool TryTakeHead(out LuaHostSubscription subscription) - { - lock (Gate) - { - if (s_head is null) - { - subscription = null!; - return false; - } - - subscription = s_head; - s_head = subscription.Next; - s_head?.Previous = null; - subscription.Next = null; - subscription.Previous = null; - subscription.IsLinked = false; - return true; - } - } + private static LuaHostSubscription? s_head; + private static bool s_acceptRegistrations; + + // Deterministic pre-disable race seam used only by the SDK's friend test assembly. It runs after registrations are + // closed and before the registry waits for callbacks that were already admitted. + internal static Action? CallbackAdmissionClosedForTesting; + + internal static Lock Gate + { + get; + } = new(); + + internal static int Count + { + get + { + lock (Gate) + { + int count = 0; + for (LuaHostSubscription? current = s_head; current is not null; current = current.Next) + { + count++; + } + + return count; + } + } + } + + internal static bool TryAdd(LuaHostSubscription subscription) + { + lock (Gate) + { + if (!s_acceptRegistrations + || !LuaRuntime.IsAttached + || LuaRuntime.CurrentStateIdentity != subscription.Identity) + { + return false; + } + + subscription.Next = s_head; + s_head?.Previous = subscription; + s_head = subscription; + subscription.IsLinked = true; + subscription.Publish(); + return true; + } + } + + internal static void Remove(LuaHostSubscription subscription) + { + lock (Gate) + { + if (!subscription.IsLinked) + { + return; + } + + if (subscription.Previous is null) + { + s_head = subscription.Next; + } + else + { + subscription.Previous.Next = subscription.Next; + } + + subscription.Next?.Previous = subscription.Previous; + subscription.Next = null; + subscription.Previous = null; + subscription.IsLinked = false; + } + } + + /// + /// Closes registration and callback admission before plugin OnDisable destroys plugin-managed state. Host + /// objects remain linked until the later state-owning lifecycle cleanup can unregister them. + /// + internal static void CloseCallbackAdmissionAndDrain() + { + List subscriptions = []; + lock (Gate) + { + s_acceptRegistrations = false; + for (LuaHostSubscription? current = s_head; current is not null; current = current.Next) + { + subscriptions.Add(current); + } + } + + Volatile.Read(ref CallbackAdmissionClosedForTesting)?.Invoke(); + + // A callback admitted before the boundary can dispose another owner or attempt a rejected registration. Neither + // action may wait behind this drain, so retain only a linked-owner snapshot while holding Gate and wait outside it. + foreach (LuaHostSubscription subscription in subscriptions) + { + subscription.CloseCallbackAdmissionAndDrain(); + } + } + + /// + /// Closes admission and makes one LIFO unregister attempt for every owner while is + /// valid. + /// + internal static void DetachAll(LuaState state) + { + lock (Gate) + { + s_acceptRegistrations = false; + } + + while (TryTakeHead(out LuaHostSubscription subscription)) + { + subscription.ReleaseFromLifecycle(state); + } + } + + internal static void OpenRegistrationAdmission() + { + lock (Gate) + { + s_acceptRegistrations = true; + } + } + + private static bool TryTakeHead(out LuaHostSubscription subscription) + { + lock (Gate) + { + if (s_head is null) + { + subscription = null!; + return false; + } + + subscription = s_head; + s_head = subscription.Next; + s_head?.Previous = null; + subscription.Next = null; + subscription.Previous = null; + subscription.IsLinked = false; + return true; + } + } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaNativeFunction.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaNativeFunction.cs index 3124e4f2..0d64e278 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaNativeFunction.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaNativeFunction.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; + using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -38,66 +39,69 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// public readonly unsafe struct LuaNativeFunction : IEquatable { - /// Wraps the address of a cdecl function int (lua_State*); zero is the null function. - /// The function address. - public LuaNativeFunction(nint address) - { - Address = address; - } + /// Wraps the address of a cdecl function int (lua_State*); zero is the null function. + /// The function address. + public LuaNativeFunction(nint address) + { + Address = address; + } - /// Wraps a typed function pointer whose parameter is the state as an integer (the shape generated thunks use). - /// The function, usually &Thunk. - public LuaNativeFunction(delegate* unmanaged[Cdecl] function) - { - Address = (nint)function; - } + /// Wraps a typed function pointer whose parameter is the state as an integer (the shape generated thunks use). + /// The function, usually &Thunk. + public LuaNativeFunction(delegate* unmanaged[Cdecl] function) + { + Address = (nint) function; + } - /// Gets the function address; zero for the null function. - public nint Address { get; } + /// Gets the function address; zero for the null function. + public nint Address + { + get; + } - /// Gets a value indicating whether this is the null function, which must not be pushed. - public bool IsNull => Address == 0; + /// Gets a value indicating whether this is the null function, which must not be pushed. + public bool IsNull => Address == 0; - internal delegate* unmanaged[Cdecl] Pointer => - (delegate* unmanaged[Cdecl])Address; + internal delegate* unmanaged[Cdecl] Pointer => + (delegate* unmanaged[Cdecl]) Address; - /// Compares addresses. - /// First function. - /// Second function. - public static bool operator ==(LuaNativeFunction left, LuaNativeFunction right) - { - return left.Address == right.Address; - } + /// Compares addresses. + /// First function. + /// Second function. + public static bool operator ==(LuaNativeFunction left, LuaNativeFunction right) + { + return left.Address == right.Address; + } - /// Compares addresses. - /// First function. - /// Second function. - public static bool operator !=(LuaNativeFunction left, LuaNativeFunction right) - { - return left.Address != right.Address; - } + /// Compares addresses. + /// First function. + /// Second function. + public static bool operator !=(LuaNativeFunction left, LuaNativeFunction right) + { + return left.Address != right.Address; + } - /// - public bool Equals(LuaNativeFunction other) - { - return Address == other.Address; - } + /// + public bool Equals(LuaNativeFunction other) + { + return Address == other.Address; + } - /// - public override bool Equals(object? obj) - { - return obj is LuaNativeFunction other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is LuaNativeFunction other && Equals(other); + } - /// - public override int GetHashCode() - { - return Address.GetHashCode(); - } + /// + public override int GetHashCode() + { + return Address.GetHashCode(); + } - /// lua_CFunction@0x.... - public override string ToString() - { - return "lua_CFunction@0x" + Address.ToString("X", CultureInfo.InvariantCulture); - } + /// lua_CFunction@0x.... + public override string ToString() + { + return "lua_CFunction@0x" + Address.ToString("X", CultureInfo.InvariantCulture); + } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaThunk.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaThunk.cs index 5fc10db1..a05e039e 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaThunk.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaThunk.cs @@ -3,10 +3,12 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Protected; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Text; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Callbacks; @@ -54,159 +56,162 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// public static class LuaThunk { - /// - /// Number of values leaves on the stack, which - /// is also what the - /// thunk must return. - /// - public const int FailureResultCount = 2; + /// + /// Number of values leaves on the stack, which + /// is also what the + /// thunk must return. + /// + public const int FailureResultCount = 2; - /// - /// Reads the state object carried by the running closure's first upvalue, the one - /// - /// installed. Only meaningful inside a thunk that Lua is currently running through such a closure: elsewhere the - /// upvalue pseudo-index is undefined. - /// - /// The expected type; a state of another type yields . - /// The state the thunk received. - /// The state object. - /// - /// when the closure has no state (pushed without one, or released) or the state is not a - /// . - /// - /// One C API call and one type check; allocates nothing. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static unsafe bool TryGetState(LuaState state, [NotNullWhen(true)] out TState? value) - where TState : class - { - // Release must not free the handle between reading its address and rooting its target. - lock (LuaCallbackRegistry.Gate) - { - var upvalue = lua_touserdata(state.Pointer, lua_upvalueindex(1)); - if (upvalue is null) - { - value = null; - return false; - } + /// + /// Reads the state object carried by the running closure's first upvalue, the one + /// + /// installed. Only meaningful inside a thunk that Lua is currently running through such a closure: elsewhere the + /// upvalue pseudo-index is undefined. + /// + /// The expected type; a state of another type yields . + /// The state the thunk received. + /// The state object. + /// + /// when the closure has no state (pushed without one, or released) or the state is not a + /// . + /// + /// One C API call and one type check; allocates nothing. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static unsafe bool TryGetState(LuaState state, [NotNullWhen(true)] out TState? value) + where TState : class + { + // Release must not free the handle between reading its address and rooting its target. + lock (LuaCallbackRegistry.Gate) + { + void* upvalue = lua_touserdata(state.Pointer, lua_upvalueindex(1)); + if (upvalue is null) + { + value = null; + return false; + } - value = GCHandle.FromIntPtr((nint)upvalue).Target as TState; - return value is not null; - } - } + value = GCHandle.FromIntPtr((nint) upvalue).Target as TState; + return value is not null; + } + } - /// - /// Reports a failure to Lua: pushes the sentinel and and returns - /// , for the thunk to return in turn. Allocates nothing on the managed side. - /// - /// The state the thunk received. - /// The error message, UTF-8. - /// 2. - /// - /// The message allocation runs through the native protection bridge. If Lua cannot allocate the message, the - /// protected Lua error value becomes the second result. A bridge failure falls back to nil, so this method - /// never lets a Lua longjmp or managed exception escape the thunk. - /// - [LuaStackEffect(FailureResultCount)] - public static unsafe int Fail(LuaState state, ReadOnlySpan message) - { - LuaHelpers.PushSentinel(state.Pointer); - try - { - _ = state.TryPushString(message); - } - catch (Exception) - { - state.PushNil(); - } + /// + /// Reports a failure to Lua: pushes the sentinel and and returns + /// , for the thunk to return in turn. Allocates nothing on the managed side. + /// + /// The state the thunk received. + /// The error message, UTF-8. + /// 2. + /// + /// The message allocation runs through the native protection bridge. If Lua cannot allocate the message, the + /// protected Lua error value becomes the second result. A bridge failure falls back to nil, so this method + /// never lets a Lua longjmp or managed exception escape the thunk. + /// + [LuaStackEffect(FailureResultCount)] + public static unsafe int Fail(LuaState state, ReadOnlySpan message) + { + LuaHelpers.PushSentinel(state.Pointer); + try + { + _ = state.TryPushString(message); + } + catch (Exception) + { + state.PushNil(); + } - return FailureResultCount; - } + return FailureResultCount; + } - /// - /// Reports a failure with a UTF-16 message (a converts implicitly); see - /// . - /// - /// The state the thunk received. - /// The error message. - /// 2. - [LuaStackEffect(FailureResultCount)] - public static unsafe int Fail(LuaState state, ReadOnlySpan message) - { - try - { - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode(message, scratch); - return Fail(state, utf8.Bytes); - } - catch (Exception) - { - LuaHelpers.PushSentinel(state.Pointer); - state.PushNil(); - return FailureResultCount; - } - } + /// + /// Reports a failure with a UTF-16 message (a converts implicitly); see + /// . + /// + /// The state the thunk received. + /// The error message. + /// 2. + [LuaStackEffect(FailureResultCount)] + public static unsafe int Fail(LuaState state, ReadOnlySpan message) + { + try + { + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(message, scratch); + return Fail(state, utf8.Bytes); + } + catch (Exception) + { + LuaHelpers.PushSentinel(state.Pointer); + state.PushNil(); + return FailureResultCount; + } + } - /// - /// Reports an argument of the wrong kind the way Lua's own luaL_check* functions do, naming the type - /// actually received: bad argument #2 (integer expected, got nil). Built in a stack buffer; allocates - /// nothing on the managed side. See . - /// - /// The state the thunk received. - /// The 1-based position of the argument, which is also its stack index. - /// What the thunk expected ("integer"u8, "string"u8, ...); truncated beyond 64 bytes. - /// 2. - [LuaStackEffect(FailureResultCount)] - [SkipLocalsInit] // Every byte read from the buffer is written first; zeroing it would be dead stores. - public static int FailBadArgument(LuaState state, int argument, ReadOnlySpan expected) - { - // "bad argument #" + digits + " (" + expected + " expected, got " + type name + ")". - Span message = stackalloc byte[128]; - var length = 0; - Append(message, ref length, "bad argument #"u8); - if (!argument.TryFormat(message[length..], out var digits, default, CultureInfo.InvariantCulture)) digits = 0; + /// + /// Reports an argument of the wrong kind the way Lua's own luaL_check* functions do, naming the type + /// actually received: bad argument #2 (integer expected, got nil). Built in a stack buffer; allocates + /// nothing on the managed side. See . + /// + /// The state the thunk received. + /// The 1-based position of the argument, which is also its stack index. + /// What the thunk expected ("integer"u8, "string"u8, ...); truncated beyond 64 bytes. + /// 2. + [LuaStackEffect(FailureResultCount)] + [SkipLocalsInit] // Every byte read from the buffer is written first; zeroing it would be dead stores. + public static int FailBadArgument(LuaState state, int argument, ReadOnlySpan expected) + { + // "bad argument #" + digits + " (" + expected + " expected, got " + type name + ")". + Span message = stackalloc byte[128]; + int length = 0; + Append(message, ref length, "bad argument #"u8); + if (!argument.TryFormat(message[length..], out int digits, default, CultureInfo.InvariantCulture)) + { + digits = 0; + } - length += digits; - Append(message, ref length, " ("u8); - Append(message, ref length, expected.Length > 64 ? expected[..64] : expected); - Append(message, ref length, " expected, got "u8); - Append(message, ref length, state.TypeName(argument)); - Append(message, ref length, ")"u8); - return Fail(state, message[..length]); - } + length += digits; + Append(message, ref length, " ("u8); + Append(message, ref length, expected.Length > 64 ? expected[..64] : expected); + Append(message, ref length, " expected, got "u8); + Append(message, ref length, state.TypeName(argument)); + Append(message, ref length, ")"u8); + return Fail(state, message[..length]); + } - private static void Append(Span buffer, ref int length, ReadOnlySpan text) - { - // The pieces are sized so that this never truncates; the guard keeps the method safe if it ever changes. - var count = Math.Min(text.Length, buffer.Length - length); - text[..count].CopyTo(buffer[length..]); - length += count; - } + private static void Append(Span buffer, ref int length, ReadOnlySpan text) + { + // The pieces are sized so that this never truncates; the guard keeps the method safe if it ever changes. + int count = Math.Min(text.Length, buffer.Length - length); + text[..count].CopyTo(buffer[length..]); + length += count; + } - /// - /// Reports an exception caught by the thunk as TypeName: Message; see - /// . - /// Never throws itself: if formatting the exception fails, a fixed message is used. - /// - /// The state the thunk received. - /// The caught exception; is reported as an unknown failure. - /// 2. - [LuaStackEffect(FailureResultCount)] - [MethodImpl(MethodImplOptions.NoInlining)] - public static int Fail(LuaState state, Exception? exception) - { - string message; - try - { - message = exception is null - ? "managed callback failed" - : exception.GetType().FullName + ": " + exception.Message; - } - catch (Exception) - { - message = "managed callback failed (the exception could not be described)"; - } + /// + /// Reports an exception caught by the thunk as TypeName: Message; see + /// . + /// Never throws itself: if formatting the exception fails, a fixed message is used. + /// + /// The state the thunk received. + /// The caught exception; is reported as an unknown failure. + /// 2. + [LuaStackEffect(FailureResultCount)] + [MethodImpl(MethodImplOptions.NoInlining)] + public static int Fail(LuaState state, Exception? exception) + { + string message; + try + { + message = exception is null + ? "managed callback failed" + : exception.GetType().FullName + ": " + exception.Message; + } + catch (Exception) + { + message = "managed callback failed (the exception could not be described)"; + } - return Fail(state, message.AsSpan()); - } + return Fail(state, message.AsSpan()); + } } diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaComparison.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaComparison.cs index 1d80d26a..3c851c84 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaComparison.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaComparison.cs @@ -8,12 +8,12 @@ namespace CheatEngine.SDK.Lua.Calls; /// public enum LuaComparison { - /// a == b, honouring __eq. - Equal = 0, + /// a == b, honouring __eq. + Equal = 0, - /// a < b, honouring __lt. - Less = 1, + /// a < b, honouring __lt. + Less = 1, - /// a <= b, honouring __le. - LessOrEqual = 2 + /// a <= b, honouring __le. + LessOrEqual = 2 } diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaError.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaError.cs index 7d9cbc37..19087b20 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaError.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaError.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.State; @@ -21,92 +22,101 @@ namespace CheatEngine.SDK.Lua.Calls; /// The error text; never . public readonly struct LuaError(LuaStatus status, string message) : IEquatable { - /// Gets the status of the failed operation. - public LuaStatus Status { get; } = status; + /// Gets the status of the failed operation. + public LuaStatus Status + { + get; + } = status; - /// - /// Gets the error text: the string error value, a number converted to text, or a description of the value's type - /// when the error value is neither ((error object is a table value), in the style of lua.c). - /// - public string Message { get; } = message ?? string.Empty; + /// + /// Gets the error text: the string error value, a number converted to text, or a description of the value's type + /// when the error value is neither ((error object is a table value), in the style of lua.c). + /// + public string Message + { + get; + } = message ?? string.Empty; - /// - /// Reads the error value on top of 's stack, which is where every failed protected - /// operation leaves it. The value stays on the stack (stack effect 0); the caller's frame discards it. - /// - /// The state the failed operation ran on. - /// The status that operation returned. - /// - /// The extracted error. When the stack is empty the message says so instead of reading a slot that does not - /// exist. - /// - /// - /// Never runs Lua code: a non-string, non-number error value is described by its type instead of being passed to - /// tostring, because a __tostring metamethod could raise. Numbers are formatted in managed code, - /// avoiding the allocating numeric conversion performed by lua_tolstring. - /// - [MethodImpl(MethodImplOptions.NoInlining)] - public static unsafe LuaError FromStack(LuaState state, LuaStatus status) - { - if (state.IsNull || state.Top == 0) return new LuaError(status, "(no error value on the stack)"); + /// + /// Reads the error value on top of 's stack, which is where every failed protected + /// operation leaves it. The value stays on the stack (stack effect 0); the caller's frame discards it. + /// + /// The state the failed operation ran on. + /// The status that operation returned. + /// + /// The extracted error. When the stack is empty the message says so instead of reading a slot that does not + /// exist. + /// + /// + /// Never runs Lua code: a non-string, non-number error value is described by its type instead of being passed to + /// tostring, because a __tostring metamethod could raise. Numbers are formatted in managed code, + /// avoiding the allocating numeric conversion performed by lua_tolstring. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + public static unsafe LuaError FromStack(LuaState state, LuaStatus status) + { + if (state.IsNull || state.Top == 0) + { + return new LuaError(status, "(no error value on the stack)"); + } - switch (state.TypeOf(-1)) - { - case LuaType.String: - nuint length; - var bytes = LuaApi.lua_tolstring(state.Pointer, -1, &length); - return new LuaError(status, Encoding.UTF8.GetString(bytes, checked((int)length))); - case LuaType.Number: - return new LuaError(status, LuaApi.lua_isinteger(state.Pointer, -1) != 0 - ? LuaApi.lua_tointegerx(state.Pointer, -1, null).ToString(CultureInfo.InvariantCulture) - : LuaApi.lua_tonumberx(state.Pointer, -1, null).ToString("G", CultureInfo.InvariantCulture)); - case LuaType.Nil: - return new LuaError(status, "(error object is a nil value)"); - default: - var typeName = Encoding.UTF8.GetString( - MemoryMarshal.CreateReadOnlySpanFromNullTerminated(LuaApi.lua_typename(state.Pointer, - (int)state.TypeOf(-1)))); - return new LuaError(status, $"(error object is a {typeName} value)"); - } - } + switch (state.TypeOf(-1)) + { + case LuaType.String: + nuint length; + byte* bytes = LuaApi.lua_tolstring(state.Pointer, -1, &length); + return new LuaError(status, Encoding.UTF8.GetString(bytes, checked((int) length))); + case LuaType.Number: + return new LuaError(status, LuaApi.lua_isinteger(state.Pointer, -1) != 0 + ? LuaApi.lua_tointegerx(state.Pointer, -1, null).ToString(CultureInfo.InvariantCulture) + : LuaApi.lua_tonumberx(state.Pointer, -1, null).ToString("G", CultureInfo.InvariantCulture)); + case LuaType.Nil: + return new LuaError(status, "(error object is a nil value)"); + default: + string typeName = Encoding.UTF8.GetString( + MemoryMarshal.CreateReadOnlySpanFromNullTerminated(LuaApi.lua_typename(state.Pointer, + (int) state.TypeOf(-1)))); + return new LuaError(status, $"(error object is a {typeName} value)"); + } + } - /// Compares status and message. - /// First error. - /// Second error. - public static bool operator ==(LuaError left, LuaError right) - { - return left.Equals(right); - } + /// Compares status and message. + /// First error. + /// Second error. + public static bool operator ==(LuaError left, LuaError right) + { + return left.Equals(right); + } - /// Compares status and message. - /// First error. - /// Second error. - public static bool operator !=(LuaError left, LuaError right) - { - return !left.Equals(right); - } + /// Compares status and message. + /// First error. + /// Second error. + public static bool operator !=(LuaError left, LuaError right) + { + return !left.Equals(right); + } - /// - public bool Equals(LuaError other) - { - return Status == other.Status && string.Equals(Message, other.Message, StringComparison.Ordinal); - } + /// + public bool Equals(LuaError other) + { + return Status == other.Status && string.Equals(Message, other.Message, StringComparison.Ordinal); + } - /// - public override bool Equals(object? obj) - { - return obj is LuaError other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is LuaError other && Equals(other); + } - /// - public override int GetHashCode() - { - return HashCode.Combine(Status, Message); - } + /// + public override int GetHashCode() + { + return HashCode.Combine(Status, Message); + } - /// LUA_ERRRUN: message. - public override string ToString() - { - return Status + ": " + Message; - } + /// LUA_ERRRUN: message. + public override string ToString() + { + return Status + ": " + Message; + } } diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaException.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaException.cs index 976fa228..1592edf3 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaException.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaException.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.State; namespace CheatEngine.SDK.Lua.Calls; @@ -16,56 +17,59 @@ namespace CheatEngine.SDK.Lua.Calls; /// public sealed class LuaException : Exception { - /// Creates an exception with a plain message and no Lua status. - /// The message. - public LuaException(string message) - : base(message) - { - } + /// Creates an exception with a plain message and no Lua status. + /// The message. + public LuaException(string message) + : base(message) + { + } - /// Creates an exception with a plain message and an inner exception. - /// The message. - /// The cause. - public LuaException(string message, Exception innerException) - : base(message, innerException) - { - } + /// Creates an exception with a plain message and an inner exception. + /// The message. + /// The cause. + public LuaException(string message, Exception innerException) + : base(message, innerException) + { + } - /// Creates an exception from an extracted error. - /// The status and text of the failure. - public LuaException(LuaError error) - : base(error.Message) - { - Status = error.Status; - } + /// Creates an exception from an extracted error. + /// The status and text of the failure. + public LuaException(LuaError error) + : base(error.Message) + { + Status = error.Status; + } - /// - /// Gets the Lua status of the failed operation; when the exception did not come from - /// a Lua status. - /// - public LuaStatus Status { get; } + /// + /// Gets the Lua status of the failed operation; when the exception did not come from + /// a Lua status. + /// + public LuaStatus Status + { + get; + } - /// - /// Reads the error value on top of 's stack (see ) and - /// throws it. The value is left on the stack for the caller's frame. Cold and never inlined. - /// - /// The state the failed operation ran on. - /// The failure status. - /// Always. - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - public static void ThrowFromStack(LuaState state, LuaStatus status) - { - throw new LuaException(LuaError.FromStack(state, status)); - } + /// + /// Reads the error value on top of 's stack (see ) and + /// throws it. The value is left on the stack for the caller's frame. Cold and never inlined. + /// + /// The state the failed operation ran on. + /// The failure status. + /// Always. + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowFromStack(LuaState state, LuaStatus status) + { + throw new LuaException(LuaError.FromStack(state, status)); + } - /// Throws an extracted error. Cold and never inlined. - /// The error. - /// Always. - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - public static void Throw(LuaError error) - { - throw new LuaException(error); - } + /// Throws an extracted error. Cold and never inlined. + /// The error. + /// Always. + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void Throw(LuaError error) + { + throw new LuaException(error); + } } diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs index ed305919..63e66600 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs @@ -13,70 +13,79 @@ namespace CheatEngine.SDK.Lua.Calls; [StructLayout(LayoutKind.Sequential)] public readonly struct LuaOperationStatus : IEquatable { - private LuaOperationStatus(LuaOperationStatusKind kind, LuaStatus luaStatus) - { - Kind = kind; - LuaStatus = luaStatus; - } - - /// Gets the factual binding outcome. - public LuaOperationStatusKind Kind { get; } - - /// Gets the protected Lua status for ; otherwise . - public LuaStatus LuaStatus { get; } - - /// Gets a successful status. - public static LuaOperationStatus Success => default; - - /// Gets a status for an absent or non-callable global. - public static LuaOperationStatus GlobalUnavailable => new(LuaOperationStatusKind.GlobalUnavailable, LuaStatus.Ok); - - /// Gets a status for an unqualified Lua nil result. - public static LuaOperationStatus NilResult => new(LuaOperationStatusKind.NilResult, LuaStatus.Ok); - - /// Gets a status for a non-nil result outside the declared marshalling contract. - public static LuaOperationStatus InvalidResult => new(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok); - - /// Gets a status for a stack-capacity failure before the call begins. - public static LuaOperationStatus StackUnavailable => new(LuaOperationStatusKind.StackUnavailable, LuaStatus.Ok); - - /// Gets a status for a protected Lua failure. - /// The unmodified status returned by the protected Lua primitive. - public static LuaOperationStatus LuaFailure(LuaStatus luaStatus) - { - return new(LuaOperationStatusKind.LuaFailure, luaStatus); - } - - /// Gets whether the call and result conversions completed successfully. - public bool IsSuccess => Kind == LuaOperationStatusKind.Success; - - /// - public bool Equals(LuaOperationStatus other) - { - return Kind == other.Kind && LuaStatus == other.LuaStatus; - } - - /// - public override bool Equals([NotNullWhen(true)] object? obj) - { - return obj is LuaOperationStatus other && Equals(other); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine((int)Kind, LuaStatus); - } - - /// Compares two operation statuses. - public static bool operator ==(LuaOperationStatus left, LuaOperationStatus right) - { - return left.Equals(right); - } - - /// Compares two operation statuses. - public static bool operator !=(LuaOperationStatus left, LuaOperationStatus right) - { - return !left.Equals(right); - } + private LuaOperationStatus(LuaOperationStatusKind kind, LuaStatus luaStatus) + { + Kind = kind; + LuaStatus = luaStatus; + } + + /// Gets the factual binding outcome. + public LuaOperationStatusKind Kind + { + get; + } + + /// + /// Gets the protected Lua status for ; otherwise + /// . + /// + public LuaStatus LuaStatus + { + get; + } + + /// Gets a successful status. + public static LuaOperationStatus Success => default; + + /// Gets a status for an absent or non-callable global. + public static LuaOperationStatus GlobalUnavailable => new(LuaOperationStatusKind.GlobalUnavailable, LuaStatus.Ok); + + /// Gets a status for an unqualified Lua nil result. + public static LuaOperationStatus NilResult => new(LuaOperationStatusKind.NilResult, LuaStatus.Ok); + + /// Gets a status for a non-nil result outside the declared marshalling contract. + public static LuaOperationStatus InvalidResult => new(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok); + + /// Gets a status for a stack-capacity failure before the call begins. + public static LuaOperationStatus StackUnavailable => new(LuaOperationStatusKind.StackUnavailable, LuaStatus.Ok); + + /// Gets a status for a protected Lua failure. + /// The unmodified status returned by the protected Lua primitive. + public static LuaOperationStatus LuaFailure(LuaStatus luaStatus) + { + return new LuaOperationStatus(LuaOperationStatusKind.LuaFailure, luaStatus); + } + + /// Gets whether the call and result conversions completed successfully. + public bool IsSuccess => Kind == LuaOperationStatusKind.Success; + + /// + public bool Equals(LuaOperationStatus other) + { + return Kind == other.Kind && LuaStatus == other.LuaStatus; + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is LuaOperationStatus other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine((int) Kind, LuaStatus); + } + + /// Compares two operation statuses. + public static bool operator ==(LuaOperationStatus left, LuaOperationStatus right) + { + return left.Equals(right); + } + + /// Compares two operation statuses. + public static bool operator !=(LuaOperationStatus left, LuaOperationStatus right) + { + return !left.Equals(right); + } } diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs index 8c06bc77..162b78cd 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs @@ -7,21 +7,21 @@ namespace CheatEngine.SDK.Lua.Calls; /// public enum LuaOperationStatusKind { - /// The global call and every declared result conversion succeeded. - Success, + /// The global call and every declared result conversion succeeded. + Success, - /// The required global was absent or was not a callable Lua function. - GlobalUnavailable, + /// The required global was absent or was not a callable Lua function. + GlobalUnavailable, - /// A protected Lua operation failed. - LuaFailure, + /// A protected Lua operation failed. + LuaFailure, - /// The call completed but returned Lua nil where the declaration requires a value. - NilResult, + /// The call completed but returned Lua nil where the declaration requires a value. + NilResult, - /// The call completed but returned a non-nil value the declared marshaller cannot represent. - InvalidResult, + /// The call completed but returned a non-nil value the declared marshaller cannot represent. + InvalidResult, - /// The Lua stack could not grow enough to begin the declared call. - StackUnavailable, + /// The Lua stack could not grow enough to begin the declared call. + StackUnavailable } diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaStatus.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaStatus.cs index 3383a06b..56c12399 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaStatus.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaStatus.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.State; @@ -21,106 +22,112 @@ namespace CheatEngine.SDK.Lua.Calls; /// The code; any value is accepted, unknown codes are treated as failures. public readonly struct LuaStatus(int code) : IEquatable { - /// Success (LUA_OK). - public static LuaStatus Ok => new(LuaApi.LUA_OK); - - /// A coroutine yielded (LUA_YIELD). Not produced by this assembly's own operations. - public static LuaStatus Yield => new(LuaApi.LUA_YIELD); - - /// - /// A runtime error (LUA_ERRRUN): error(...), the error channel of managed callbacks, and the one - /// precondition this assembly reports as a status instead of throwing ( - /// on a - /// released or stale callback), always with a message on the stack. - /// - public static LuaStatus RuntimeError => new(LuaApi.LUA_ERRRUN); - - /// The chunk did not compile (LUA_ERRSYNTAX). - public static LuaStatus SyntaxError => new(LuaApi.LUA_ERRSYNTAX); - - /// Lua could not allocate memory (LUA_ERRMEM); the message handler is not run for it. - public static LuaStatus MemoryError => new(LuaApi.LUA_ERRMEM); - - /// A __gc metamethod raised during a collector step (LUA_ERRGCMM). - public static LuaStatus GcMetamethodError => new(LuaApi.LUA_ERRGCMM); - - /// The message handler itself raised (LUA_ERRERR). - public static LuaStatus MessageHandlerError => new(LuaApi.LUA_ERRERR); - - /// A file could not be read (LUA_ERRFILE). Not produced by this assembly's own operations. - public static LuaStatus FileError => new(LuaApi.LUA_ERRFILE); - - /// Gets the raw code (LUA_OK is 0). - public int Code { get; } = code; - - /// Gets a value indicating whether the operation succeeded. - public bool IsOk - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => Code == LuaApi.LUA_OK; - } - - /// Compares two statuses by code. - /// First status. - /// Second status. - public static bool operator ==(LuaStatus left, LuaStatus right) - { - return left.Code == right.Code; - } - - /// Compares two statuses by code. - /// First status. - /// Second status. - public static bool operator !=(LuaStatus left, LuaStatus right) - { - return left.Code != right.Code; - } - - /// - public bool Equals(LuaStatus other) - { - return Code == other.Code; - } - - /// - public override bool Equals(object? obj) - { - return obj is LuaStatus other && Equals(other); - } - - /// - public override int GetHashCode() - { - return Code; - } - - /// The C API name of the code (LUA_OK, LUA_ERRRUN, ...), or the number for an unknown code. - public override string ToString() - { - return Code switch - { - LuaApi.LUA_OK => "LUA_OK", - LuaApi.LUA_YIELD => "LUA_YIELD", - LuaApi.LUA_ERRRUN => "LUA_ERRRUN", - LuaApi.LUA_ERRSYNTAX => "LUA_ERRSYNTAX", - LuaApi.LUA_ERRMEM => "LUA_ERRMEM", - LuaApi.LUA_ERRGCMM => "LUA_ERRGCMM", - LuaApi.LUA_ERRERR => "LUA_ERRERR", - LuaApi.LUA_ERRFILE => "LUA_ERRFILE", - _ => Code.ToString(CultureInfo.InvariantCulture) - }; - } - - /// - /// Throws a built from the error value on top of 's stack when - /// the status is a failure; returns normally otherwise. The error value is left on the stack for the caller's - /// frame to discard. - /// - /// The state the failed operation ran on. - /// The status is not . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void ThrowIfFailed(LuaState state) - { - if (Code != LuaApi.LUA_OK) LuaException.ThrowFromStack(state, this); - } + /// Success (LUA_OK). + public static LuaStatus Ok => new(LuaApi.LUA_OK); + + /// A coroutine yielded (LUA_YIELD). Not produced by this assembly's own operations. + public static LuaStatus Yield => new(LuaApi.LUA_YIELD); + + /// + /// A runtime error (LUA_ERRRUN): error(...), the error channel of managed callbacks, and the one + /// precondition this assembly reports as a status instead of throwing ( + /// on a + /// released or stale callback), always with a message on the stack. + /// + public static LuaStatus RuntimeError => new(LuaApi.LUA_ERRRUN); + + /// The chunk did not compile (LUA_ERRSYNTAX). + public static LuaStatus SyntaxError => new(LuaApi.LUA_ERRSYNTAX); + + /// Lua could not allocate memory (LUA_ERRMEM); the message handler is not run for it. + public static LuaStatus MemoryError => new(LuaApi.LUA_ERRMEM); + + /// A __gc metamethod raised during a collector step (LUA_ERRGCMM). + public static LuaStatus GcMetamethodError => new(LuaApi.LUA_ERRGCMM); + + /// The message handler itself raised (LUA_ERRERR). + public static LuaStatus MessageHandlerError => new(LuaApi.LUA_ERRERR); + + /// A file could not be read (LUA_ERRFILE). Not produced by this assembly's own operations. + public static LuaStatus FileError => new(LuaApi.LUA_ERRFILE); + + /// Gets the raw code (LUA_OK is 0). + public int Code + { + get; + } = code; + + /// Gets a value indicating whether the operation succeeded. + public bool IsOk + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Code == LuaApi.LUA_OK; + } + + /// Compares two statuses by code. + /// First status. + /// Second status. + public static bool operator ==(LuaStatus left, LuaStatus right) + { + return left.Code == right.Code; + } + + /// Compares two statuses by code. + /// First status. + /// Second status. + public static bool operator !=(LuaStatus left, LuaStatus right) + { + return left.Code != right.Code; + } + + /// + public bool Equals(LuaStatus other) + { + return Code == other.Code; + } + + /// + public override bool Equals(object? obj) + { + return obj is LuaStatus other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Code; + } + + /// The C API name of the code (LUA_OK, LUA_ERRRUN, ...), or the number for an unknown code. + public override string ToString() + { + return Code switch + { + LuaApi.LUA_OK => "LUA_OK", + LuaApi.LUA_YIELD => "LUA_YIELD", + LuaApi.LUA_ERRRUN => "LUA_ERRRUN", + LuaApi.LUA_ERRSYNTAX => "LUA_ERRSYNTAX", + LuaApi.LUA_ERRMEM => "LUA_ERRMEM", + LuaApi.LUA_ERRGCMM => "LUA_ERRGCMM", + LuaApi.LUA_ERRERR => "LUA_ERRERR", + LuaApi.LUA_ERRFILE => "LUA_ERRFILE", + _ => Code.ToString(CultureInfo.InvariantCulture) + }; + } + + /// + /// Throws a built from the error value on top of 's stack when + /// the status is a failure; returns normally otherwise. The error value is left on the stack for the caller's + /// frame to discard. + /// + /// The state the failed operation ran on. + /// The status is not . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ThrowIfFailed(LuaState state) + { + if (Code != LuaApi.LUA_OK) + { + LuaException.ThrowFromStack(state, this); + } + } } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs index bd188961..fbdad622 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.State; @@ -28,124 +29,125 @@ namespace CheatEngine.SDK.Lua.CompilerServices; [EditorBrowsable(EditorBrowsableState.Never)] public static class LuaCallSupport { - /// - /// Restores the stack to (discarding an error value or a partial result) and returns - /// . - /// - /// The state the body ran on. - /// The top recorded at the start of the body. - /// , always. - [MethodImpl(MethodImplOptions.NoInlining)] - public static bool Fail(LuaState state, int top) - { - state.SetTop(top); - return false; - } + /// + /// Restores the stack to (discarding an error value or a partial result) and returns + /// . + /// + /// The state the body ran on. + /// The top recorded at the start of the body. + /// , always. + [MethodImpl(MethodImplOptions.NoInlining)] + public static bool Fail(LuaState state, int top) + { + state.SetTop(top); + return false; + } - /// - /// Restores the stack to , defaults the result and returns - /// . - /// - /// - /// The result type of the generated method: a value, a , or the - /// written count of a copy-out result; never a span into Lua's memory. - /// - /// The state the body ran on. - /// The top recorded at the start of the body. - /// Set to . - /// , always. - [MethodImpl(MethodImplOptions.NoInlining)] - public static bool Fail(LuaState state, int top, out TResult result) - { - state.SetTop(top); - result = default!; - return false; - } + /// + /// Restores the stack to , defaults the result and returns + /// . + /// + /// + /// The result type of the generated method: a value, a , or the + /// written count of a copy-out result; never a span into Lua's memory. + /// + /// The state the body ran on. + /// The top recorded at the start of the body. + /// Set to . + /// , always. + [MethodImpl(MethodImplOptions.NoInlining)] + public static bool Fail(LuaState state, int top, out TResult result) + { + state.SetTop(top); + result = default!; + return false; + } - /// - /// Restores the stack to and returns the factual detailed outcome for an opt-in - /// generated binding. - /// - /// The state the body ran on. - /// The top recorded at the start of the body. - /// The already classified outcome; no error text is read from Lua. - /// . - [MethodImpl(MethodImplOptions.NoInlining)] - public static LuaOperationStatus Fail(LuaState state, int top, LuaOperationStatus status) - { - state.SetTop(top); - return status; - } + /// + /// Restores the stack to and returns the factual detailed outcome for an opt-in + /// generated binding. + /// + /// The state the body ran on. + /// The top recorded at the start of the body. + /// The already classified outcome; no error text is read from Lua. + /// . + [MethodImpl(MethodImplOptions.NoInlining)] + public static LuaOperationStatus Fail(LuaState state, int top, LuaOperationStatus status) + { + state.SetTop(top); + return status; + } - /// - /// Restores the stack to , defaults the result and returns the - /// factual detailed outcome for an opt-in generated binding. - /// - /// The result type of the generated method. - /// The state the body ran on. - /// The top recorded at the start of the body. - /// The already classified outcome; no error text is read from Lua. - /// Set to . - /// . - [MethodImpl(MethodImplOptions.NoInlining)] - public static LuaOperationStatus Fail(LuaState state, int top, LuaOperationStatus status, out TResult result) - { - state.SetTop(top); - result = default!; - return status; - } + /// + /// Restores the stack to , defaults the result and returns the + /// factual detailed outcome for an opt-in generated binding. + /// + /// The result type of the generated method. + /// The state the body ran on. + /// The top recorded at the start of the body. + /// The already classified outcome; no error text is read from Lua. + /// Set to . + /// . + [MethodImpl(MethodImplOptions.NoInlining)] + public static LuaOperationStatus Fail(LuaState state, int top, LuaOperationStatus status, + out TResult result) + { + state.SetTop(top); + result = default!; + return status; + } - /// - /// Restores the stack to and throws a describing the error value - /// that was on top of the stack: the exit of a generated throwing wrapper when the protected call failed. - /// - /// The state the body ran on. - /// The top recorded at the start of the body. - /// The failure status. - /// Always. - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - public static void Throw(LuaState state, int top, LuaStatus status) - { - var error = LuaError.FromStack(state, status); - state.SetTop(top); - LuaException.Throw(error); - } + /// + /// Restores the stack to and throws a describing the error value + /// that was on top of the stack: the exit of a generated throwing wrapper when the protected call failed. + /// + /// The state the body ran on. + /// The top recorded at the start of the body. + /// The failure status. + /// Always. + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + public static void Throw(LuaState state, int top, LuaStatus status) + { + LuaError error = LuaError.FromStack(state, status); + state.SetTop(top); + LuaException.Throw(error); + } - /// - /// Restores the stack to and throws because a bound global could not be resolved (undefined - /// or not a function): the exit of a generated throwing wrapper when fails. - /// - /// The state the body ran on. - /// The top recorded at the start of the body. - /// The name the body tried to bind. - /// Always. - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - public static void ThrowUnresolvedGlobal(LuaState state, int top, string globalName) - { - state.SetTop(top); - throw new LuaException("The Lua global '" + globalName + "' is undefined or is not a function."); - } + /// + /// Restores the stack to and throws because a bound global could not be resolved (undefined + /// or not a function): the exit of a generated throwing wrapper when fails. + /// + /// The state the body ran on. + /// The top recorded at the start of the body. + /// The name the body tried to bind. + /// Always. + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + public static void ThrowUnresolvedGlobal(LuaState state, int top, string globalName) + { + state.SetTop(top); + throw new LuaException("The Lua global '" + globalName + "' is undefined or is not a function."); + } - /// - /// Names the Lua type of the value at , restores the stack to and - /// throws: the exit of a generated throwing wrapper when the call succeeded but a result could not be read as - /// the declared type (The Lua global 'readInteger' returned a nil value, not an integer.). - /// - /// The state the body ran on. - /// The top recorded at the start of the body. - /// The index of the offending result, still on the stack (-1 for a single result). - /// The name of the bound global. - /// What the wrapper expected, with its article: "an integer", "a string". - /// Always. - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - public static void ThrowUnexpectedResult(LuaState state, int top, int index, string globalName, string expected) - { - var typeName = Encoding.UTF8.GetString(state.TypeName(index)); - state.SetTop(top); - throw new LuaException("The Lua global '" + globalName + "' returned a " + typeName + " value, not " + - expected + "."); - } + /// + /// Names the Lua type of the value at , restores the stack to and + /// throws: the exit of a generated throwing wrapper when the call succeeded but a result could not be read as + /// the declared type (The Lua global 'readInteger' returned a nil value, not an integer.). + /// + /// The state the body ran on. + /// The top recorded at the start of the body. + /// The index of the offending result, still on the stack (-1 for a single result). + /// The name of the bound global. + /// What the wrapper expected, with its article: "an integer", "a string". + /// Always. + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + public static void ThrowUnexpectedResult(LuaState state, int top, int index, string globalName, string expected) + { + string typeName = Encoding.UTF8.GetString(state.TypeName(index)); + state.SetTop(top); + throw new LuaException("The Lua global '" + globalName + "' returned a " + typeName + " value, not " + + expected + "."); + } } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalFunctions.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalFunctions.cs index afe23399..7f091f3c 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalFunctions.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalFunctions.cs @@ -2,6 +2,8 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Threading; + +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -31,110 +33,114 @@ namespace CheatEngine.SDK.Lua.CompilerServices; [EditorBrowsable(EditorBrowsableState.Never)] public static class LuaGlobalFunctions { - private static readonly Lock SResolveGate = new(); + private static readonly Lock SResolveGate = new(); + + /// + /// Pushes the global function , resolving and caching it in on + /// first use and whenever the cached slot is stale. Stack: +1 on ; +0 on + /// . + /// + /// The state to push on; the calling thread's. + /// + /// The reference that caches the resolution; a static readonly field created with + /// new LuaRef(). + /// + /// The global name, UTF-8; a "..."u8 literal. + /// + /// when the global is undefined, is not a function, or reading it raised; the stack is + /// unchanged then. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryPush(LuaState state, LuaRef cache, ReadOnlySpan name) + { + return TryPushWithStatus(state, cache, name) == LuaGlobalPushStatus.Success; + } - /// - /// Pushes the global function , resolving and caching it in on - /// first use and whenever the cached slot is stale. Stack: +1 on ; +0 on - /// . - /// - /// The state to push on; the calling thread's. - /// - /// The reference that caches the resolution; a static readonly field created with - /// new LuaRef(). - /// - /// The global name, UTF-8; a "..."u8 literal. - /// - /// when the global is undefined, is not a function, or reading it raised; the stack is - /// unchanged then. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryPush(LuaState state, LuaRef cache, ReadOnlySpan name) - { - return TryPushWithStatus(state, cache, name) == LuaGlobalPushStatus.Success; - } + /// + /// Pushes a global function and retains whether protected global lookup itself failed. This is for SDK layers + /// that expose a distinct Lua-error category; generated bindings should continue using . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static LuaGlobalPushStatus TryPushWithStatus(LuaState state, LuaRef cache, ReadOnlySpan name) + { + return TryPushWithOutcome(state, cache, name).Status; + } - /// - /// Pushes a global function and retains whether protected global lookup itself failed. This is for SDK layers - /// that expose a distinct Lua-error category; generated bindings should continue using . - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static LuaGlobalPushStatus TryPushWithStatus(LuaState state, LuaRef cache, ReadOnlySpan name) - { - return TryPushWithOutcome(state, cache, name).Status; - } + /// + /// Pushes a global function and preserves the protected Lua status of an unsuccessful resolution. Generated + /// outcome bindings use this value-only result so that they do not need to inspect transient Lua error text. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static LuaGlobalPushOutcome TryPushWithOutcome(LuaState state, LuaRef cache, ReadOnlySpan name) + { + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(state); + if (state.TryPushRef(cache)) + { + if (state.IsFunction(-1)) + { + return LuaGlobalPushOutcome.Success; + } - /// - /// Pushes a global function and preserves the protected Lua status of an unsuccessful resolution. Generated - /// outcome bindings use this value-only result so that they do not need to inspect transient Lua error text. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static LuaGlobalPushOutcome TryPushWithOutcome(LuaState state, LuaRef cache, ReadOnlySpan name) - { - using var operation = LuaRuntime.EnterStateOperation(state); - if (state.TryPushRef(cache)) - { - if (state.IsFunction(-1)) return LuaGlobalPushOutcome.Success; - state.Pop(1); - } + state.Pop(1); + } - return Resolve(state, cache, name); - } + return Resolve(state, cache, name); + } - [MethodImpl(MethodImplOptions.NoInlining)] - private static LuaGlobalPushOutcome Resolve(LuaState state, LuaRef cache, ReadOnlySpan name) - { - var top = state.Top; - // Capture both the attachment epoch and state generation before Lua can run. A globals __index handler can - // execute a supported in-place reset, which preserves the attach epoch but makes old registry slots unsafe. - var identity = LuaRuntime.CurrentStateIdentity; - // A globals __index handler can execute arbitrary Lua, including a cross-thread synchronize call. - // Do not hold the resolution gate while it runs. - var status = state.TryGetGlobal(name); - if (!status.IsOk) - { - state.SetTop(top); - return LuaGlobalPushOutcome.LuaFailure(status); - } + [MethodImpl(MethodImplOptions.NoInlining)] + private static LuaGlobalPushOutcome Resolve(LuaState state, LuaRef cache, ReadOnlySpan name) + { + int top = state.Top; + // Capture both the attachment epoch and state generation before Lua can run. A globals __index handler can + // execute a supported in-place reset, which preserves the attach epoch but makes old registry slots unsafe. + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + // A globals __index handler can execute arbitrary Lua, including a cross-thread synchronize call. + // Do not hold the resolution gate while it runs. + LuaStatus status = state.TryGetGlobal(name); + if (!status.IsOk) + { + state.SetTop(top); + return LuaGlobalPushOutcome.LuaFailure(status); + } - if (!state.IsFunction(-1)) - { - state.SetTop(top); - return LuaGlobalPushOutcome.Unavailable; - } + if (!state.IsFunction(-1)) + { + state.SetTop(top); + return LuaGlobalPushOutcome.Unavailable; + } - lock (SResolveGate) - { - if (identity != LuaRuntime.CurrentStateIdentity) - { - state.SetTop(top); - return LuaGlobalPushOutcome.Unavailable; - } + lock (SResolveGate) + { + if (identity != LuaRuntime.CurrentStateIdentity) + { + state.SetTop(top); + return LuaGlobalPushOutcome.Unavailable; + } - // Another thread may have resolved it while this one waited for the gate. - if (state.TryPushRef(cache)) - { - if (state.IsFunction(-1)) - { - state.Remove(-2); - return LuaGlobalPushOutcome.Success; - } + // Another thread may have resolved it while this one waited for the gate. + if (state.TryPushRef(cache)) + { + if (state.IsFunction(-1)) + { + state.Remove(-2); + return LuaGlobalPushOutcome.Success; + } - state.Pop(1); - } + state.Pop(1); + } - state.PushValue(-1); - status = LuaReferences.Create(state, out var reference); - if (!status.IsOk) - { - state.SetTop(top); - return LuaGlobalPushOutcome.LuaFailure(status); - } + state.PushValue(-1); + status = LuaReferences.Create(state, out int reference); + if (!status.IsOk) + { + state.SetTop(top); + return LuaGlobalPushOutcome.LuaFailure(status); + } - // Use the snapshot from before TryGetGlobal. Rebinding an old slot with the current generation would make it - // appear usable after a reset that ran from __index. - cache.Rebind(reference, identity); - return LuaGlobalPushOutcome.Success; - } - } + // Use the snapshot from before TryGetGlobal. Rebinding an old slot with the current generation would make it + // appear usable after a reset that ran from __index. + cache.Rebind(reference, identity); + return LuaGlobalPushOutcome.Success; + } + } } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs index 6caa0bb9..7624e306 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Lua.CompilerServices; @@ -9,42 +10,48 @@ namespace CheatEngine.SDK.Lua.CompilerServices; [StructLayout(LayoutKind.Sequential)] public readonly struct LuaGlobalPushOutcome { - private LuaGlobalPushOutcome(LuaGlobalPushStatus status, LuaStatus luaStatus) - { - Status = status; - LuaStatus = luaStatus; - } - - /// Gets the resolution category. - public LuaGlobalPushStatus Status { get; } - - /// Gets the protected Lua status when is . - public LuaStatus LuaStatus { get; } - - /// Gets whether the global function was pushed. - public bool IsSuccess => Status == LuaGlobalPushStatus.Success; - - /// Gets a successful resolution. - public static LuaGlobalPushOutcome Success => default; - - /// Creates an unavailable resolution. - public static LuaGlobalPushOutcome Unavailable => new(LuaGlobalPushStatus.Unavailable, LuaStatus.Ok); - - /// Creates a protected Lua resolution failure. - /// The protected Lua status. - public static LuaGlobalPushOutcome LuaFailure(LuaStatus luaStatus) - { - return new(LuaGlobalPushStatus.LuaFailure, luaStatus); - } - - /// Projects this resolution into a generated binding status. - public LuaOperationStatus ToOperationStatus() - { - return Status switch - { - LuaGlobalPushStatus.Success => LuaOperationStatus.Success, - LuaGlobalPushStatus.Unavailable => LuaOperationStatus.GlobalUnavailable, - _ => LuaOperationStatus.LuaFailure(LuaStatus), - }; - } + private LuaGlobalPushOutcome(LuaGlobalPushStatus status, LuaStatus luaStatus) + { + Status = status; + LuaStatus = luaStatus; + } + + /// Gets the resolution category. + public LuaGlobalPushStatus Status + { + get; + } + + /// Gets the protected Lua status when is . + public LuaStatus LuaStatus + { + get; + } + + /// Gets whether the global function was pushed. + public bool IsSuccess => Status == LuaGlobalPushStatus.Success; + + /// Gets a successful resolution. + public static LuaGlobalPushOutcome Success => default; + + /// Creates an unavailable resolution. + public static LuaGlobalPushOutcome Unavailable => new(LuaGlobalPushStatus.Unavailable, LuaStatus.Ok); + + /// Creates a protected Lua resolution failure. + /// The protected Lua status. + public static LuaGlobalPushOutcome LuaFailure(LuaStatus luaStatus) + { + return new LuaGlobalPushOutcome(LuaGlobalPushStatus.LuaFailure, luaStatus); + } + + /// Projects this resolution into a generated binding status. + public LuaOperationStatus ToOperationStatus() + { + return Status switch + { + LuaGlobalPushStatus.Success => LuaOperationStatus.Success, + LuaGlobalPushStatus.Unavailable => LuaOperationStatus.GlobalUnavailable, + _ => LuaOperationStatus.LuaFailure(LuaStatus) + }; + } } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs index e746bc2d..5b33c105 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs @@ -6,12 +6,12 @@ namespace CheatEngine.SDK.Lua.CompilerServices; [EditorBrowsable(EditorBrowsableState.Never)] public enum LuaGlobalPushStatus { - /// The function was pushed. - Success, + /// The function was pushed. + Success, - /// The global was absent or was not a function. - Unavailable, + /// The global was absent or was not a function. + Unavailable, - /// A protected global lookup or reference creation failed. - LuaFailure, + /// A protected global lookup or reference creation failed. + LuaFailure } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs index 6d4fbfbb..95ea2039 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -21,33 +22,33 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// public readonly struct AddressMarshaller : ILuaMarshaller { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, nuint value) - { - state.PushInteger(unchecked((long)value)); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, nuint value) + { + state.PushInteger(unchecked((long) value)); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out nuint value) - { - if (state.TypeOf(index) != LuaType.Number || !state.TryReadInteger(index, out var bits)) - { - value = 0; - return false; - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out nuint value) + { + if (state.TypeOf(index) != LuaType.Number || !state.TryReadInteger(index, out long bits)) + { + value = 0; + return false; + } - var wide = unchecked((ulong)bits); - if (nuint.Size == sizeof(uint) && wide > uint.MaxValue) - { - value = 0; - return false; - } + ulong wide = unchecked((ulong) bits); + if (nuint.Size == sizeof(uint) && wide > uint.MaxValue) + { + value = 0; + return false; + } - value = unchecked((nuint)wide); - return true; - } + value = unchecked((nuint) wide); + return true; + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/BooleanMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/BooleanMarshaller.cs index 766a9795..2d98dcc2 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/BooleanMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/BooleanMarshaller.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -13,26 +14,26 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// One C API call to push, two to read (type, then value); allocates nothing. public readonly struct BooleanMarshaller : ILuaMarshaller { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, bool value) - { - state.PushBoolean(value); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, bool value) + { + state.PushBoolean(value); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out bool value) - { - if (state.TypeOf(index) != LuaType.Boolean) - { - value = false; - return false; - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out bool value) + { + if (state.TypeOf(index) != LuaType.Boolean) + { + value = false; + return false; + } - value = state.ToBoolean(index); - return true; - } + value = state.ToBoolean(index); + return true; + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/DoubleMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/DoubleMarshaller.cs index d4cc571c..44c8dc18 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/DoubleMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/DoubleMarshaller.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -11,19 +12,19 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// One C API call each way; allocates nothing. public readonly struct DoubleMarshaller : ILuaMarshaller { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, double value) - { - state.PushNumber(value); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, double value) + { + state.PushNumber(value); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out double value) - { - return state.TryReadNumber(index, out value); - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out double value) + { + return state.TryReadNumber(index, out value); + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/ILuaMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/ILuaMarshaller.cs index fee804e5..6c09659d 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/ILuaMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/ILuaMarshaller.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -43,19 +44,19 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// /// public interface ILuaMarshaller - where T : allows ref struct + where T : allows ref struct { - /// Pushes as one Lua value. - /// The state to push on. - /// The value. - [LuaStackEffect(1)] - public static abstract void Push(LuaState state, T value); + /// Pushes as one Lua value. + /// The state to push on. + /// The value. + [LuaStackEffect(1)] + public static abstract void Push(LuaState state, T value); - /// Reads the value at as a without changing the stack. - /// The state to read from. - /// An acceptable index. - /// The read value, or when the Lua value is not of the expected kind. - /// when holds a read value. - [LuaStackEffect(0)] - public static abstract bool TryRead(LuaState state, int index, [MaybeNullWhen(false)] out T value); + /// Reads the value at as a without changing the stack. + /// The state to read from. + /// An acceptable index. + /// The read value, or when the Lua value is not of the expected kind. + /// when holds a read value. + [LuaStackEffect(0)] + public static abstract bool TryRead(LuaState state, int index, [MaybeNullWhen(false)] out T value); } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs index 52bcafdd..64b477c0 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -14,26 +15,26 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// public readonly struct Int32Marshaller : ILuaMarshaller { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, int value) - { - state.PushInteger(value); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, int value) + { + state.PushInteger(value); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out int value) - { - if (state.TryReadInteger(index, out var wide) && wide >= int.MinValue && wide <= int.MaxValue) - { - value = (int)wide; - return true; - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out int value) + { + if (state.TryReadInteger(index, out long wide) && wide >= int.MinValue && wide <= int.MaxValue) + { + value = (int) wide; + return true; + } - value = 0; - return false; - } + value = 0; + return false; + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs index a3524398..571a28d8 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -15,19 +16,19 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// public readonly struct Int64Marshaller : ILuaMarshaller { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, long value) - { - state.PushInteger(value); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, long value) + { + state.PushInteger(value); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out long value) - { - return state.TryReadInteger(index, out value); - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out long value) + { + return state.TryReadInteger(index, out value); + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/SingleMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/SingleMarshaller.cs index 169f0e1e..fce2c7c2 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/SingleMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/SingleMarshaller.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -12,21 +13,21 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// One C API call each way; allocates nothing. public readonly struct SingleMarshaller : ILuaMarshaller { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, float value) - { - state.PushNumber(value); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, float value) + { + state.PushNumber(value); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out float value) - { - var ok = state.TryReadNumber(index, out var wide); - value = (float)wide; - return ok; - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out float value) + { + bool ok = state.TryReadNumber(index, out double wide); + value = (float) wide; + return ok; + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/StringMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/StringMarshaller.cs index 58ac0afe..afcdab5d 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/StringMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/StringMarshaller.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Text; @@ -22,24 +23,24 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// public readonly struct StringMarshaller : ILuaMarshaller { - /// Pushes ; is pushed as nil. - /// - [LuaStackEffect(1)] - public static void Push(LuaState state, string? value) - { - if (value is null) - { - state.PushNil(); - return; - } + /// Pushes ; is pushed as nil. + /// + [LuaStackEffect(1)] + public static void Push(LuaState state, string? value) + { + if (value is null) + { + state.PushNil(); + return; + } - state.PushString(value.AsSpan()); - } + state.PushString(value.AsSpan()); + } - /// - [LuaStackEffect(0)] - public static bool TryRead(LuaState state, int index, [MaybeNullWhen(false)] out string value) - { - return state.TryReadString(index, out value); - } + /// + [LuaStackEffect(0)] + public static bool TryRead(LuaState state, int index, [MaybeNullWhen(false)] out string value) + { + return state.TryReadString(index, out value); + } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/Utf8Marshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/Utf8Marshaller.cs index 0f47b45f..afe9e37d 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/Utf8Marshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/Utf8Marshaller.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State; @@ -16,19 +17,19 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// public readonly struct Utf8Marshaller : ILuaMarshaller> { - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Push(LuaState state, ReadOnlySpan value) - { - state.PushString(value); - } + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Push(LuaState state, ReadOnlySpan value) + { + state.PushString(value); + } - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryRead(LuaState state, int index, out ReadOnlySpan value) - { - return state.TryReadUtf8(index, out value); - } + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, out ReadOnlySpan value) + { + return state.TryReadUtf8(index, out value); + } } diff --git a/libs/CheatEngine.SDK.Lua/Protected/LuaHelper.cs b/libs/CheatEngine.SDK.Lua/Protected/LuaHelper.cs index 4d5eb8f2..ac63c549 100644 --- a/libs/CheatEngine.SDK.Lua/Protected/LuaHelper.cs +++ b/libs/CheatEngine.SDK.Lua/Protected/LuaHelper.cs @@ -9,33 +9,33 @@ namespace CheatEngine.SDK.Lua.Protected; /// internal enum LuaHelper { - /// - /// function(f): returns a closure that calls f and turns the error-channel sentinel into - /// error(message, 2). - /// - Wrap = 0, + /// + /// function(f): returns a closure that calls f and turns the error-channel sentinel into + /// error(message, 2). + /// + Wrap = 0, - /// function(k): return _ENV[k]. - GetGlobal = 1, + /// function(k): return _ENV[k]. + GetGlobal = 1, - /// function(k, v): _ENV[k] = v. - SetGlobal = 2, + /// function(k, v): _ENV[k] = v. + SetGlobal = 2, - /// function(o, k): return o[k]. - Index = 3, + /// function(o, k): return o[k]. + Index = 3, - /// function(o, k, v): o[k] = v. - NewIndex = 4, + /// function(o, k, v): o[k] = v. + NewIndex = 4, - /// function(o): return #o. - Length = 5, + /// function(o): return #o. + Length = 5, - /// function(v): return tostring(v), with the tostring captured when the chunk ran. - ToString = 6, + /// function(v): return tostring(v), with the tostring captured when the chunk ran. + ToString = 6, - /// function(op, a, b): ==, < or <= by . - Compare = 7, + /// function(op, a, b): ==, < or <= by . + Compare = 7, - /// function(t, k): return next(t, k), with the next captured when the chunk ran. - Next = 8 + /// function(t, k): return next(t, k), with the next captured when the chunk ran. + Next = 8 } diff --git a/libs/CheatEngine.SDK.Lua/Protected/LuaHelpers.cs b/libs/CheatEngine.SDK.Lua/Protected/LuaHelpers.cs index 474ddc0c..7ff58b43 100644 --- a/libs/CheatEngine.SDK.Lua/Protected/LuaHelpers.cs +++ b/libs/CheatEngine.SDK.Lua/Protected/LuaHelpers.cs @@ -1,11 +1,13 @@ using System; using System.Diagnostics; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Protected; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.State; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Protected; @@ -52,143 +54,158 @@ namespace CheatEngine.SDK.Lua.Protected; /// internal static unsafe class LuaHelpers { - /// Number of functions the chunk returns. - internal const int Count = (int)LuaHelper.Next + 1; - - /// - /// Free stack slots needs above the caller's top: the chunk, its argument, and - /// lua_pcallk's requirement that the frame has room for nresults - nargs more values - /// (Count - 1), rounded up by one. - /// - internal const int InstallStackSlots = Count + 2; - - // Key block: Count helper keys followed by the sentinel address. The bytes are only ever addressed. - private const int SentinelOffset = Count; - private const int KeyBlockSize = Count + 1; - - private static readonly nint SKeyBlock = - RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(LuaHelpers), KeyBlockSize); - - /// - /// The helper chunk. It receives the sentinel as its single argument and returns the functions of - /// in enum order. Written for Lua 5.3: _ENV is the globals table of the state at - /// load time, error, tostring and next are captured once so later redefinitions by scripts - /// do not change the SDK's behaviour, and error(message, 2) blames the caller of the wrapped function. - /// - internal static ReadOnlySpan Source => """ - local SENTINEL = ... - local error, tostring, next = error, tostring, next - local function check(...) - local first = ... - if first == SENTINEL then - local _, message = ... - error(message, 2) - end - return ... - end - return - function(f) return function(...) return check(f(...)) end end, - function(k) return _ENV[k] end, - function(k, v) _ENV[k] = v end, - function(o, k) return o[k] end, - function(o, k, v) o[k] = v end, - function(o) return #o end, - function(v) return tostring(v) end, - function(op, a, b) - if op == 0 then return a == b elseif op == 1 then return a < b else return a <= b end - end, - function(t, k) return next(t, k) end - """u8; - - /// Address used as the error-channel sentinel light userdata. Stable for the life of the load context. - internal static nint SentinelAddress => SKeyBlock + SentinelOffset; - - /// Pushes the sentinel light userdata. Never raises. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static void PushSentinel(lua_State* l) - { - lua_pushlightuserdata(l, (void*)SentinelAddress); - } - - /// Whether the value at is the sentinel. - internal static bool IsSentinel(lua_State* l, int index) - { - return lua_type(l, index) == LUA_TLIGHTUSERDATA && (nint)lua_touserdata(l, index) == SentinelAddress; - } - - /// - /// Pushes the helper of the state, compiling and installing the chunk on first use. - /// Stack: +1 on success (the function); +1 on failure (the error value of the failed load or run). - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static LuaStatus Push(lua_State* l, LuaHelper helper) - { - if (lua_rawgetp(l, LUA_REGISTRYINDEX, Key(helper)) == LUA_TFUNCTION) return LuaStatus.Ok; - - lua_settop(l, -2); - return InstallAndPush(l, helper); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static LuaStatus InstallAndPush(lua_State* l, LuaHelper helper) - { - var status = Install(l); - if (!status.IsOk) return status; - - var type = lua_rawgetp(l, LUA_REGISTRYINDEX, Key(helper)); - Debug.Assert(type == LUA_TFUNCTION, "The helper chunk ran but did not install this helper."); - return LuaStatus.Ok; - } - - /// - /// Compiles and runs the chunk on and stores its results in the registry. Stack: +0 on - /// success, +1 (error value) on failure. Safe to run again on a state that already has the helpers: the new set - /// replaces the old one. - /// - /// - /// The C API requires the caller of lua_pcallk to have room for the results - /// (lapi.c, checkresults), so the first protected operation in a state asks for - /// free slots; every later one needs at most four. lua_checkstack can only - /// fail when the stack is at its hard limit or the process is out of memory; then the one error value this - /// method still owes its caller is nil, because pushing a string would allocate. - /// - internal static LuaStatus Install(lua_State* L) - { - if (lua_checkstack(L, InstallStackSlots) == 0) - { - lua_pushnil(L); - return LuaStatus.MemoryError; - } - - var top = lua_gettop(L); - int loadStatus; - fixed (byte* source = Source) - fixed (byte* name = "=CheatEngine.SDK.Lua"u8) - fixed (byte* mode = "t"u8) - { - loadStatus = luaL_loadbufferx(L, source, (nuint)Source.Length, name, mode); - } - - if (loadStatus != LUA_OK) return new LuaStatus(loadStatus); - - PushSentinel(L); - var runStatus = lua_pcallk(L, 1, Count, 0, 0, null); - if (runStatus != LUA_OK) return new LuaStatus(runStatus); - - // The last result is on top; rawsetp pops the top each time, so the keys are assigned in reverse. - for (var i = Count - 1; i >= 0; i--) - { - var status = new LuaStatus(LuaProtectedApi.RawSetP(L, LUA_REGISTRYINDEX, SKeyBlock + i)); - if (!status.IsOk) return new LuaState((nint)L).KeepProtectedError(top, status); - } - - Debug.Assert(lua_gettop(L) == top, "Installing the helpers left the stack unbalanced."); - return LuaStatus.Ok; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void* Key(LuaHelper helper) - { - return (void*)(SKeyBlock + (int)helper); - } + /// Number of functions the chunk returns. + internal const int Count = (int) LuaHelper.Next + 1; + + /// + /// Free stack slots needs above the caller's top: the chunk, its argument, and + /// lua_pcallk's requirement that the frame has room for nresults - nargs more values + /// (Count - 1), rounded up by one. + /// + internal const int InstallStackSlots = Count + 2; + + // Key block: Count helper keys followed by the sentinel address. The bytes are only ever addressed. + private const int SentinelOffset = Count; + private const int KeyBlockSize = Count + 1; + + private static readonly nint SKeyBlock = + RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(LuaHelpers), KeyBlockSize); + + /// + /// The helper chunk. It receives the sentinel as its single argument and returns the functions of + /// in enum order. Written for Lua 5.3: _ENV is the globals table of the state at + /// load time, error, tostring and next are captured once so later redefinitions by scripts + /// do not change the SDK's behaviour, and error(message, 2) blames the caller of the wrapped function. + /// + internal static ReadOnlySpan Source => """ + local SENTINEL = ... + local error, tostring, next = error, tostring, next + local function check(...) + local first = ... + if first == SENTINEL then + local _, message = ... + error(message, 2) + end + return ... + end + return + function(f) return function(...) return check(f(...)) end end, + function(k) return _ENV[k] end, + function(k, v) _ENV[k] = v end, + function(o, k) return o[k] end, + function(o, k, v) o[k] = v end, + function(o) return #o end, + function(v) return tostring(v) end, + function(op, a, b) + if op == 0 then return a == b elseif op == 1 then return a < b else return a <= b end + end, + function(t, k) return next(t, k) end + """u8; + + /// Address used as the error-channel sentinel light userdata. Stable for the life of the load context. + internal static nint SentinelAddress => SKeyBlock + SentinelOffset; + + /// Pushes the sentinel light userdata. Never raises. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void PushSentinel(lua_State* l) + { + lua_pushlightuserdata(l, (void*) SentinelAddress); + } + + /// Whether the value at is the sentinel. + internal static bool IsSentinel(lua_State* l, int index) + { + return lua_type(l, index) == LUA_TLIGHTUSERDATA && (nint) lua_touserdata(l, index) == SentinelAddress; + } + + /// + /// Pushes the helper of the state, compiling and installing the chunk on first use. + /// Stack: +1 on success (the function); +1 on failure (the error value of the failed load or run). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static LuaStatus Push(lua_State* l, LuaHelper helper) + { + if (lua_rawgetp(l, LUA_REGISTRYINDEX, Key(helper)) == LUA_TFUNCTION) + { + return LuaStatus.Ok; + } + + lua_settop(l, -2); + return InstallAndPush(l, helper); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static LuaStatus InstallAndPush(lua_State* l, LuaHelper helper) + { + LuaStatus status = Install(l); + if (!status.IsOk) + { + return status; + } + + int type = lua_rawgetp(l, LUA_REGISTRYINDEX, Key(helper)); + Debug.Assert(type == LUA_TFUNCTION, "The helper chunk ran but did not install this helper."); + return LuaStatus.Ok; + } + + /// + /// Compiles and runs the chunk on and stores its results in the registry. Stack: +0 on + /// success, +1 (error value) on failure. Safe to run again on a state that already has the helpers: the new set + /// replaces the old one. + /// + /// + /// The C API requires the caller of lua_pcallk to have room for the results + /// (lapi.c, checkresults), so the first protected operation in a state asks for + /// free slots; every later one needs at most four. lua_checkstack can only + /// fail when the stack is at its hard limit or the process is out of memory; then the one error value this + /// method still owes its caller is nil, because pushing a string would allocate. + /// + internal static LuaStatus Install(lua_State* L) + { + if (lua_checkstack(L, InstallStackSlots) == 0) + { + lua_pushnil(L); + return LuaStatus.MemoryError; + } + + int top = lua_gettop(L); + int loadStatus; + fixed (byte* source = Source) + fixed (byte* name = "=CheatEngine.SDK.Lua"u8) + fixed (byte* mode = "t"u8) + { + loadStatus = luaL_loadbufferx(L, source, (nuint) Source.Length, name, mode); + } + + if (loadStatus != LUA_OK) + { + return new LuaStatus(loadStatus); + } + + PushSentinel(L); + int runStatus = lua_pcallk(L, 1, Count, 0, 0, null); + if (runStatus != LUA_OK) + { + return new LuaStatus(runStatus); + } + + // The last result is on top; rawsetp pops the top each time, so the keys are assigned in reverse. + for (int i = Count - 1; i >= 0; i--) + { + LuaStatus status = new(LuaProtectedApi.RawSetP(L, LUA_REGISTRYINDEX, SKeyBlock + i)); + if (!status.IsOk) + { + return new LuaState((nint) L).KeepProtectedError(top, status); + } + } + + Debug.Assert(lua_gettop(L) == top, "Installing the helpers left the stack unbalanced."); + return LuaStatus.Ok; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void* Key(LuaHelper helper) + { + return (void*) (SKeyBlock + (int) helper); + } } diff --git a/libs/CheatEngine.SDK.Lua/README.md b/libs/CheatEngine.SDK.Lua/README.md index 0b18e207..55c867fa 100644 --- a/libs/CheatEngine.SDK.Lua/README.md +++ b/libs/CheatEngine.SDK.Lua/README.md @@ -27,7 +27,7 @@ balanced, and keeps the hot paths free of allocations. | `CheatEngine.SDK.Lua.References` | `LuaRef` | Registry reference stamped with attachment epoch and state generation | | `CheatEngine.SDK.Lua.Callbacks` | `LuaNativeFunction`, `LuaCallback`, `LuaCallback`, `LuaThunk` | Managed functions that Lua can call | | `CheatEngine.SDK.Lua.CompilerServices` | `LuaGlobalFunctions`, `LuaCallSupport` | Called by generated code, hidden from IntelliSense | -| `CheatEngine.SDK.Lua.Registration` | `LuaRegistrationSet`, `LuaRegistrationLease`, `LuaRegistrationResult` | Ownership-aware generated-global publication and cleanup outcomes | +| `CheatEngine.SDK.Lua.Registration` | `LuaRegistrationSet`, `LuaRegistrationLease`, `LuaRegistrationResult` | Ownership-aware generated-global publication and cleanup outcomes | `LuaState` is a pointer-sized `readonly struct` over a borrowed `lua_State*`. Raw members make one or two C calls and never run Lua code. Protected members (`TryCall`, `TryLoad`, `TryExecute`, `TryGetGlobal`, `TryGetField`, `TryLength`, @@ -142,7 +142,8 @@ static class MemoryReads Generated bodies restore the stack in `finally`, including a managed exception from a marshaller. A body also handles: the global is unresolved, protected global lookup itself failed, the call raised, or the result is `nil` or of the wrong -type. A `Try*` form returns `false` through `LuaCallSupport.Fail`. A throwing form calls `ThrowUnresolvedGlobal`, `Throw` +type. A `Try*` form returns `false` through `LuaCallSupport.Fail`. A throwing form calls `ThrowUnresolvedGlobal`, +`Throw` or `ThrowUnexpectedResult`, which restore the stack and throw `LuaException`. ### String results diff --git a/libs/CheatEngine.SDK.Lua/References/LuaRef.cs b/libs/CheatEngine.SDK.Lua/References/LuaRef.cs index b57c5830..c873039e 100644 --- a/libs/CheatEngine.SDK.Lua/References/LuaRef.cs +++ b/libs/CheatEngine.SDK.Lua/References/LuaRef.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Threading; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -40,152 +41,160 @@ namespace CheatEngine.SDK.Lua.References; /// public sealed class LuaRef : IDisposable { - private const int NoReference = LuaApi.LUA_NOREF; - - // A reference write publishes slot and both identity components together. The hot path only reads the immutable - // object; rebinding and releasing are already cold, serialized paths. - private LuaRefBinding? _binding; - - /// - /// Creates an unresolved reference: is until code binds it - /// (see CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions). - /// - /// Runs no Lua code, so it may be a static field initializer of a class that binds globals lazily. - public LuaRef() - { - _binding = null; - } - - internal LuaRef(int reference, LuaStateIdentity identity) - { - _binding = new LuaRefBinding(reference, identity); - } - - /// - /// Gets the slot in the SDK's private reference table, or LUA_NOREF (-2) when unresolved or released. - /// LUA_REFNIL (-1) is a - /// valid reference to nil. - /// - public int Reference => Volatile.Read(ref _binding)?.Reference ?? NoReference; - - /// - /// Gets the complete Lua state identity the reference was created in, or the default identity for an unresolved - /// or released reference. - /// - public LuaStateIdentity Identity => Volatile.Read(ref _binding)?.Identity ?? default; - - /// - /// Gets the attach epoch component of ; 0 for an unresolved or released reference. - /// - public int Epoch => Identity.AttachEpoch; - - /// - /// Gets the state generation component of ; 0 for an unresolved or released reference. - /// - public int StateGeneration => Identity.StateGeneration; - - /// - /// Gets a value indicating whether the reference holds a slot at all (resolved and not released), whatever its - /// identity. - /// - public bool IsResolved => Reference != NoReference; - - /// - /// Gets a value indicating whether the reference holds a slot created in the current - /// : the only state in which it may be pushed or released. - /// - public bool IsCurrent - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => TryGetCurrent(out _); - } - - /// - /// with the state acquired from . When the runtime is detached the - /// slot cannot be reached and the reference is only marked released. Prefer where a state is at - /// hand. - /// - public void Dispose() - { - if (!LuaRuntime.TryAcquireOperation(out var operation)) - { - Release(default); - return; - } - - using (operation) - { - Release(operation.State); - } - } - - /// Reads the slot when the reference is current. One binding read and one identity comparison. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal bool TryGetCurrent(out int reference) - { - var binding = Volatile.Read(ref _binding); - reference = binding?.Reference ?? NoReference; - return binding is not null && reference != NoReference && binding.Identity == LuaRuntime.CurrentStateIdentity; - } - - /// - /// Points the reference at a slot of the given state identity. The previous slot, if any, is not released: the - /// caller decides whether it still belongs to a live state. - /// - internal void Rebind(int reference, LuaStateIdentity identity) - { - lock (LuaReferences.Gate) - { - Volatile.Write(ref _binding, new LuaRefBinding(reference, identity)); - } - } - - // Temporary compatibility for call sites being migrated to the complete identity. New code must capture and - // revalidate CurrentStateIdentity around any operation that can run Lua before using the overload above. - internal void Rebind(int reference, int epoch) - { - Rebind(reference, new LuaStateIdentity(epoch, LuaRuntime.StateGeneration)); - } - - /// - /// Releases the registry slot (luaL_unref) when the reference is current, and marks the reference released - /// in every case. Safe to call on an unresolved, stale or already released reference: nothing happens then. - /// - /// A state of the Lua universe the reference was created in; the calling thread's state. - public void Release(LuaState state) - { - var operation = state.IsNull ? default : LuaRuntime.EnterStateOperation(state); - try - { - lock (LuaReferences.Gate) - { - var binding = Interlocked.Exchange(ref _binding, value: null); - if (binding is not null && binding.Reference != NoReference && - binding.Identity == LuaRuntime.CurrentStateIdentity && - !state.IsNull) - LuaReferences.Release(state, binding.Reference); - } - } - finally - { - operation.Dispose(); - } - } - - /// LuaRef(slot, attach epoch N, state generation M), or LuaRef(unresolved). - public override string ToString() - { - var binding = Volatile.Read(ref _binding); - return binding is null || binding.Reference == NoReference - ? "LuaRef(unresolved)" - : string.Create(CultureInfo.InvariantCulture, - $"LuaRef({binding.Reference}, attach epoch {binding.Identity.AttachEpoch}, state generation {binding.Identity.StateGeneration})"); - } - - private sealed class LuaRefBinding(int reference, LuaStateIdentity identity) - { - internal int Reference { get; } = reference; - - internal LuaStateIdentity Identity { get; } = identity; - } + private const int NoReference = LuaApi.LUA_NOREF; + + // A reference write publishes slot and both identity components together. The hot path only reads the immutable + // object; rebinding and releasing are already cold, serialized paths. + private LuaRefBinding? _binding; + + /// + /// Creates an unresolved reference: is until code binds it + /// (see CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions). + /// + /// Runs no Lua code, so it may be a static field initializer of a class that binds globals lazily. + public LuaRef() + { + _binding = null; + } + + internal LuaRef(int reference, LuaStateIdentity identity) + { + _binding = new LuaRefBinding(reference, identity); + } + + /// + /// Gets the slot in the SDK's private reference table, or LUA_NOREF (-2) when unresolved or released. + /// LUA_REFNIL (-1) is a + /// valid reference to nil. + /// + public int Reference => Volatile.Read(ref _binding)?.Reference ?? NoReference; + + /// + /// Gets the complete Lua state identity the reference was created in, or the default identity for an unresolved + /// or released reference. + /// + public LuaStateIdentity Identity => Volatile.Read(ref _binding)?.Identity ?? default; + + /// + /// Gets the attach epoch component of ; 0 for an unresolved or released reference. + /// + public int Epoch => Identity.AttachEpoch; + + /// + /// Gets the state generation component of ; 0 for an unresolved or released reference. + /// + public int StateGeneration => Identity.StateGeneration; + + /// + /// Gets a value indicating whether the reference holds a slot at all (resolved and not released), whatever its + /// identity. + /// + public bool IsResolved => Reference != NoReference; + + /// + /// Gets a value indicating whether the reference holds a slot created in the current + /// : the only state in which it may be pushed or released. + /// + public bool IsCurrent + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => TryGetCurrent(out _); + } + + /// + /// with the state acquired from . When the runtime is detached the + /// slot cannot be reached and the reference is only marked released. Prefer where a state is at + /// hand. + /// + public void Dispose() + { + if (!LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)) + { + Release(default); + return; + } + + using (operation) + { + Release(operation.State); + } + } + + /// Reads the slot when the reference is current. One binding read and one identity comparison. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryGetCurrent(out int reference) + { + LuaRefBinding? binding = Volatile.Read(ref _binding); + reference = binding?.Reference ?? NoReference; + return binding is not null && reference != NoReference && binding.Identity == LuaRuntime.CurrentStateIdentity; + } + + /// + /// Points the reference at a slot of the given state identity. The previous slot, if any, is not released: the + /// caller decides whether it still belongs to a live state. + /// + internal void Rebind(int reference, LuaStateIdentity identity) + { + lock (LuaReferences.Gate) + { + Volatile.Write(ref _binding, new LuaRefBinding(reference, identity)); + } + } + + // Temporary compatibility for call sites being migrated to the complete identity. New code must capture and + // revalidate CurrentStateIdentity around any operation that can run Lua before using the overload above. + internal void Rebind(int reference, int epoch) + { + Rebind(reference, new LuaStateIdentity(epoch, LuaRuntime.StateGeneration)); + } + + /// + /// Releases the registry slot (luaL_unref) when the reference is current, and marks the reference released + /// in every case. Safe to call on an unresolved, stale or already released reference: nothing happens then. + /// + /// A state of the Lua universe the reference was created in; the calling thread's state. + public void Release(LuaState state) + { + LuaRuntimeOperation operation = state.IsNull ? default : LuaRuntime.EnterStateOperation(state); + try + { + lock (LuaReferences.Gate) + { + LuaRefBinding? binding = Interlocked.Exchange(ref _binding, null); + if (binding is not null && binding.Reference != NoReference && + binding.Identity == LuaRuntime.CurrentStateIdentity && + !state.IsNull) + { + LuaReferences.Release(state, binding.Reference); + } + } + } + finally + { + operation.Dispose(); + } + } + + /// LuaRef(slot, attach epoch N, state generation M), or LuaRef(unresolved). + public override string ToString() + { + LuaRefBinding? binding = Volatile.Read(ref _binding); + return binding is null || binding.Reference == NoReference + ? "LuaRef(unresolved)" + : string.Create(CultureInfo.InvariantCulture, + $"LuaRef({binding.Reference}, attach epoch {binding.Identity.AttachEpoch}, state generation {binding.Identity.StateGeneration})"); + } + + private sealed class LuaRefBinding(int reference, LuaStateIdentity identity) + { + internal int Reference + { + get; + } = reference; + + internal LuaStateIdentity Identity + { + get; + } = identity; + } } diff --git a/libs/CheatEngine.SDK.Lua/References/LuaReferences.cs b/libs/CheatEngine.SDK.Lua/References/LuaReferences.cs index 4e22599c..d8a180c7 100644 --- a/libs/CheatEngine.SDK.Lua/References/LuaReferences.cs +++ b/libs/CheatEngine.SDK.Lua/References/LuaReferences.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Threading; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Protected; @@ -10,34 +11,40 @@ namespace CheatEngine.SDK.Lua.References; /// Serializes references in this SDK copy's private table, isolated from the host registry free list. internal static class LuaReferences { - internal static Lock Gate { get; } = new(); - - internal static nint Key { get; } = RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(LuaReferences), 1); - - internal static unsafe LuaStatus Create(LuaState state, out int reference) - { - lock (Gate) - { - return new LuaStatus(LuaProtectedApi.TryCreatePrivateRef(state.Pointer, Key, out reference)); - } - } - - internal static unsafe bool Push(LuaState state, int reference) - { - // Neither raw lookup allocates or runs Lua code. The caller holds Gate across slot validation and this read. - if (LuaApi.lua_rawgetp(state.Pointer, LuaApi.LUA_REGISTRYINDEX, (void*)Key) != LuaApi.LUA_TTABLE) - { - state.Pop(1); - return false; - } - - _ = LuaApi.lua_rawgeti(state.Pointer, -1, reference); - state.Remove(-2); - return true; - } - - internal static unsafe void Release(LuaState state, int reference) - { - state.CheckProtectedResult(new LuaStatus(LuaProtectedApi.UnrefPrivate(state.Pointer, Key, reference))); - } + internal static Lock Gate + { + get; + } = new(); + + internal static nint Key + { + get; + } = RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(LuaReferences), 1); + + internal static unsafe LuaStatus Create(LuaState state, out int reference) + { + lock (Gate) + { + return new LuaStatus(LuaProtectedApi.TryCreatePrivateRef(state.Pointer, Key, out reference)); + } + } + + internal static unsafe bool Push(LuaState state, int reference) + { + // Neither raw lookup allocates or runs Lua code. The caller holds Gate across slot validation and this read. + if (LuaApi.lua_rawgetp(state.Pointer, LuaApi.LUA_REGISTRYINDEX, (void*) Key) != LuaApi.LUA_TTABLE) + { + state.Pop(1); + return false; + } + + _ = LuaApi.lua_rawgeti(state.Pointer, -1, reference); + state.Remove(-2); + return true; + } + + internal static unsafe void Release(LuaState state, int reference) + { + state.CheckProtectedResult(new LuaStatus(LuaProtectedApi.UnrefPrivate(state.Pointer, Key, reference))); + } } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationCollisionPolicy.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationCollisionPolicy.cs index d6a1244e..4c6c005b 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationCollisionPolicy.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationCollisionPolicy.cs @@ -3,12 +3,12 @@ namespace CheatEngine.SDK.Lua.Registration; /// Controls how a registration set treats an effective global value that already exists. public enum LuaRegistrationCollisionPolicy { - /// Refuse the complete set before publication when any requested global has a non- value. - RejectExisting = 0, + /// Refuse the complete set before publication when any requested global has a non- value. + RejectExisting = 0, - /// - /// Replace existing values and retain them in the lease. Releasing the lease restores a retained value only while - /// the registered closure is still the effective global value. - /// - ReplaceExisting = 1, + /// + /// Replace existing values and retain them in the lease. Releasing the lease restores a retained value only while + /// the registered closure is still the effective global value. + /// + ReplaceExisting = 1 } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationEntry.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationEntry.cs index 9dca9f2a..c1b5a329 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationEntry.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationEntry.cs @@ -1,5 +1,6 @@ using System; using System.Text; + using CheatEngine.SDK.Lua.Callbacks; namespace CheatEngine.SDK.Lua.Registration; @@ -11,27 +12,36 @@ namespace CheatEngine.SDK.Lua.Registration; /// public readonly struct LuaRegistrationEntry { - private readonly byte[] _utf8Name; + private readonly byte[] _utf8Name; - /// Initializes one entry. - /// The nonempty Lua global name. - /// The non-null native thunk to wrap and publish. - /// is empty or is null. - public LuaRegistrationEntry(string name, LuaNativeFunction function) - { - ArgumentException.ThrowIfNullOrEmpty(name); - if (function.IsNull) throw new ArgumentException("The registration thunk is null.", nameof(function)); + /// Initializes one entry. + /// The nonempty Lua global name. + /// The non-null native thunk to wrap and publish. + /// is empty or is null. + public LuaRegistrationEntry(string name, LuaNativeFunction function) + { + ArgumentException.ThrowIfNullOrEmpty(name); + if (function.IsNull) + { + throw new ArgumentException("The registration thunk is null.", nameof(function)); + } - Name = name; - _utf8Name = Encoding.UTF8.GetBytes(name); - Function = function; - } + Name = name; + _utf8Name = Encoding.UTF8.GetBytes(name); + Function = function; + } - /// Gets the global name used in diagnostics and release reports. - public string Name { get; } + /// Gets the global name used in diagnostics and release reports. + public string Name + { + get; + } - /// Gets the native thunk wrapped by the Lua registration helper. - public LuaNativeFunction Function { get; } + /// Gets the native thunk wrapped by the Lua registration helper. + public LuaNativeFunction Function + { + get; + } - internal ReadOnlySpan Utf8Name => _utf8Name; + internal ReadOnlySpan Utf8Name => _utf8Name; } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationFailure.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationFailure.cs index 66949b21..14e21f55 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationFailure.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationFailure.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Lua.Registration; diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationLease.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationLease.cs index c4096eda..9ff1b828 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationLease.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationLease.cs @@ -1,5 +1,6 @@ using System; using System.Threading; + using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -14,78 +15,96 @@ namespace CheatEngine.SDK.Lua.Registration; /// public sealed class LuaRegistrationLease : IDisposable { - private LuaRegistrationSet.LeaseEntry[]? _entries; - private LuaRegistrationReleaseOutcome _lastReleaseOutcome; + private LuaRegistrationSet.LeaseEntry[]? _entries; - internal LuaRegistrationLease(LuaStateIdentity identity, LuaRegistrationSet.LeaseEntry[] entries) - { - Identity = identity; - _entries = entries; - _lastReleaseOutcome = LuaRegistrationReleaseOutcome.NotAttempted(); - } + internal LuaRegistrationLease(LuaStateIdentity identity, LuaRegistrationSet.LeaseEntry[] entries) + { + Identity = identity; + _entries = entries; + LastReleaseOutcome = LuaRegistrationReleaseOutcome.NotAttempted(); + } - /// Gets the attachment epoch and reset generation captured at publication. - public LuaStateIdentity Identity { get; } + /// Gets the attachment epoch and reset generation captured at publication. + public LuaStateIdentity Identity + { + get; + } - /// Gets whether ownership has already been consumed by or . - public bool IsDisposed => Volatile.Read(ref _entries) is null; + /// + /// Gets whether ownership has already been consumed by or + /// . + /// + public bool IsDisposed => Volatile.Read(ref _entries) is null; - /// Gets the factual outcome of the one completed release attempt. - public LuaRegistrationReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; + /// Gets the factual outcome of the one completed release attempt. + public LuaRegistrationReleaseOutcome LastReleaseOutcome + { + get; + private set; + } - /// Releases this set through a newly acquired operation and returns every factual cleanup result. - /// - /// Ownership is consumed before the release begins. Repeated calls are idempotent and return - /// ; a protected cleanup failure is not retried - /// implicitly because a metamethod may have performed an uncertain side effect. - /// - public LuaRegistrationReleaseOutcome ReleaseWithOutcome() - { - var entries = Interlocked.Exchange(ref _entries, value: null); - if (entries is null) return LuaRegistrationReleaseOutcome.AlreadyReleased(); + /// Best-effort, no-throw ownership release. Calling this more than once performs no further Lua mutation. + public void Dispose() + { + try + { + _ = ReleaseWithOutcome(); + } + catch (Exception) + { + // IDisposable cleanup must never hide a caller failure. Ownership was atomically consumed before Lua work. + } + } - if (!LuaRuntime.TryAcquireOperation(out var operation)) - { - LuaRegistrationSet.Forget(entries); - return Store(LuaRegistrationReleaseOutcome.Stale(entries.Length)); - } + /// Releases this set through a newly acquired operation and returns every factual cleanup result. + /// + /// Ownership is consumed before the release begins. Repeated calls are idempotent and return + /// ; a protected cleanup failure is not retried + /// implicitly because a metamethod may have performed an uncertain side effect. + /// + public LuaRegistrationReleaseOutcome ReleaseWithOutcome() + { + LuaRegistrationSet.LeaseEntry[]? entries = Interlocked.Exchange(ref _entries, null); + if (entries is null) + { + return LuaRegistrationReleaseOutcome.AlreadyReleased(); + } - using (operation) - { - return Store(LuaRegistrationSet.Release(operation.State, Identity, entries, retainFailures: false, - out _)); - } - } + if (!LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)) + { + LuaRegistrationSet.Forget(entries); + return Store(LuaRegistrationReleaseOutcome.Stale(entries.Length)); + } - /// Releases this set through a caller-owned, admitted Lua state. - /// The calling thread's state for the currently attached Lua universe. - /// Every factual cleanup result; a stale lease performs no Lua operation. - public LuaRegistrationReleaseOutcome ReleaseWithOutcome(LuaState state) - { - if (state.IsNull) throw new ArgumentException("A registration lease needs a non-null Lua state.", nameof(state)); + using (operation) + { + return Store(LuaRegistrationSet.Release(operation.State, Identity, entries, false, + out _)); + } + } - var entries = Interlocked.Exchange(ref _entries, value: null); - if (entries is null) return LuaRegistrationReleaseOutcome.AlreadyReleased(); + /// Releases this set through a caller-owned, admitted Lua state. + /// The calling thread's state for the currently attached Lua universe. + /// Every factual cleanup result; a stale lease performs no Lua operation. + public LuaRegistrationReleaseOutcome ReleaseWithOutcome(LuaState state) + { + if (state.IsNull) + { + throw new ArgumentException("A registration lease needs a non-null Lua state.", nameof(state)); + } - return Store(LuaRegistrationSet.Release(state, Identity, entries, retainFailures: false, out _)); - } + LuaRegistrationSet.LeaseEntry[]? entries = Interlocked.Exchange(ref _entries, null); + if (entries is null) + { + return LuaRegistrationReleaseOutcome.AlreadyReleased(); + } - /// Best-effort, no-throw ownership release. Calling this more than once performs no further Lua mutation. - public void Dispose() - { - try - { - _ = ReleaseWithOutcome(); - } - catch (Exception) - { - // IDisposable cleanup must never hide a caller failure. Ownership was atomically consumed before Lua work. - } - } + return Store(LuaRegistrationSet.Release(state, Identity, entries, false, out _)); + } - private LuaRegistrationReleaseOutcome Store(LuaRegistrationReleaseOutcome outcome) - { - _lastReleaseOutcome = outcome; - return outcome; - } + private LuaRegistrationReleaseOutcome Store(LuaRegistrationReleaseOutcome outcome) + { + LastReleaseOutcome = outcome; + return outcome; + } } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseFailure.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseFailure.cs index bfcbaf18..ae980031 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseFailure.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseFailure.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Lua.Registration; diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseKind.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseKind.cs index 36bc641b..2d44e1fe 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseKind.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseKind.cs @@ -3,18 +3,18 @@ namespace CheatEngine.SDK.Lua.Registration; /// Classifies a completed lease release attempt. public enum LuaRegistrationReleaseKind { - /// No cleanup was necessary or attempted. - NotAttempted = 0, + /// No cleanup was necessary or attempted. + NotAttempted = 0, - /// Every still-owned entry was restored or removed, and replacements were left alone. - Released = 1, + /// Every still-owned entry was restored or removed, and replacements were left alone. + Released = 1, - /// One or more cleanup operations failed; the named failures are available independently. - PartiallyReleased = 2, + /// One or more cleanup operations failed; the named failures are available independently. + PartiallyReleased = 2, - /// The lease belonged to an earlier attachment or reset generation, so no Lua operation was attempted. - Stale = 3, + /// The lease belonged to an earlier attachment or reset generation, so no Lua operation was attempted. + Stale = 3, - /// The lease had already been released or disposed. - AlreadyReleased = 4, + /// The lease had already been released or disposed. + AlreadyReleased = 4 } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseOutcome.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseOutcome.cs index 5ea89d86..0c2626fd 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseOutcome.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationReleaseOutcome.cs @@ -8,57 +8,75 @@ namespace CheatEngine.SDK.Lua.Registration; [StructLayout(LayoutKind.Auto)] public readonly record struct LuaRegistrationReleaseOutcome { - private readonly IReadOnlyList? _failures; + private readonly IReadOnlyList? _failures; - internal LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind kind, int removedCount, int restoredCount, - int replacementCount, int remainingCount, IReadOnlyList failures) - { - Kind = kind; - RemovedCount = removedCount; - RestoredCount = restoredCount; - ReplacementCount = replacementCount; - RemainingCount = remainingCount; - _failures = failures; - } + internal LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind kind, int removedCount, int restoredCount, + int replacementCount, int remainingCount, IReadOnlyList failures) + { + Kind = kind; + RemovedCount = removedCount; + RestoredCount = restoredCount; + ReplacementCount = replacementCount; + RemainingCount = remainingCount; + _failures = failures; + } - /// Gets the stable outcome category. - public LuaRegistrationReleaseKind Kind { get; } + /// Gets the stable outcome category. + public LuaRegistrationReleaseKind Kind + { + get; + } - /// Gets the number of globals this lease removed because no prior value existed. - public int RemovedCount { get; } + /// Gets the number of globals this lease removed because no prior value existed. + public int RemovedCount + { + get; + } - /// Gets the number of prior values this lease restored under . - public int RestoredCount { get; } + /// + /// Gets the number of prior values this lease restored under + /// . + /// + public int RestoredCount + { + get; + } - /// Gets the number of entries a later owner replaced and this lease deliberately did not write. - public int ReplacementCount { get; } + /// Gets the number of entries a later owner replaced and this lease deliberately did not write. + public int ReplacementCount + { + get; + } - /// Gets the number of entries whose cleanup state remained unconfirmed after this attempt. - public int RemainingCount { get; } + /// Gets the number of entries whose cleanup state remained unconfirmed after this attempt. + public int RemainingCount + { + get; + } - /// Gets every independent protected cleanup failure, in registration order. - public IReadOnlyList Failures => - _failures ?? Array.Empty(); + /// Gets every independent protected cleanup failure, in registration order. + public IReadOnlyList Failures => + _failures ?? Array.Empty(); - /// Gets whether no cleanup operation failed. - public bool IsComplete => Kind is LuaRegistrationReleaseKind.NotAttempted or LuaRegistrationReleaseKind.Released - or LuaRegistrationReleaseKind.AlreadyReleased or LuaRegistrationReleaseKind.Stale; + /// Gets whether no cleanup operation failed. + public bool IsComplete => Kind is LuaRegistrationReleaseKind.NotAttempted or LuaRegistrationReleaseKind.Released + or LuaRegistrationReleaseKind.AlreadyReleased or LuaRegistrationReleaseKind.Stale; - internal static LuaRegistrationReleaseOutcome NotAttempted() - { - return new LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind.NotAttempted, 0, 0, 0, 0, - Array.Empty()); - } + internal static LuaRegistrationReleaseOutcome NotAttempted() + { + return new LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind.NotAttempted, 0, 0, 0, 0, + Array.Empty()); + } - internal static LuaRegistrationReleaseOutcome AlreadyReleased() - { - return new LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind.AlreadyReleased, 0, 0, 0, 0, - Array.Empty()); - } + internal static LuaRegistrationReleaseOutcome AlreadyReleased() + { + return new LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind.AlreadyReleased, 0, 0, 0, 0, + Array.Empty()); + } - internal static LuaRegistrationReleaseOutcome Stale(int remainingCount) - { - return new LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind.Stale, 0, 0, 0, remainingCount, - Array.Empty()); - } + internal static LuaRegistrationReleaseOutcome Stale(int remainingCount) + { + return new LuaRegistrationReleaseOutcome(LuaRegistrationReleaseKind.Stale, 0, 0, 0, remainingCount, + Array.Empty()); + } } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResult.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResult.cs index a9cc107e..0e862572 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResult.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResult.cs @@ -6,27 +6,45 @@ namespace CheatEngine.SDK.Lua.Registration; [StructLayout(LayoutKind.Auto)] public readonly record struct LuaRegistrationResult { - internal LuaRegistrationResult(LuaRegistrationResultKind kind, LuaRegistrationFailure? failure, - LuaRegistrationReleaseOutcome rollback, LuaRegistrationLease? lease) - { - Kind = kind; - Failure = failure; - Rollback = rollback; - Lease = lease; - } + internal LuaRegistrationResult(LuaRegistrationResultKind kind, LuaRegistrationFailure? failure, + LuaRegistrationReleaseOutcome rollback, LuaRegistrationLease? lease) + { + Kind = kind; + Failure = failure; + Rollback = rollback; + Lease = lease; + } - /// Gets the stable registration category. - public LuaRegistrationResultKind Kind { get; } + /// Gets the stable registration category. + public LuaRegistrationResultKind Kind + { + get; + } - /// Gets the collision or protected-operation failure when is . - public LuaRegistrationFailure? Failure { get; } + /// Gets the collision or protected-operation failure when is . + public LuaRegistrationFailure? Failure + { + get; + } - /// Gets the compensation outcome after a publication failure, otherwise . - public LuaRegistrationReleaseOutcome Rollback { get; } + /// + /// Gets the compensation outcome after a publication failure, otherwise + /// . + /// + public LuaRegistrationReleaseOutcome Rollback + { + get; + } - /// Gets the complete successful lease, or a residual lease after failed compensation. Dispose every non-null lease. - public LuaRegistrationLease? Lease { get; } + /// + /// Gets the complete successful lease, or a residual lease after failed compensation. Dispose every non-null + /// lease. + /// + public LuaRegistrationLease? Lease + { + get; + } - /// Gets whether every requested entry was published. - public bool IsSuccess => Kind == LuaRegistrationResultKind.Succeeded; + /// Gets whether every requested entry was published. + public bool IsSuccess => Kind == LuaRegistrationResultKind.Succeeded; } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResultKind.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResultKind.cs index 0b153ecf..18ed71de 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResultKind.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationResultKind.cs @@ -3,18 +3,18 @@ namespace CheatEngine.SDK.Lua.Registration; /// Classifies the factual result of publishing a Lua registration set. public enum LuaRegistrationResultKind { - /// No registration was attempted. - Unspecified = 0, + /// No registration was attempted. + Unspecified = 0, - /// Every entry was published and the returned lease owns the complete set. - Succeeded = 1, + /// Every entry was published and the returned lease owns the complete set. + Succeeded = 1, - /// Preflight found an existing effective global while the policy rejected replacement. - Collision = 2, + /// Preflight found an existing effective global while the policy rejected replacement. + Collision = 2, - /// A protected operation failed before any requested global was published. - PreflightFailed = 3, + /// A protected operation failed before any requested global was published. + PreflightFailed = 3, - /// A protected operation failed during publication; the rollback report describes compensation. - PublicationFailed = 4, + /// A protected operation failed during publication; the rollback report describes compensation. + PublicationFailed = 4 } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs index 694e9d46..03750711 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Text; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -10,350 +12,420 @@ namespace CheatEngine.SDK.Lua.Registration; /// Publishes static Lua functions as one ownership-aware registration transaction. public static class LuaRegistrationSet { - /// Publishes every entry and returns the lease that owns exactly the installed effective globals. - /// The current calling thread's state of an attached runtime. - /// The nonempty, uniquely named static registrations to publish. - /// The explicit handling for existing effective globals. - /// The factual registration outcome and a lease on success or after a failed compensation that left a residual owner. - /// is null, entries are empty, duplicated, or invalid. - public static LuaRegistrationResult Register(LuaState state, ReadOnlySpan entries, - LuaRegistrationCollisionPolicy collisionPolicy = LuaRegistrationCollisionPolicy.RejectExisting) - { - if (state.IsNull) throw new ArgumentException("A registration set needs a non-null Lua state.", nameof(state)); - if (entries.IsEmpty) throw new ArgumentException("A registration set needs at least one entry.", nameof(entries)); - if (collisionPolicy is not LuaRegistrationCollisionPolicy.RejectExisting and not LuaRegistrationCollisionPolicy.ReplaceExisting) - throw new ArgumentOutOfRangeException(nameof(collisionPolicy)); - - ValidateEntries(entries); - - using var operation = LuaRuntime.AcquireOperation(state); - var identity = LuaRuntime.CurrentStateIdentity; - var entriesToLease = new LeaseEntry[entries.Length]; - var preflight = Preflight(state, entries, collisionPolicy, entriesToLease); - if (preflight is not null) return preflight.Value; - - return Publish(state, identity, entries, entriesToLease); - } - - internal static LuaRegistrationReleaseOutcome Release(LuaState state, LuaStateIdentity identity, - LeaseEntry[] entries, bool retainFailures, out LeaseEntry[]? residual) - { - residual = null; - if (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) - { - Forget(entries); - return LuaRegistrationReleaseOutcome.Stale(entries.Length); - } - - using var operation = LuaRuntime.AcquireOperation(state); - if (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) - { - Forget(entries); - return LuaRegistrationReleaseOutcome.Stale(entries.Length); - } - - var accumulator = new ReleaseAccumulator(entries.Length, retainFailures); - var top = state.Top; - - for (var index = 0; index < entries.Length; index++) - ReleaseEntry(state, entries[index], top, accumulator); - - return accumulator.CreateOutcome(out residual); - } - - private static LuaRegistrationResult? Preflight(LuaState state, ReadOnlySpan entries, - LuaRegistrationCollisionPolicy collisionPolicy, LeaseEntry[] entriesToLease) - { - var top = state.Top; - for (var index = 0; index < entries.Length; index++) - { - var entry = entries[index]; - var status = state.TryGetGlobal(entry.Utf8Name); - if (!status.IsOk) - return PreflightFailed(state, top, entriesToLease, index, entry.Name, status); - - var hadPreviousValue = !state.IsNil(-1); - if (hadPreviousValue && collisionPolicy == LuaRegistrationCollisionPolicy.RejectExisting) - { - state.SetTop(top); - ReleaseEntries(state, entriesToLease, index); - return Collision(entry.Name); - } - - LuaRef? previous = null; - if (hadPreviousValue) - { - state.PushValue(-1); - status = state.TryCreateRef(out previous); - if (!status.IsOk || previous is null) - return PreflightFailed(state, top, entriesToLease, index, entry.Name, status); - } - - entriesToLease[index] = new LeaseEntry(entry.Name, previous); - state.SetTop(top); - } - - return null; - } - - private static LuaRegistrationResult Publish(LuaState state, LuaStateIdentity identity, - ReadOnlySpan entries, LeaseEntry[] entriesToLease) - { - var top = state.Top; - for (var index = 0; index < entries.Length; index++) - { - var entry = entries[index]; - var status = LuaRuntime.TryPushGeneratedFunction(state, entry.Function); - if (!status.IsOk) return PublishFailure(state, top, identity, entriesToLease, index, entry.Name, status); - - state.PushValue(-1); - status = state.TryCreateRef(out var installed); - if (!status.IsOk || installed is null) - return PublishFailure(state, top, identity, entriesToLease, index, entry.Name, status); - - entriesToLease[index].Installed = installed; - status = state.TrySetGlobal(entry.Utf8Name); - if (!status.IsOk) - return PublishFailure(state, top, identity, entriesToLease, index + 1, entry.Name, status); - state.SetTop(top); - } - - return new LuaRegistrationResult(LuaRegistrationResultKind.Succeeded, failure: null, - LuaRegistrationReleaseOutcome.NotAttempted(), new LuaRegistrationLease(identity, entriesToLease)); - } - - private static LuaRegistrationResult PreflightFailed(LuaState state, int top, LeaseEntry[] entries, int count, - string name, LuaStatus status) - { - state.SetTop(top); - ReleaseEntries(state, entries, count); - return Failed(LuaRegistrationResultKind.PreflightFailed, name, status); - } - - private static LuaRegistrationResult PublishFailure(LuaState state, int top, LuaStateIdentity identity, - LeaseEntry[] entries, int count, string name, LuaStatus status) - { - state.SetTop(top); - return PublishFailed(state, identity, entries, count, name, status); - } - - private static void ReleaseEntry(LuaState state, LeaseEntry entry, int top, ReleaseAccumulator accumulator) - { - if (entry.Installed is null) - { - ReleaseReferences(state, entry); - return; - } - - var status = state.TryGetGlobal(entry.Utf8Name); - if (!status.IsOk) - { - state.SetTop(top); - accumulator.Failed(state, entry, status); - return; - } - - if (!state.TryPushRef(entry.Installed)) - { - state.SetTop(top); - accumulator.Failed(state, entry, LuaStatus.RuntimeError); - return; - } - - var ownsCurrentValue = state.RawEquals(-2, -1); - state.SetTop(top); - if (!ownsCurrentValue) - { - accumulator.Replaced(state, entry); - return; - } - - if (!TryPushPrevious(state, entry)) - { - state.SetTop(top); - accumulator.Failed(state, entry, LuaStatus.RuntimeError); - return; - } - - status = state.TrySetGlobal(entry.Utf8Name); - state.SetTop(top); - if (!status.IsOk) - { - accumulator.Failed(state, entry, status); - return; - } - - accumulator.Released(state, entry); - } - - private static bool TryPushPrevious(LuaState state, LeaseEntry entry) - { - if (entry.Previous is null) - { - state.PushNil(); - return true; - } - - return state.TryPushRef(entry.Previous); - } - - internal static void Forget(LeaseEntry[] entries) - { - for (var index = 0; index < entries.Length; index++) Forget(entries[index]); - } - - private static LuaRegistrationResult PublishFailed(LuaState state, LuaStateIdentity identity, - LeaseEntry[] entries, int count, string name, LuaStatus status) - { - var published = new LeaseEntry[count]; - Array.Copy(entries, published, count); - var rollback = Release(state, identity, published, retainFailures: true, out var residual); - ReleaseEntries(state, entries, count, entries.Length); - LuaRegistrationLease? lease = residual is null ? null : new LuaRegistrationLease(identity, residual); - return new LuaRegistrationResult(LuaRegistrationResultKind.PublicationFailed, - new LuaRegistrationFailure(name, status), rollback, lease); - } - - private static LuaRegistrationResult Collision(string name) - { - return new LuaRegistrationResult(LuaRegistrationResultKind.Collision, - new LuaRegistrationFailure(name, LuaStatus.Ok), LuaRegistrationReleaseOutcome.NotAttempted(), lease: null); - } - - private static LuaRegistrationResult Failed(LuaRegistrationResultKind kind, string name, LuaStatus status) - { - return new LuaRegistrationResult(kind, new LuaRegistrationFailure(name, status), - LuaRegistrationReleaseOutcome.NotAttempted(), lease: null); - } - - private static void ReleaseReferences(LuaState state, LeaseEntry entry) - { - try - { - entry.Installed?.Release(state); - } - finally - { - entry.Previous?.Release(state); - } - } - - private static void Forget(LeaseEntry entry) - { - entry.Installed?.Release(default); - entry.Previous?.Release(default); - } - - private static void ReleaseEntries(LuaState state, LeaseEntry[] entries, int count) - { - ReleaseEntries(state, entries, 0, count); - } - - private static void ReleaseEntries(LuaState state, LeaseEntry[] entries, int start, int end) - { - for (var index = start; index < end; index++) ReleaseReferences(state, entries[index]); - } - - private static void ValidateEntries(ReadOnlySpan entries) - { - var names = new HashSet(StringComparer.Ordinal); - for (var index = 0; index < entries.Length; index++) - { - var entry = entries[index]; - if (string.IsNullOrEmpty(entry.Name) || entry.Function.IsNull) - throw new ArgumentException("Every registration entry needs a nonempty name and non-null thunk.", nameof(entries)); - if (!names.Add(entry.Name)) - throw new ArgumentException("A registration set cannot contain duplicate global names.", nameof(entries)); - } - } - - internal sealed class LeaseEntry - { - internal LeaseEntry(string name, LuaRef? previous) - { - Name = name; - Utf8Name = System.Text.Encoding.UTF8.GetBytes(name); - Previous = previous; - } - - internal string Name { get; } - - internal byte[] Utf8Name { get; } - - internal LuaRef? Installed { get; set; } - - internal LuaRef? Previous { get; } - } - - private sealed class ReleaseAccumulator - { - private readonly LuaRegistrationReleaseFailure[] _failures; - private readonly LeaseEntry[]? _residual; - private int _failureCount; - private int _removedCount; - private int _replacementCount; - private int _residualCount; - private int _restoredCount; - - internal ReleaseAccumulator(int count, bool retainFailures) - { - _failures = new LuaRegistrationReleaseFailure[count]; - _residual = retainFailures ? new LeaseEntry[count] : null; - } - - internal void Failed(LuaState state, LeaseEntry entry, LuaStatus status) - { - _failures[_failureCount++] = new LuaRegistrationReleaseFailure(entry.Name, status); - if (_residual is not null) - { - _residual[_residualCount++] = entry; - return; - } - - ReleaseReferences(state, entry); - } - - internal void Replaced(LuaState state, LeaseEntry entry) - { - _replacementCount++; - ReleaseReferences(state, entry); - } - - internal void Released(LuaState state, LeaseEntry entry) - { - if (entry.Previous is null) - _removedCount++; - else - _restoredCount++; - ReleaseReferences(state, entry); - } - - internal LuaRegistrationReleaseOutcome CreateOutcome(out LeaseEntry[]? residual) - { - residual = CopyResidual(); - var failures = CopyFailures(); - var kind = _failureCount == 0 ? LuaRegistrationReleaseKind.Released : LuaRegistrationReleaseKind.PartiallyReleased; - var remainingCount = _residual is null ? _failureCount : _residualCount; - return new LuaRegistrationReleaseOutcome(kind, _removedCount, _restoredCount, _replacementCount, - remainingCount, failures); - } - - private LuaRegistrationReleaseFailure[] CopyFailures() - { - if (_failureCount == 0) return Array.Empty(); - - var copy = new LuaRegistrationReleaseFailure[_failureCount]; - Array.Copy(_failures, copy, _failureCount); - return copy; - } - - private LeaseEntry[]? CopyResidual() - { - if (_residualCount == 0) return null; - - var copy = new LeaseEntry[_residualCount]; - Array.Copy(_residual!, copy, _residualCount); - return copy; - } - } + /// Publishes every entry and returns the lease that owns exactly the installed effective globals. + /// The current calling thread's state of an attached runtime. + /// The nonempty, uniquely named static registrations to publish. + /// The explicit handling for existing effective globals. + /// + /// The factual registration outcome and a lease on success or after a failed compensation that left a residual + /// owner. + /// + /// is null, entries are empty, duplicated, or invalid. + public static LuaRegistrationResult Register(LuaState state, ReadOnlySpan entries, + LuaRegistrationCollisionPolicy collisionPolicy = LuaRegistrationCollisionPolicy.RejectExisting) + { + if (state.IsNull) + { + throw new ArgumentException("A registration set needs a non-null Lua state.", nameof(state)); + } + + if (entries.IsEmpty) + { + throw new ArgumentException("A registration set needs at least one entry.", nameof(entries)); + } + + if (collisionPolicy is not LuaRegistrationCollisionPolicy.RejectExisting + and not LuaRegistrationCollisionPolicy.ReplaceExisting) + { + throw new ArgumentOutOfRangeException(nameof(collisionPolicy)); + } + + ValidateEntries(entries); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(state); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + LeaseEntry[] entriesToLease = new LeaseEntry[entries.Length]; + LuaRegistrationResult? preflight = Preflight(state, entries, collisionPolicy, entriesToLease); + if (preflight is not null) + { + return preflight.Value; + } + + return Publish(state, identity, entries, entriesToLease); + } + + internal static LuaRegistrationReleaseOutcome Release(LuaState state, LuaStateIdentity identity, + LeaseEntry[] entries, bool retainFailures, out LeaseEntry[]? residual) + { + residual = null; + if (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) + { + Forget(entries); + return LuaRegistrationReleaseOutcome.Stale(entries.Length); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(state); + if (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) + { + Forget(entries); + return LuaRegistrationReleaseOutcome.Stale(entries.Length); + } + + ReleaseAccumulator accumulator = new(entries.Length, retainFailures); + int top = state.Top; + + for (int index = 0; index < entries.Length; index++) + { + ReleaseEntry(state, entries[index], top, accumulator); + } + + return accumulator.CreateOutcome(out residual); + } + + private static LuaRegistrationResult? Preflight(LuaState state, ReadOnlySpan entries, + LuaRegistrationCollisionPolicy collisionPolicy, LeaseEntry[] entriesToLease) + { + int top = state.Top; + for (int index = 0; index < entries.Length; index++) + { + LuaRegistrationEntry entry = entries[index]; + LuaStatus status = state.TryGetGlobal(entry.Utf8Name); + if (!status.IsOk) + { + return PreflightFailed(state, top, entriesToLease, index, entry.Name, status); + } + + bool hadPreviousValue = !state.IsNil(-1); + if (hadPreviousValue && collisionPolicy == LuaRegistrationCollisionPolicy.RejectExisting) + { + state.SetTop(top); + ReleaseEntries(state, entriesToLease, index); + return Collision(entry.Name); + } + + LuaRef? previous = null; + if (hadPreviousValue) + { + state.PushValue(-1); + status = state.TryCreateRef(out previous); + if (!status.IsOk || previous is null) + { + return PreflightFailed(state, top, entriesToLease, index, entry.Name, status); + } + } + + entriesToLease[index] = new LeaseEntry(entry.Name, previous); + state.SetTop(top); + } + + return null; + } + + private static LuaRegistrationResult Publish(LuaState state, LuaStateIdentity identity, + ReadOnlySpan entries, LeaseEntry[] entriesToLease) + { + int top = state.Top; + for (int index = 0; index < entries.Length; index++) + { + LuaRegistrationEntry entry = entries[index]; + LuaStatus status = LuaRuntime.TryPushGeneratedFunction(state, entry.Function); + if (!status.IsOk) + { + return PublishFailure(state, top, identity, entriesToLease, index, entry.Name, status); + } + + state.PushValue(-1); + status = state.TryCreateRef(out LuaRef? installed); + if (!status.IsOk || installed is null) + { + return PublishFailure(state, top, identity, entriesToLease, index, entry.Name, status); + } + + entriesToLease[index].Installed = installed; + status = state.TrySetGlobal(entry.Utf8Name); + if (!status.IsOk) + { + return PublishFailure(state, top, identity, entriesToLease, index + 1, entry.Name, status); + } + + state.SetTop(top); + } + + return new LuaRegistrationResult(LuaRegistrationResultKind.Succeeded, null, + LuaRegistrationReleaseOutcome.NotAttempted(), new LuaRegistrationLease(identity, entriesToLease)); + } + + private static LuaRegistrationResult PreflightFailed(LuaState state, int top, LeaseEntry[] entries, int count, + string name, LuaStatus status) + { + state.SetTop(top); + ReleaseEntries(state, entries, count); + return Failed(LuaRegistrationResultKind.PreflightFailed, name, status); + } + + private static LuaRegistrationResult PublishFailure(LuaState state, int top, LuaStateIdentity identity, + LeaseEntry[] entries, int count, string name, LuaStatus status) + { + state.SetTop(top); + return PublishFailed(state, identity, entries, count, name, status); + } + + private static void ReleaseEntry(LuaState state, LeaseEntry entry, int top, ReleaseAccumulator accumulator) + { + if (entry.Installed is null) + { + ReleaseReferences(state, entry); + return; + } + + LuaStatus status = state.TryGetGlobal(entry.Utf8Name); + if (!status.IsOk) + { + state.SetTop(top); + accumulator.Failed(state, entry, status); + return; + } + + if (!state.TryPushRef(entry.Installed)) + { + state.SetTop(top); + accumulator.Failed(state, entry, LuaStatus.RuntimeError); + return; + } + + bool ownsCurrentValue = state.RawEquals(-2, -1); + state.SetTop(top); + if (!ownsCurrentValue) + { + accumulator.Replaced(state, entry); + return; + } + + if (!TryPushPrevious(state, entry)) + { + state.SetTop(top); + accumulator.Failed(state, entry, LuaStatus.RuntimeError); + return; + } + + status = state.TrySetGlobal(entry.Utf8Name); + state.SetTop(top); + if (!status.IsOk) + { + accumulator.Failed(state, entry, status); + return; + } + + accumulator.Released(state, entry); + } + + private static bool TryPushPrevious(LuaState state, LeaseEntry entry) + { + if (entry.Previous is null) + { + state.PushNil(); + return true; + } + + return state.TryPushRef(entry.Previous); + } + + internal static void Forget(LeaseEntry[] entries) + { + for (int index = 0; index < entries.Length; index++) + { + Forget(entries[index]); + } + } + + private static LuaRegistrationResult PublishFailed(LuaState state, LuaStateIdentity identity, + LeaseEntry[] entries, int count, string name, LuaStatus status) + { + LeaseEntry[] published = new LeaseEntry[count]; + Array.Copy(entries, published, count); + LuaRegistrationReleaseOutcome rollback = Release(state, identity, published, true, out LeaseEntry[]? residual); + ReleaseEntries(state, entries, count, entries.Length); + LuaRegistrationLease? lease = residual is null ? null : new LuaRegistrationLease(identity, residual); + return new LuaRegistrationResult(LuaRegistrationResultKind.PublicationFailed, + new LuaRegistrationFailure(name, status), rollback, lease); + } + + private static LuaRegistrationResult Collision(string name) + { + return new LuaRegistrationResult(LuaRegistrationResultKind.Collision, + new LuaRegistrationFailure(name, LuaStatus.Ok), LuaRegistrationReleaseOutcome.NotAttempted(), null); + } + + private static LuaRegistrationResult Failed(LuaRegistrationResultKind kind, string name, LuaStatus status) + { + return new LuaRegistrationResult(kind, new LuaRegistrationFailure(name, status), + LuaRegistrationReleaseOutcome.NotAttempted(), null); + } + + private static void ReleaseReferences(LuaState state, LeaseEntry entry) + { + try + { + entry.Installed?.Release(state); + } + finally + { + entry.Previous?.Release(state); + } + } + + private static void Forget(LeaseEntry entry) + { + entry.Installed?.Release(default); + entry.Previous?.Release(default); + } + + private static void ReleaseEntries(LuaState state, LeaseEntry[] entries, int count) + { + ReleaseEntries(state, entries, 0, count); + } + + private static void ReleaseEntries(LuaState state, LeaseEntry[] entries, int start, int end) + { + for (int index = start; index < end; index++) + { + ReleaseReferences(state, entries[index]); + } + } + + private static void ValidateEntries(ReadOnlySpan entries) + { + HashSet names = new(StringComparer.Ordinal); + for (int index = 0; index < entries.Length; index++) + { + LuaRegistrationEntry entry = entries[index]; + if (string.IsNullOrEmpty(entry.Name) || entry.Function.IsNull) + { + throw new ArgumentException("Every registration entry needs a nonempty name and non-null thunk.", + nameof(entries)); + } + + if (!names.Add(entry.Name)) + { + throw new ArgumentException("A registration set cannot contain duplicate global names.", + nameof(entries)); + } + } + } + + internal sealed class LeaseEntry + { + internal LeaseEntry(string name, LuaRef? previous) + { + Name = name; + Utf8Name = Encoding.UTF8.GetBytes(name); + Previous = previous; + } + + internal string Name + { + get; + } + + internal byte[] Utf8Name + { + get; + } + + internal LuaRef? Installed + { + get; + set; + } + + internal LuaRef? Previous + { + get; + } + } + + private sealed class ReleaseAccumulator + { + private readonly LuaRegistrationReleaseFailure[] _failures; + private readonly LeaseEntry[]? _residual; + private int _failureCount; + private int _removedCount; + private int _replacementCount; + private int _residualCount; + private int _restoredCount; + + internal ReleaseAccumulator(int count, bool retainFailures) + { + _failures = new LuaRegistrationReleaseFailure[count]; + _residual = retainFailures ? new LeaseEntry[count] : null; + } + + internal void Failed(LuaState state, LeaseEntry entry, LuaStatus status) + { + _failures[_failureCount++] = new LuaRegistrationReleaseFailure(entry.Name, status); + if (_residual is not null) + { + _residual[_residualCount++] = entry; + return; + } + + ReleaseReferences(state, entry); + } + + internal void Replaced(LuaState state, LeaseEntry entry) + { + _replacementCount++; + ReleaseReferences(state, entry); + } + + internal void Released(LuaState state, LeaseEntry entry) + { + if (entry.Previous is null) + { + _removedCount++; + } + else + { + _restoredCount++; + } + + ReleaseReferences(state, entry); + } + + internal LuaRegistrationReleaseOutcome CreateOutcome(out LeaseEntry[]? residual) + { + residual = CopyResidual(); + LuaRegistrationReleaseFailure[] failures = CopyFailures(); + LuaRegistrationReleaseKind kind = _failureCount == 0 + ? LuaRegistrationReleaseKind.Released + : LuaRegistrationReleaseKind.PartiallyReleased; + int remainingCount = _residual is null ? _failureCount : _residualCount; + return new LuaRegistrationReleaseOutcome(kind, _removedCount, _restoredCount, _replacementCount, + remainingCount, failures); + } + + private LuaRegistrationReleaseFailure[] CopyFailures() + { + if (_failureCount == 0) + { + return Array.Empty(); + } + + LuaRegistrationReleaseFailure[] copy = new LuaRegistrationReleaseFailure[_failureCount]; + Array.Copy(_failures, copy, _failureCount); + return copy; + } + + private LeaseEntry[]? CopyResidual() + { + if (_residualCount == 0) + { + return null; + } + + LeaseEntry[] copy = new LeaseEntry[_residualCount]; + Array.Copy(_residual!, copy, _residualCount); + return copy; + } + } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs index a0f4cbaa..faf0bfff 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Types; namespace CheatEngine.SDK.Lua.Runtime; @@ -40,81 +41,90 @@ namespace CheatEngine.SDK.Lua.Runtime; [StructLayout(LayoutKind.Auto)] public readonly unsafe struct LuaHostBinding : IEquatable { - /// Creates a binding from typed function pointers, in the shape of the host's exported-functions record. - /// The per-thread state provider; must not be null. - /// The host-object pusher; null when the host has none. - /// The managed thread id of the host's main thread. - public LuaHostBinding( - delegate* unmanaged[Stdcall] stateProvider, - delegate* unmanaged[Stdcall] hostObjectPusher, - int mainThreadId) - { - StateProvider = (nint)stateProvider; - HostObjectPusher = (nint)hostObjectPusher; - MainThreadId = mainThreadId; - } + /// Creates a binding from typed function pointers, in the shape of the host's exported-functions record. + /// The per-thread state provider; must not be null. + /// The host-object pusher; null when the host has none. + /// The managed thread id of the host's main thread. + public LuaHostBinding( + delegate* unmanaged[Stdcall] stateProvider, + delegate* unmanaged[Stdcall] hostObjectPusher, + int mainThreadId) + { + StateProvider = (nint) stateProvider; + HostObjectPusher = (nint) hostObjectPusher; + MainThreadId = mainThreadId; + } - /// Creates a binding from raw function addresses, for code that received them as integers. - /// Address of a stdcall function lua_State* (); must not be zero. - /// Address of a stdcall function void (lua_State*, void*), or zero. - /// The managed thread id of the host's main thread. - public LuaHostBinding(nint stateProvider, nint hostObjectPusher, int mainThreadId) - { - StateProvider = stateProvider; - HostObjectPusher = hostObjectPusher; - MainThreadId = mainThreadId; - } + /// Creates a binding from raw function addresses, for code that received them as integers. + /// Address of a stdcall function lua_State* (); must not be zero. + /// Address of a stdcall function void (lua_State*, void*), or zero. + /// The managed thread id of the host's main thread. + public LuaHostBinding(nint stateProvider, nint hostObjectPusher, int mainThreadId) + { + StateProvider = stateProvider; + HostObjectPusher = hostObjectPusher; + MainThreadId = mainThreadId; + } - /// Gets the address of the per-thread state provider. - public nint StateProvider { get; } + /// Gets the address of the per-thread state provider. + public nint StateProvider + { + get; + } - /// Gets the address of the host-object pusher, or zero. - public nint HostObjectPusher { get; } + /// Gets the address of the host-object pusher, or zero. + public nint HostObjectPusher + { + get; + } - /// Gets the managed thread id of the host's main thread. - public int MainThreadId { get; } + /// Gets the managed thread id of the host's main thread. + public int MainThreadId + { + get; + } - /// Gets a value indicating whether the binding can be attached: it has a state provider. - public bool IsValid => StateProvider != 0; + /// Gets a value indicating whether the binding can be attached: it has a state provider. + public bool IsValid => StateProvider != 0; - internal delegate* unmanaged[Stdcall] Provider => - (delegate* unmanaged[Stdcall])StateProvider; + internal delegate* unmanaged[Stdcall] Provider => + (delegate* unmanaged[Stdcall]) StateProvider; - internal delegate* unmanaged[Stdcall] Pusher => - (delegate* unmanaged[Stdcall])HostObjectPusher; + internal delegate* unmanaged[Stdcall] Pusher => + (delegate* unmanaged[Stdcall]) HostObjectPusher; - /// Compares all three fields. - /// First binding. - /// Second binding. - public static bool operator ==(LuaHostBinding left, LuaHostBinding right) - { - return left.Equals(right); - } + /// Compares all three fields. + /// First binding. + /// Second binding. + public static bool operator ==(LuaHostBinding left, LuaHostBinding right) + { + return left.Equals(right); + } - /// Compares all three fields. - /// First binding. - /// Second binding. - public static bool operator !=(LuaHostBinding left, LuaHostBinding right) - { - return !left.Equals(right); - } + /// Compares all three fields. + /// First binding. + /// Second binding. + public static bool operator !=(LuaHostBinding left, LuaHostBinding right) + { + return !left.Equals(right); + } - /// - public bool Equals(LuaHostBinding other) - { - return StateProvider == other.StateProvider && HostObjectPusher == other.HostObjectPusher && - MainThreadId == other.MainThreadId; - } + /// + public bool Equals(LuaHostBinding other) + { + return StateProvider == other.StateProvider && HostObjectPusher == other.HostObjectPusher && + MainThreadId == other.MainThreadId; + } - /// - public override bool Equals(object? obj) - { - return obj is LuaHostBinding other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is LuaHostBinding other && Equals(other); + } - /// - public override int GetHashCode() - { - return HashCode.Combine(StateProvider, HostObjectPusher, MainThreadId); - } + /// + public override int GetHashCode() + { + return HashCode.Combine(StateProvider, HostObjectPusher, MainThreadId); + } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaHostServices.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaHostServices.cs index c5d1a4ec..6fb193d6 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaHostServices.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaHostServices.cs @@ -8,19 +8,31 @@ namespace CheatEngine.SDK.Lua.Runtime; /// internal sealed unsafe class LuaHostServices { - internal LuaHostServices(in LuaHostBinding binding) - { - Binding = binding; - Provider = binding.Provider; - Pusher = binding.Pusher; - MainThreadId = binding.MainThreadId; - } + internal LuaHostServices(in LuaHostBinding binding) + { + Binding = binding; + Provider = binding.Provider; + Pusher = binding.Pusher; + MainThreadId = binding.MainThreadId; + } - internal LuaHostBinding Binding { get; } + internal LuaHostBinding Binding + { + get; + } - internal delegate* unmanaged[Stdcall] Provider { get; } + internal delegate* unmanaged[Stdcall] Provider + { + get; + } - internal delegate* unmanaged[Stdcall] Pusher { get; } + internal delegate* unmanaged[Stdcall] Pusher + { + get; + } - internal int MainThreadId { get; } + internal int MainThreadId + { + get; + } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs index c65b4ef5..dd2dad8c 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs @@ -4,12 +4,14 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Protected; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.State; @@ -62,709 +64,787 @@ namespace CheatEngine.SDK.Lua.Runtime; /// public static unsafe class LuaRuntime { - private static readonly Lock SGate = new(); - private static readonly Lock SOperationGate = new(); - private static readonly ManualResetEventSlim SOperationsDrained = new(initialState: true); - - private static LuaHostServices? s_services; - private static int s_activeOperations; - private static bool s_acceptOperations; - - private static bool s_resetTransitionActive; - - // High 32 bits: attach epoch. Low 32 bits: state generation. A single volatile read never combines either component - // from different lifecycle transitions. - private static long s_identity; - - // A transition owner is allowed to release callbacks and references after admission has closed. An active Lua - // operation is never allowed to start a transition: doing so would wait for itself and deadlock. - [ThreadStatic] private static int t_operationDepth; - - [ThreadStatic] private static int t_transitionDepth; - - // Deterministic lifecycle-race seam used only by the SDK's friend test assembly. It is invoked after admission is - // closed and before the drain wait, outside every runtime lock. - internal static Action? OperationAdmissionClosedForTesting; - - /// Gets a value indicating whether a host binding is attached. Lock-free; any thread. - public static bool IsAttached => Volatile.Read(ref s_services) is not null; - - /// - /// Gets whether the calling thread currently owns an admitted Lua operation. Internal lifecycle code uses this - /// preflight before taking another lifecycle lock, so an invalid nested transition is rejected without waiting - /// behind a transition that is draining this thread's lease. - /// - internal static bool IsOperationAdmittedOnCurrentThread => t_operationDepth != 0; - - /// - /// Gets the attach counter: 0 before the first , incremented by every attach, unchanged by - /// or a supported state replacement. This compatibility property is the attach component - /// of ; persistent Lua resources must compare the complete identity. - /// Lock-free; any thread. - /// - public static int Epoch - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => CurrentStateIdentity.AttachEpoch; - } - - /// - /// Gets the Lua state replacement counter. It changes only when the SDK prepares a supported state replacement, - /// not when the host attaches or detaches. Lock-free; any thread. - /// - public static int StateGeneration - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => CurrentStateIdentity.StateGeneration; - } - - /// - /// Gets the atomically captured pair of the current attachment epoch and state generation. A persistent Lua - /// resource is usable only while both components match the value it captured. Lock-free; any thread. - /// - public static LuaStateIdentity CurrentStateIdentity - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => UnpackIdentity(Volatile.Read(ref s_identity)); - } - - /// - /// Gets a value indicating whether the calling thread is the host's main thread. while - /// detached. - /// - public static bool IsMainThread - { - get - { - var services = Volatile.Read(ref s_services); - return services is not null && services.MainThreadId == Environment.CurrentManagedThreadId; - } - } - - /// Gets the attached binding, or while detached. - public static LuaHostBinding CurrentBinding => Volatile.Read(ref s_services)?.Binding ?? default; - - /// - /// Acquires a Lua state together with a lifecycle admission that spans the whole synchronous operation. - /// - /// The admitted operation. Dispose it before returning to the host. - /// - /// No binding is attached, the host returned no state for the calling thread, or a lifecycle transition has - /// already closed admission for new work. - /// - /// - /// Use using var operation = LuaRuntime.AcquireOperation(); var state = operation.State;. The provider is - /// called exactly once after admission succeeds. This method does not allocate and must not be used across an - /// . - /// - [RequiresPluginEnabled] - public static LuaRuntimeOperation AcquireOperation() - { - if (TryEnterProviderOperation(out var state) == LuaCallbackDisposeOperationResult.Acquired) - return new LuaRuntimeOperation(state, admitted: true); - - if (Volatile.Read(ref s_services) is null) ThrowDetached(); - if (!IsOperationAdmissionOpen()) ThrowOperationAdmissionClosed(); - throw new InvalidOperationException("The host returned no Lua state for the calling thread."); - } - - /// Non-throwing . - /// The admitted operation on success; default otherwise. - /// - /// when a binding is attached, admission is open and the host provided a state for the - /// calling thread. - /// - [RequiresPluginEnabled] - public static bool TryAcquireOperation(out LuaRuntimeOperation operation) - { - if (TryEnterProviderOperation(out var state) == LuaCallbackDisposeOperationResult.Acquired) - { - operation = new LuaRuntimeOperation(state, admitted: true); - return true; - } - - operation = default; - return false; - } - - /// - /// Acquires an operation for and reports whether an attached lifecycle - /// transition, rather than an unavailable state, rejected it. - /// - /// - /// The result is selected while SOperationGate is held. In particular, an - /// result cannot be reinterpreted as detached - /// after an unsuccessful transition reopens admission: callback disposal must leave registry ownership with that - /// transition until a state has neutralized the Lua closure. - /// - internal static LuaCallbackDisposeOperationResult TryAcquireOperationForCallbackDispose( - out LuaRuntimeOperation operation) - { - var result = TryEnterProviderOperation(out var state); - operation = result == LuaCallbackDisposeOperationResult.Acquired - ? new LuaRuntimeOperation(state, admitted: true) - : default; - return result; - } - - /// - /// Acquires lifecycle admission for synchronous work that already has the Lua state supplied by the host, such - /// as a generated binding with a leading parameter. - /// - /// The calling thread's Lua state. - /// The admitted operation. Dispose it after the last operation on . - /// - /// No binding is attached or the runtime is closing admission for a lifecycle transition. - /// - /// - /// This overload does not call the host state provider. It is re-entrant on a thread that already owns an - /// operation: the returned non-owning lease preserves but relies on the outer lease, - /// so nested generated helpers cannot release admission before their caller has restored its stack. Do not use - /// it across . - /// - [RequiresPluginEnabled] - public static LuaRuntimeOperation AcquireOperation(LuaState state) - { - if (state.IsNull) - throw new ArgumentException("A supplied Lua operation state cannot be null.", nameof(state)); - - if (Volatile.Read(ref s_services) is null) ThrowDetached(); - // Only the lifecycle code itself may use a state while it has made the transition exclusive. A public - // generated binding must never inherit that privilege merely because it happened to run synchronously from - // host cleanup code. - if (t_transitionDepth != 0) ThrowOperationAdmissionClosed(); - if (t_operationDepth != 0) return new LuaRuntimeOperation(state, admitted: false); - if (TryEnterOperation()) return new LuaRuntimeOperation(state, admitted: true); - - ThrowOperationAdmissionClosed(); - return default; - } - - /// - /// Pushes one generated [LuaFunction] closure and captures the current attachment identity in that - /// closure. Stack: +1 (the function) on success; +1 (the error value) on failure. - /// - /// The calling thread's Lua state. - /// The generated unmanaged thunk to wrap. - /// The status of installing the Lua error wrapper and guarded closure. - /// - /// The LuaBindings generator is the normal caller. Each invocation stores the current attachment epoch and - /// state generation as closure upvalues. Consequently, a Lua script retaining a function value after disable, - /// state reset or re-enable receives an ordinary Lua error instead of reaching the old generated scope. A - /// detached native-fixture registration remains supported for existing direct-runtime tests; it is explicitly - /// marked as not requiring an attachment and must not be used as a host lifecycle integration path. - /// - [EditorBrowsable(EditorBrowsableState.Advanced)] - public static LuaStatus TryPushGeneratedFunction(LuaState state, LuaNativeFunction thunk) - { - using var operation = EnterStateOperation(state); - var identity = CurrentStateIdentity; - var requiresAttachedRuntime = Volatile.Read(ref s_services) is not null; - return state.TryPushGeneratedFunction(thunk, identity, requiresAttachedRuntime); - } - - /// - /// Publishes a host binding and advances . Called by the host's enable callback; when a binding - /// is already attached it is replaced (its live callbacks are neutralized first, as in ). - /// - /// The binding; its state provider must not be zero. - /// has no state provider. - /// - /// Requires CheatEngine.SDK.Lua.Interop.Api.LuaApi to be bound already (asserted in Debug builds): every - /// operation after this call goes through it. - /// - public static void Attach(in LuaHostBinding binding) - { - if (!binding.IsValid) - throw new ArgumentException("The host binding has no Lua state provider.", nameof(binding)); - - Debug.Assert(LuaApi.IsInitialized, - "CheatEngine.SDK.Lua.Interop.Api.LuaApi must be bound before the runtime is attached."); - - ThrowIfTransitionFromCurrentOperation(); - lock (SGate) - { - ThrowIfResetTransitionActive(); - CloseOperationAdmissionAndDrain(); - BeginTransition(); - try - { - var previous = s_services; - if (previous is not null) - { - LuaHostSubscriptionRegistry.DetachAll(new LuaState(previous.Provider())); - LuaCallbackRegistry.DetachAll(previous); - } - - lock (LuaReferences.Gate) - { - var identity = CurrentStateIdentity; - PublishIdentity(unchecked(identity.AttachEpoch + 1), identity.StateGeneration); - Volatile.Write(ref s_services, new LuaHostServices(binding)); - LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); - } - } - finally - { - EndTransition(); - // If replacement cleanup failed, s_services still names the previous usable binding. Reopen it rather - // than stranding every caller behind the admission gate until a later lifecycle call happens to retry. - OpenOperationAdmission(); - if (Volatile.Read(ref s_services) is not null) LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); - } - } - } - - /// - /// Begins the exclusive SDK side of a supported Lua-state replacement. The returned transition keeps operation - /// admission closed until its method completes. - /// - /// - /// This internal protocol is intentionally unavailable until the SDK owns the protected CE - /// resetLuaState invocation end to end. Call it while the old state is reachable, perform the host reset, - /// then dispose the returned transition. It first rejects new operations and drains admitted operations, then - /// neutralizes callbacks and advances . A reset attempted from an admitted Lua - /// operation is rejected rather than waiting for itself. If the host reset fails, dispose still reopens admission; - /// the advanced generation conservatively abandons old state resources. - /// - internal static LuaStateResetTransition BeginStateReset() - { - ThrowIfTransitionFromCurrentOperation(); - lock (SGate) - { - ThrowIfResetTransitionActive(); - var services = s_services; - if (services is null) ThrowDetached(); - - CloseOperationAdmissionAndDrain(); - BeginTransition(); - try - { - LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); - LuaCallbackRegistry.DetachAll(services); - lock (LuaReferences.Gate) - { - var identity = CurrentStateIdentity; - PublishIdentity(identity.AttachEpoch, unchecked(identity.StateGeneration + 1)); - } - - s_resetTransitionActive = true; - return new LuaStateResetTransition(active: true); - } - catch - { - EndTransition(); - OpenOperationAdmission(); - LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); - throw; - } - } - } - - /// - /// Withdraws the host binding. Called by the host's disable callback while the provider is still valid: every - /// live is neutralized on the Lua side and its managed state freed before the binding - /// goes away. Idempotent. Does not change . - /// - public static void Detach() - { - ThrowIfTransitionFromCurrentOperation(); - lock (SGate) - { - ThrowIfResetTransitionActive(); - var services = s_services; - if (services is null) return; - - CloseOperationAdmissionAndDrain(); - BeginTransition(); - var detachSucceeded = false; - try - { - LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); - LuaCallbackRegistry.DetachAll(services); - Volatile.Write(ref s_services, null); - detachSucceeded = true; - } - finally - { - EndTransition(); - // A callback release can fail (for example, while Lua rejects a registry operation). Keep the binding - // and its remaining registry entries reachable so a caller can retry Detach after that failure clears. - if (!detachSucceeded) - { - OpenOperationAdmission(); - LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); - } - } - } - } - - /// - /// Asks the host for the Lua state of the calling thread without retaining a lifecycle operation admission. - /// - /// The state view; never . - /// - /// No binding is attached (the plugin is not enabled), the host returned no state for this thread, or a lifecycle - /// transition is in progress. - /// - /// - /// This legacy escape hatch is unsafe across attach, detach and Lua-state replacement because the returned value - /// carries no deterministic release point for a lifecycle admission. Normal SDK and generated code must use - /// instead. It remains only for advanced code that is already externally - /// serialized with the host lifecycle (for example, a Lua callback receiving its own state). - /// - [RequiresPluginEnabled] - [EditorBrowsable(EditorBrowsableState.Advanced)] - public static LuaState AcquireState() - { - using var operation = AcquireOperation(); - return operation.State; - } - - /// Non-throwing advanced-unsafe counterpart of . - /// The state view, or on failure. - /// while detached or when the host returned no state for this thread. - public static bool TryAcquireState(out LuaState state) - { - if (!TryAcquireOperation(out var operation)) - { - state = default; - return false; - } - - try - { - state = operation.State; - return true; - } - finally - { - operation.Dispose(); - } - } - - /// - /// Pushes the host's userdata for a native object (Cheat Engine's LuaPushClassInstance): the only way a - /// host object gets onto the stack. Stack: +1. - /// - /// The state to push on; the calling thread's. - /// - /// The native object pointer as the host knows it; zero is passed through and is the host's - /// business. - /// - /// No binding is attached, or the binding has no pusher. - /// - /// The pusher runs beneath the native bridge's lua_pcallk protection. If creating the userdata or its - /// metatable raises a Lua error, the bridge returns that status and this method translates the error object on - /// the stack through the normal protected-result path; a Lua longjmp never crosses a managed frame. - /// - [RequiresPluginEnabled] - [LuaStackEffect(1)] - public static void PushHostObject(LuaState state, nint nativeObject) - { - using var operation = EnterStateOperation(state); - var services = Volatile.Read(ref s_services); - if (services is null) ThrowDetached(); - - if (services.Pusher is null) ThrowNoPusher(); - - state.CheckProtectedResult(new LuaStatus( - LuaProtectedApi.PushHostObject(state.Pointer, (nint)services.Pusher, nativeObject))); - } - - /// - /// Begins a lifecycle admission for an operation that already owns a Lua state, such as callback construction or - /// a private-reference operation. The transition owner and detached native-fixture callers need no admission. - /// - /// - /// The caller must dispose the returned value after its last Lua operation. This is internal so ordinary callers - /// acquire both the state and its admission through . A raw state obtained through - /// the advanced escape hatch remains the caller's responsibility across a host - /// lifecycle transition. - /// - internal static LuaRuntimeOperation EnterStateOperation(LuaState state) - { - if (t_transitionDepth != 0 || t_operationDepth != 0 || Volatile.Read(ref s_services) is null) return default; - if (!TryEnterOperation()) ThrowOperationAdmissionClosed(); - - return new LuaRuntimeOperation(state, admitted: true); - } - - /// - /// Attempts to admit one invocation of a managed Lua callback. A callback begun before a lifecycle transition - /// keeps the gate until its unmanaged thunk returns; one begun after admission has closed is rejected before - /// plugin code is entered. - /// - /// The lease held for this callback invocation when the runtime is attached. - /// - /// only when an attached runtime is closing admission. Detached native-fixture - /// callbacks remain supported and return with a default lease. - /// - internal static bool TryEnterCallbackOperation(out LuaRuntimeOperation operation) - { - // A callback that is re-entered by an already admitted Lua operation shares that outer lease. The lifecycle - // transition owner is different: admitting plugin code there would let a finalizer/metamethod re-enter after - // CloseOperationAdmissionAndDrain has established exclusive cleanup. - if (t_transitionDepth != 0 && Volatile.Read(ref s_services) is not null) - { - operation = default; - return false; - } - - if (t_operationDepth != 0 || Volatile.Read(ref s_services) is null) - { - operation = default; - return true; - } - - if (TryEnterOperation()) - { - operation = new LuaRuntimeOperation(default, admitted: true); - return true; - } - - operation = default; - return false; - } - - /// - /// Closes admission for every internal host-subscription callback and drains callbacks already admitted. Hosting - /// calls this before plugin OnDisable, while Lua remains attached; later owns the - /// state-bound unregister attempt. This is not a timer or hotkey capability surface. - /// - internal static void CloseHostSubscriptionAdmissionAndDrain() - { - ThrowIfTransitionFromCurrentOperation(); - if (Volatile.Read(ref s_services) is null) return; - - LuaHostSubscriptionRegistry.CloseCallbackAdmissionAndDrain(); - } - - // A generated [LuaFunction] closure stores this pair in Lua upvalues. Keeping the test and the following - // admission attempt distinct is intentional: once admission closes, a closure that raced with disable is rejected - // before user code; one admitted before the boundary retains its lease until its unmanaged thunk returns. - internal static bool IsGeneratedFunctionRegistrationCurrent(int attachEpoch, int stateGeneration) - { - return Volatile.Read(ref s_services) is not null - && Volatile.Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) - && IsOperationAdmissionOpen(); - } - - /// - /// Closes admission for new calls and waits for all admitted operations to leave. - /// This is the host lifecycle seam immediately before disable cleanup. It is idempotent while admission is closed. - /// - /// - /// This method must not run from an admitted Lua operation: waiting would deadlock on that operation's own lease. - /// It deliberately does not hold the admission gate while waiting or while the caller neutralizes callbacks. - /// - internal static void CloseOperationAdmissionAndDrain() - { - ThrowIfTransitionFromCurrentOperation(); - - lock (SOperationGate) - { - s_acceptOperations = false; - } - - Volatile.Read(ref OperationAdmissionClosedForTesting)?.Invoke(); - SOperationsDrained.Wait(); - } - - internal static void CompleteStateReset() - { - lock (SGate) - { - if (!s_resetTransitionActive || t_transitionDepth == 0) - throw new InvalidOperationException( - "The Lua state-reset transition must be completed on its owning thread."); - - s_resetTransitionActive = false; - EndTransition(); - OpenOperationAdmission(); - if (Volatile.Read(ref s_services) is not null) LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); - } - } - - internal static void ExitOperation() - { - if (t_operationDepth <= 0) - throw new InvalidOperationException( - "A Lua runtime operation must be disposed on the thread that acquired it."); - - t_operationDepth--; - lock (SOperationGate) - { - if (--s_activeOperations == 0) SOperationsDrained.Set(); - } - } - - private static LuaCallbackDisposeOperationResult TryEnterProviderOperation(out LuaState state) - { - LuaHostServices? services; - lock (SOperationGate) - { - services = s_services; - if (services is null) - { - state = default; - return LuaCallbackDisposeOperationResult.Unavailable; - } - - if (!s_acceptOperations) - { - state = default; - return LuaCallbackDisposeOperationResult.AdmissionClosed; - } - - IncrementActiveOperation(); - } - - try - { - var l = services.Provider(); - if (l is null) - { - ExitOperation(); - state = default; - return LuaCallbackDisposeOperationResult.Unavailable; - } - - state = new LuaState(l); - return LuaCallbackDisposeOperationResult.Acquired; - } - catch - { - ExitOperation(); - throw; - } - } - - private static bool TryEnterOperation() - { - lock (SOperationGate) - { - if (s_services is null || !s_acceptOperations) return false; - - IncrementActiveOperation(); - return true; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void IncrementActiveOperation() - { - checked - { - s_activeOperations++; - } - - t_operationDepth++; - if (s_activeOperations == 1) SOperationsDrained.Reset(); - } - - private static bool IsOperationAdmissionOpen() - { - lock (SOperationGate) - { - return s_acceptOperations; - } - } - - private static void OpenOperationAdmission() - { - lock (SOperationGate) - { - if (s_services is not null) s_acceptOperations = true; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void BeginTransition() - { - t_transitionDepth++; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void EndTransition() - { - t_transitionDepth--; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void ThrowIfResetTransitionActive() - { - if (s_resetTransitionActive) - throw new InvalidOperationException("A Lua state-reset transition is already active."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowDetached() - { - throw new InvalidOperationException( - "No host binding is attached: the plugin is not enabled, so there is no Lua state to talk to."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowNoState() - { - throw new InvalidOperationException("The host returned no Lua state for the calling thread."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowNoPusher() - { - throw new InvalidOperationException("The attached host binding has no host-object pusher."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowOperationAdmissionClosed() - { - throw new InvalidOperationException( - "The Lua runtime is transitioning, so it is not accepting a new Lua operation."); - } - - [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowTransitionFromOperation() - { - throw new InvalidOperationException( - "A Lua lifecycle transition cannot start from an admitted Lua operation because it would wait for itself."); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void ThrowIfTransitionFromCurrentOperation() - { - if (t_operationDepth != 0) ThrowTransitionFromOperation(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void PublishIdentity(int attachEpoch, int stateGeneration) - { - Volatile.Write(ref s_identity, PackIdentity(attachEpoch, stateGeneration)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static long PackIdentity(int attachEpoch, int stateGeneration) - { - return ((long)attachEpoch << 32) | (uint)stateGeneration; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static LuaStateIdentity UnpackIdentity(long packed) - { - return new LuaStateIdentity((int)(packed >> 32), (int)(uint)packed); - } - - /// Result of an internal callback-disposal operation acquisition. - internal enum LuaCallbackDisposeOperationResult - { - Acquired, - Unavailable, - AdmissionClosed, - } + private static readonly Lock SGate = new(); + private static readonly Lock SOperationGate = new(); + private static readonly ManualResetEventSlim SOperationsDrained = new(true); + + private static LuaHostServices? s_services; + private static int s_activeOperations; + private static bool s_acceptOperations; + + private static bool s_resetTransitionActive; + + // High 32 bits: attach epoch. Low 32 bits: state generation. A single volatile read never combines either component + // from different lifecycle transitions. + private static long s_identity; + + // A transition owner is allowed to release callbacks and references after admission has closed. An active Lua + // operation is never allowed to start a transition: doing so would wait for itself and deadlock. + [ThreadStatic] private static int t_operationDepth; + + [ThreadStatic] private static int t_transitionDepth; + + // Deterministic lifecycle-race seam used only by the SDK's friend test assembly. It is invoked after admission is + // closed and before the drain wait, outside every runtime lock. + internal static Action? OperationAdmissionClosedForTesting; + + /// Gets a value indicating whether a host binding is attached. Lock-free; any thread. + public static bool IsAttached => Volatile.Read(ref s_services) is not null; + + /// + /// Gets whether the calling thread currently owns an admitted Lua operation. Internal lifecycle code uses this + /// preflight before taking another lifecycle lock, so an invalid nested transition is rejected without waiting + /// behind a transition that is draining this thread's lease. + /// + internal static bool IsOperationAdmittedOnCurrentThread => t_operationDepth != 0; + + /// + /// Gets the attach counter: 0 before the first , incremented by every attach, unchanged by + /// or a supported state replacement. This compatibility property is the attach component + /// of ; persistent Lua resources must compare the complete identity. + /// Lock-free; any thread. + /// + public static int Epoch + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CurrentStateIdentity.AttachEpoch; + } + + /// + /// Gets the Lua state replacement counter. It changes only when the SDK prepares a supported state replacement, + /// not when the host attaches or detaches. Lock-free; any thread. + /// + public static int StateGeneration + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CurrentStateIdentity.StateGeneration; + } + + /// + /// Gets the atomically captured pair of the current attachment epoch and state generation. A persistent Lua + /// resource is usable only while both components match the value it captured. Lock-free; any thread. + /// + public static LuaStateIdentity CurrentStateIdentity + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UnpackIdentity(Volatile.Read(ref s_identity)); + } + + /// + /// Gets a value indicating whether the calling thread is the host's main thread. while + /// detached. + /// + public static bool IsMainThread + { + get + { + LuaHostServices? services = Volatile.Read(ref s_services); + return services is not null && services.MainThreadId == Environment.CurrentManagedThreadId; + } + } + + /// Gets the attached binding, or while detached. + public static LuaHostBinding CurrentBinding => Volatile.Read(ref s_services)?.Binding ?? default; + + /// + /// Acquires a Lua state together with a lifecycle admission that spans the whole synchronous operation. + /// + /// The admitted operation. Dispose it before returning to the host. + /// + /// No binding is attached, the host returned no state for the calling thread, or a lifecycle transition has + /// already closed admission for new work. + /// + /// + /// Use using var operation = LuaRuntime.AcquireOperation(); var state = operation.State;. The provider is + /// called exactly once after admission succeeds. This method does not allocate and must not be used across an + /// . + /// + [RequiresPluginEnabled] + public static LuaRuntimeOperation AcquireOperation() + { + if (TryEnterProviderOperation(out LuaState state) == LuaCallbackDisposeOperationResult.Acquired) + { + return new LuaRuntimeOperation(state, true); + } + + if (Volatile.Read(ref s_services) is null) + { + ThrowDetached(); + } + + if (!IsOperationAdmissionOpen()) + { + ThrowOperationAdmissionClosed(); + } + + throw new InvalidOperationException("The host returned no Lua state for the calling thread."); + } + + /// Non-throwing . + /// The admitted operation on success; default otherwise. + /// + /// when a binding is attached, admission is open and the host provided a state for the + /// calling thread. + /// + [RequiresPluginEnabled] + public static bool TryAcquireOperation(out LuaRuntimeOperation operation) + { + if (TryEnterProviderOperation(out LuaState state) == LuaCallbackDisposeOperationResult.Acquired) + { + operation = new LuaRuntimeOperation(state, true); + return true; + } + + operation = default; + return false; + } + + /// + /// Acquires an operation for and reports whether an attached lifecycle + /// transition, rather than an unavailable state, rejected it. + /// + /// + /// The result is selected while SOperationGate is held. In particular, an + /// result cannot be reinterpreted as detached + /// after an unsuccessful transition reopens admission: callback disposal must leave registry ownership with that + /// transition until a state has neutralized the Lua closure. + /// + internal static LuaCallbackDisposeOperationResult TryAcquireOperationForCallbackDispose( + out LuaRuntimeOperation operation) + { + LuaCallbackDisposeOperationResult result = TryEnterProviderOperation(out LuaState state); + operation = result == LuaCallbackDisposeOperationResult.Acquired + ? new LuaRuntimeOperation(state, true) + : default; + return result; + } + + /// + /// Acquires lifecycle admission for synchronous work that already has the Lua state supplied by the host, such + /// as a generated binding with a leading parameter. + /// + /// The calling thread's Lua state. + /// The admitted operation. Dispose it after the last operation on . + /// + /// No binding is attached or the runtime is closing admission for a lifecycle transition. + /// + /// + /// This overload does not call the host state provider. It is re-entrant on a thread that already owns an + /// operation: the returned non-owning lease preserves but relies on the outer lease, + /// so nested generated helpers cannot release admission before their caller has restored its stack. Do not use + /// it across . + /// + [RequiresPluginEnabled] + public static LuaRuntimeOperation AcquireOperation(LuaState state) + { + if (state.IsNull) + { + throw new ArgumentException("A supplied Lua operation state cannot be null.", nameof(state)); + } + + if (Volatile.Read(ref s_services) is null) + { + ThrowDetached(); + } + + // Only the lifecycle code itself may use a state while it has made the transition exclusive. A public + // generated binding must never inherit that privilege merely because it happened to run synchronously from + // host cleanup code. + if (t_transitionDepth != 0) + { + ThrowOperationAdmissionClosed(); + } + + if (t_operationDepth != 0) + { + return new LuaRuntimeOperation(state, false); + } + + if (TryEnterOperation()) + { + return new LuaRuntimeOperation(state, true); + } + + ThrowOperationAdmissionClosed(); + return default; + } + + /// + /// Pushes one generated [LuaFunction] closure and captures the current attachment identity in that + /// closure. Stack: +1 (the function) on success; +1 (the error value) on failure. + /// + /// The calling thread's Lua state. + /// The generated unmanaged thunk to wrap. + /// The status of installing the Lua error wrapper and guarded closure. + /// + /// The LuaBindings generator is the normal caller. Each invocation stores the current attachment epoch and + /// state generation as closure upvalues. Consequently, a Lua script retaining a function value after disable, + /// state reset or re-enable receives an ordinary Lua error instead of reaching the old generated scope. A + /// detached native-fixture registration remains supported for existing direct-runtime tests; it is explicitly + /// marked as not requiring an attachment and must not be used as a host lifecycle integration path. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static LuaStatus TryPushGeneratedFunction(LuaState state, LuaNativeFunction thunk) + { + using LuaRuntimeOperation operation = EnterStateOperation(state); + LuaStateIdentity identity = CurrentStateIdentity; + bool requiresAttachedRuntime = Volatile.Read(ref s_services) is not null; + return state.TryPushGeneratedFunction(thunk, identity, requiresAttachedRuntime); + } + + /// + /// Publishes a host binding and advances . Called by the host's enable callback; when a binding + /// is already attached it is replaced (its live callbacks are neutralized first, as in ). + /// + /// The binding; its state provider must not be zero. + /// has no state provider. + /// + /// Requires CheatEngine.SDK.Lua.Interop.Api.LuaApi to be bound already (asserted in Debug builds): every + /// operation after this call goes through it. + /// + public static void Attach(in LuaHostBinding binding) + { + if (!binding.IsValid) + { + throw new ArgumentException("The host binding has no Lua state provider.", nameof(binding)); + } + + Debug.Assert(LuaApi.IsInitialized, + "CheatEngine.SDK.Lua.Interop.Api.LuaApi must be bound before the runtime is attached."); + + ThrowIfTransitionFromCurrentOperation(); + lock (SGate) + { + ThrowIfResetTransitionActive(); + CloseOperationAdmissionAndDrain(); + BeginTransition(); + try + { + LuaHostServices? previous = s_services; + if (previous is not null) + { + LuaHostSubscriptionRegistry.DetachAll(new LuaState(previous.Provider())); + LuaCallbackRegistry.DetachAll(previous); + } + + lock (LuaReferences.Gate) + { + LuaStateIdentity identity = CurrentStateIdentity; + PublishIdentity(unchecked(identity.AttachEpoch + 1), identity.StateGeneration); + Volatile.Write(ref s_services, new LuaHostServices(binding)); + LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + } + } + finally + { + EndTransition(); + // If replacement cleanup failed, s_services still names the previous usable binding. Reopen it rather + // than stranding every caller behind the admission gate until a later lifecycle call happens to retry. + OpenOperationAdmission(); + if (Volatile.Read(ref s_services) is not null) + { + LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + } + } + } + } + + /// + /// Begins the exclusive SDK side of a supported Lua-state replacement. The returned transition keeps operation + /// admission closed until its method completes. + /// + /// + /// This internal protocol is intentionally unavailable until the SDK owns the protected CE + /// resetLuaState invocation end to end. Call it while the old state is reachable, perform the host reset, + /// then dispose the returned transition. It first rejects new operations and drains admitted operations, then + /// neutralizes callbacks and advances . A reset attempted from an admitted Lua + /// operation is rejected rather than waiting for itself. If the host reset fails, dispose still reopens admission; + /// the advanced generation conservatively abandons old state resources. + /// + internal static LuaStateResetTransition BeginStateReset() + { + ThrowIfTransitionFromCurrentOperation(); + lock (SGate) + { + ThrowIfResetTransitionActive(); + LuaHostServices? services = s_services; + if (services is null) + { + ThrowDetached(); + } + + CloseOperationAdmissionAndDrain(); + BeginTransition(); + try + { + LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); + LuaCallbackRegistry.DetachAll(services); + lock (LuaReferences.Gate) + { + LuaStateIdentity identity = CurrentStateIdentity; + PublishIdentity(identity.AttachEpoch, unchecked(identity.StateGeneration + 1)); + } + + s_resetTransitionActive = true; + return new LuaStateResetTransition(true); + } + catch + { + EndTransition(); + OpenOperationAdmission(); + LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + throw; + } + } + } + + /// + /// Withdraws the host binding. Called by the host's disable callback while the provider is still valid: every + /// live is neutralized on the Lua side and its managed state freed before the binding + /// goes away. Idempotent. Does not change . + /// + public static void Detach() + { + ThrowIfTransitionFromCurrentOperation(); + lock (SGate) + { + ThrowIfResetTransitionActive(); + LuaHostServices? services = s_services; + if (services is null) + { + return; + } + + CloseOperationAdmissionAndDrain(); + BeginTransition(); + bool detachSucceeded = false; + try + { + LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); + LuaCallbackRegistry.DetachAll(services); + Volatile.Write(ref s_services, null); + detachSucceeded = true; + } + finally + { + EndTransition(); + // A callback release can fail (for example, while Lua rejects a registry operation). Keep the binding + // and its remaining registry entries reachable so a caller can retry Detach after that failure clears. + if (!detachSucceeded) + { + OpenOperationAdmission(); + LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + } + } + } + } + + /// + /// Asks the host for the Lua state of the calling thread without retaining a lifecycle operation admission. + /// + /// The state view; never . + /// + /// No binding is attached (the plugin is not enabled), the host returned no state for this thread, or a lifecycle + /// transition is in progress. + /// + /// + /// This legacy escape hatch is unsafe across attach, detach and Lua-state replacement because the returned value + /// carries no deterministic release point for a lifecycle admission. Normal SDK and generated code must use + /// instead. It remains only for advanced code that is already externally + /// serialized with the host lifecycle (for example, a Lua callback receiving its own state). + /// + [RequiresPluginEnabled] + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static LuaState AcquireState() + { + using LuaRuntimeOperation operation = AcquireOperation(); + return operation.State; + } + + /// Non-throwing advanced-unsafe counterpart of . + /// The state view, or on failure. + /// while detached or when the host returned no state for this thread. + public static bool TryAcquireState(out LuaState state) + { + if (!TryAcquireOperation(out LuaRuntimeOperation operation)) + { + state = default; + return false; + } + + try + { + state = operation.State; + return true; + } + finally + { + operation.Dispose(); + } + } + + /// + /// Pushes the host's userdata for a native object (Cheat Engine's LuaPushClassInstance): the only way a + /// host object gets onto the stack. Stack: +1. + /// + /// The state to push on; the calling thread's. + /// + /// The native object pointer as the host knows it; zero is passed through and is the host's + /// business. + /// + /// No binding is attached, or the binding has no pusher. + /// + /// The pusher runs beneath the native bridge's lua_pcallk protection. If creating the userdata or its + /// metatable raises a Lua error, the bridge returns that status and this method translates the error object on + /// the stack through the normal protected-result path; a Lua longjmp never crosses a managed frame. + /// + [RequiresPluginEnabled] + [LuaStackEffect(1)] + public static void PushHostObject(LuaState state, nint nativeObject) + { + using LuaRuntimeOperation operation = EnterStateOperation(state); + LuaHostServices? services = Volatile.Read(ref s_services); + if (services is null) + { + ThrowDetached(); + } + + if (services.Pusher is null) + { + ThrowNoPusher(); + } + + state.CheckProtectedResult(new LuaStatus( + LuaProtectedApi.PushHostObject(state.Pointer, (nint) services.Pusher, nativeObject))); + } + + /// + /// Begins a lifecycle admission for an operation that already owns a Lua state, such as callback construction or + /// a private-reference operation. The transition owner and detached native-fixture callers need no admission. + /// + /// + /// The caller must dispose the returned value after its last Lua operation. This is internal so ordinary callers + /// acquire both the state and its admission through . A raw state obtained through + /// the advanced escape hatch remains the caller's responsibility across a host + /// lifecycle transition. + /// + internal static LuaRuntimeOperation EnterStateOperation(LuaState state) + { + if (t_transitionDepth != 0 || t_operationDepth != 0 || Volatile.Read(ref s_services) is null) + { + return default; + } + + if (!TryEnterOperation()) + { + ThrowOperationAdmissionClosed(); + } + + return new LuaRuntimeOperation(state, true); + } + + /// + /// Attempts to admit one invocation of a managed Lua callback. A callback begun before a lifecycle transition + /// keeps the gate until its unmanaged thunk returns; one begun after admission has closed is rejected before + /// plugin code is entered. + /// + /// The lease held for this callback invocation when the runtime is attached. + /// + /// only when an attached runtime is closing admission. Detached native-fixture + /// callbacks remain supported and return with a default lease. + /// + internal static bool TryEnterCallbackOperation(out LuaRuntimeOperation operation) + { + // A callback that is re-entered by an already admitted Lua operation shares that outer lease. The lifecycle + // transition owner is different: admitting plugin code there would let a finalizer/metamethod re-enter after + // CloseOperationAdmissionAndDrain has established exclusive cleanup. + if (t_transitionDepth != 0 && Volatile.Read(ref s_services) is not null) + { + operation = default; + return false; + } + + if (t_operationDepth != 0 || Volatile.Read(ref s_services) is null) + { + operation = default; + return true; + } + + if (TryEnterOperation()) + { + operation = new LuaRuntimeOperation(default, true); + return true; + } + + operation = default; + return false; + } + + /// + /// Closes admission for every internal host-subscription callback and drains callbacks already admitted. Hosting + /// calls this before plugin OnDisable, while Lua remains attached; later owns the + /// state-bound unregister attempt. This is not a timer or hotkey capability surface. + /// + internal static void CloseHostSubscriptionAdmissionAndDrain() + { + ThrowIfTransitionFromCurrentOperation(); + if (Volatile.Read(ref s_services) is null) + { + return; + } + + LuaHostSubscriptionRegistry.CloseCallbackAdmissionAndDrain(); + } + + // A generated [LuaFunction] closure stores this pair in Lua upvalues. Keeping the test and the following + // admission attempt distinct is intentional: once admission closes, a closure that raced with disable is rejected + // before user code; one admitted before the boundary retains its lease until its unmanaged thunk returns. + internal static bool IsGeneratedFunctionRegistrationCurrent(int attachEpoch, int stateGeneration) + { + return Volatile.Read(ref s_services) is not null + && Volatile.Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) + && IsOperationAdmissionOpen(); + } + + /// + /// Closes admission for new calls and waits for all admitted operations to leave. + /// This is the host lifecycle seam immediately before disable cleanup. It is idempotent while admission is closed. + /// + /// + /// This method must not run from an admitted Lua operation: waiting would deadlock on that operation's own lease. + /// It deliberately does not hold the admission gate while waiting or while the caller neutralizes callbacks. + /// + internal static void CloseOperationAdmissionAndDrain() + { + ThrowIfTransitionFromCurrentOperation(); + + lock (SOperationGate) + { + s_acceptOperations = false; + } + + Volatile.Read(ref OperationAdmissionClosedForTesting)?.Invoke(); + SOperationsDrained.Wait(); + } + + internal static void CompleteStateReset() + { + lock (SGate) + { + if (!s_resetTransitionActive || t_transitionDepth == 0) + { + throw new InvalidOperationException( + "The Lua state-reset transition must be completed on its owning thread."); + } + + s_resetTransitionActive = false; + EndTransition(); + OpenOperationAdmission(); + if (Volatile.Read(ref s_services) is not null) + { + LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + } + } + } + + internal static void ExitOperation() + { + if (t_operationDepth <= 0) + { + throw new InvalidOperationException( + "A Lua runtime operation must be disposed on the thread that acquired it."); + } + + t_operationDepth--; + lock (SOperationGate) + { + if (--s_activeOperations == 0) + { + SOperationsDrained.Set(); + } + } + } + + private static LuaCallbackDisposeOperationResult TryEnterProviderOperation(out LuaState state) + { + LuaHostServices? services; + lock (SOperationGate) + { + services = s_services; + if (services is null) + { + state = default; + return LuaCallbackDisposeOperationResult.Unavailable; + } + + if (!s_acceptOperations) + { + state = default; + return LuaCallbackDisposeOperationResult.AdmissionClosed; + } + + IncrementActiveOperation(); + } + + try + { + lua_State* l = services.Provider(); + if (l is null) + { + ExitOperation(); + state = default; + return LuaCallbackDisposeOperationResult.Unavailable; + } + + state = new LuaState(l); + return LuaCallbackDisposeOperationResult.Acquired; + } + catch + { + ExitOperation(); + throw; + } + } + + private static bool TryEnterOperation() + { + lock (SOperationGate) + { + if (s_services is null || !s_acceptOperations) + { + return false; + } + + IncrementActiveOperation(); + return true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void IncrementActiveOperation() + { + checked + { + s_activeOperations++; + } + + t_operationDepth++; + if (s_activeOperations == 1) + { + SOperationsDrained.Reset(); + } + } + + private static bool IsOperationAdmissionOpen() + { + lock (SOperationGate) + { + return s_acceptOperations; + } + } + + private static void OpenOperationAdmission() + { + lock (SOperationGate) + { + if (s_services is not null) + { + s_acceptOperations = true; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void BeginTransition() + { + t_transitionDepth++; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void EndTransition() + { + t_transitionDepth--; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ThrowIfResetTransitionActive() + { + if (s_resetTransitionActive) + { + throw new InvalidOperationException("A Lua state-reset transition is already active."); + } + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowDetached() + { + throw new InvalidOperationException( + "No host binding is attached: the plugin is not enabled, so there is no Lua state to talk to."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNoState() + { + throw new InvalidOperationException("The host returned no Lua state for the calling thread."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNoPusher() + { + throw new InvalidOperationException("The attached host binding has no host-object pusher."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowOperationAdmissionClosed() + { + throw new InvalidOperationException( + "The Lua runtime is transitioning, so it is not accepting a new Lua operation."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowTransitionFromOperation() + { + throw new InvalidOperationException( + "A Lua lifecycle transition cannot start from an admitted Lua operation because it would wait for itself."); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ThrowIfTransitionFromCurrentOperation() + { + if (t_operationDepth != 0) + { + ThrowTransitionFromOperation(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void PublishIdentity(int attachEpoch, int stateGeneration) + { + Volatile.Write(ref s_identity, PackIdentity(attachEpoch, stateGeneration)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long PackIdentity(int attachEpoch, int stateGeneration) + { + return ((long) attachEpoch << 32) | (uint) stateGeneration; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static LuaStateIdentity UnpackIdentity(long packed) + { + return new LuaStateIdentity((int) (packed >> 32), (int) (uint) packed); + } + + /// Result of an internal callback-disposal operation acquisition. + internal enum LuaCallbackDisposeOperationResult + { + Acquired, + Unavailable, + AdmissionClosed + } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeOperation.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeOperation.cs index 711ee737..1191af01 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeOperation.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeOperation.cs @@ -17,26 +17,33 @@ namespace CheatEngine.SDK.Lua.Runtime; /// public ref struct LuaRuntimeOperation { - private bool _admitted; + private bool _admitted; - internal LuaRuntimeOperation(LuaState state, bool admitted) - { - State = state; - _admitted = admitted; - } + internal LuaRuntimeOperation(LuaState state, bool admitted) + { + State = state; + _admitted = admitted; + } - /// Gets the Lua state acquired for this operation on the calling thread. - public LuaState State { get; } + /// Gets the Lua state acquired for this operation on the calling thread. + public LuaState State + { + get; + } - /// - /// Ends the operation admission. Idempotent for the same value; callers should rely on a scope - /// instead of invoking it directly. - /// - public void Dispose() - { - if (!_admitted) return; + /// + /// Ends the operation admission. Idempotent for the same value; callers should rely on a + /// scope + /// instead of invoking it directly. + /// + public void Dispose() + { + if (!_admitted) + { + return; + } - _admitted = false; - LuaRuntime.ExitOperation(); - } + _admitted = false; + LuaRuntime.ExitOperation(); + } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaStateIdentity.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaStateIdentity.cs index 0fd8d309..898a43c9 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaStateIdentity.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaStateIdentity.cs @@ -20,51 +20,57 @@ namespace CheatEngine.SDK.Lua.Runtime; [StructLayout(LayoutKind.Sequential)] public readonly struct LuaStateIdentity : IEquatable { - internal LuaStateIdentity(int attachEpoch, int stateGeneration) - { - AttachEpoch = attachEpoch; - StateGeneration = stateGeneration; - } + internal LuaStateIdentity(int attachEpoch, int stateGeneration) + { + AttachEpoch = attachEpoch; + StateGeneration = stateGeneration; + } - /// Gets the attachment lifetime that established the state. - public int AttachEpoch { get; } + /// Gets the attachment lifetime that established the state. + public int AttachEpoch + { + get; + } - /// Gets the state replacement generation within the attachment lifetime. - public int StateGeneration { get; } + /// Gets the state replacement generation within the attachment lifetime. + public int StateGeneration + { + get; + } - /// Compares both the attachment epoch and the state generation. - public static bool operator ==(LuaStateIdentity left, LuaStateIdentity right) - { - return left.Equals(right); - } + /// Compares both the attachment epoch and the state generation. + public static bool operator ==(LuaStateIdentity left, LuaStateIdentity right) + { + return left.Equals(right); + } - /// Compares both the attachment epoch and the state generation. - public static bool operator !=(LuaStateIdentity left, LuaStateIdentity right) - { - return !left.Equals(right); - } + /// Compares both the attachment epoch and the state generation. + public static bool operator !=(LuaStateIdentity left, LuaStateIdentity right) + { + return !left.Equals(right); + } - /// - public bool Equals(LuaStateIdentity other) - { - return AttachEpoch == other.AttachEpoch && StateGeneration == other.StateGeneration; - } + /// + public bool Equals(LuaStateIdentity other) + { + return AttachEpoch == other.AttachEpoch && StateGeneration == other.StateGeneration; + } - /// - public override bool Equals(object? obj) - { - return obj is LuaStateIdentity other && Equals(other); - } + /// + public override bool Equals(object? obj) + { + return obj is LuaStateIdentity other && Equals(other); + } - /// - public override int GetHashCode() - { - return HashCode.Combine(AttachEpoch, StateGeneration); - } + /// + public override int GetHashCode() + { + return HashCode.Combine(AttachEpoch, StateGeneration); + } - /// Formats the identity for diagnostics only. - public override string ToString() - { - return $"(attach epoch {AttachEpoch}, state generation {StateGeneration})"; - } + /// Formats the identity for diagnostics only. + public override string ToString() + { + return $"(attach epoch {AttachEpoch}, state generation {StateGeneration})"; + } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaStateResetTransition.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaStateResetTransition.cs index baf14d87..42e48a80 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaStateResetTransition.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaStateResetTransition.cs @@ -9,18 +9,21 @@ namespace CheatEngine.SDK.Lua.Runtime; /// internal ref struct LuaStateResetTransition { - private bool _active; + private bool _active; - internal LuaStateResetTransition(bool active) - { - _active = active; - } + internal LuaStateResetTransition(bool active) + { + _active = active; + } - public void Dispose() - { - if (!_active) return; + public void Dispose() + { + if (!_active) + { + return; + } - _active = false; - LuaRuntime.CompleteStateReset(); - } + _active = false; + LuaRuntime.CompleteStateReset(); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaFrame.cs b/libs/CheatEngine.SDK.Lua/State/LuaFrame.cs index e5fb71dc..d8f047fb 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaFrame.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaFrame.cs @@ -25,45 +25,48 @@ namespace CheatEngine.SDK.Lua.State; [StructLayout(LayoutKind.Auto)] public readonly ref struct LuaFrame : IDisposable { - private readonly LuaState _state; + private readonly LuaState _state; - /// Records the current top of . - /// The state to guard; must not be . - public LuaFrame(LuaState state) - { - _state = state; - Top = state.Top; - } + /// Records the current top of . + /// The state to guard; must not be . + public LuaFrame(LuaState state) + { + _state = state; + Top = state.Top; + } - /// Gets the guarded state. - public LuaState State => _state; + /// Gets the guarded state. + public LuaState State => _state; - /// - /// Gets the stack top recorded at creation: the height restores, and the index below the - /// frame's first own value. - /// - public int Top { get; } + /// + /// Gets the stack top recorded at creation: the height restores, and the index below the + /// frame's first own value. + /// + public int Top + { + get; + } - /// Gets the number of values the frame currently owns: those pushed since it was created. - public int Count => _state.Top - Top; + /// Gets the number of values the frame currently owns: those pushed since it was created. + public int Count => _state.Top - Top; - /// - /// Asserts, in Debug builds only, that the stack is exactly at the recorded height: for code that claims to be - /// balanced by construction before the frame restores anything. - /// - [Conditional("DEBUG")] - public void AssertBalanced() - { - Debug.Assert(_state.Top == Top, "The Lua stack is not at the height recorded by the frame."); - } + /// + /// Asserts, in Debug builds only, that the stack is exactly at the recorded height: for code that claims to be + /// balanced by construction before the frame restores anything. + /// + [Conditional("DEBUG")] + public void AssertBalanced() + { + Debug.Assert(_state.Top == Top, "The Lua stack is not at the height recorded by the frame."); + } - /// Restores the recorded top, dropping every value pushed inside the frame. Never raises; idempotent. - public void Dispose() - { - // A frame that ends below its own start has consumed values it did not own: restoring would paper over it - // with nils. Only a Debug build can tell; Release restores regardless, which is the safer of two wrongs. - Debug.Assert(_state.Top >= Top, - "The Lua stack is below the height recorded by the frame: values that were not pushed inside it have been popped."); - _state.SetTop(Top); - } + /// Restores the recorded top, dropping every value pushed inside the frame. Never raises; idempotent. + public void Dispose() + { + // A frame that ends below its own start has consumed values it did not own: restoring would paper over it + // with nils. Only a Debug build can tell; Release restores regardless, which is the safer of two wrongs. + Debug.Assert(_state.Top >= Top, + "The Lua stack is below the height recorded by the frame: values that were not pushed inside it have been popped."); + _state.SetTop(Top); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs index bfe9fa59..a56c0550 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; @@ -9,6 +10,7 @@ using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Protected; using CheatEngine.SDK.Lua.Runtime; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -16,177 +18,207 @@ namespace CheatEngine.SDK.Lua.State; // Pushing managed functions: with the error channel (the normal form) or without it. public readonly unsafe partial struct LuaState { - /// - /// Pushes a generated [LuaFunction] closure whose invocation is bound to one host attachment and Lua - /// state generation. Stack: +1 (the function) on success; +1 (the error value) on failure. - /// - /// The managed lua_CFunction that implements the generated export. - /// The attachment and state-generation identity captured while registering the export. - /// - /// Whether an attached runtime must still own for the closure to enter plugin - /// code. Native-fixture registrations made while the runtime is detached deliberately pass - /// to preserve their standalone contract. - /// - /// The status of installing the helpers or running the wrapper. - /// - /// This is the implementation primitive behind . It is - /// public only because generated code is compiled into a consumer assembly; ordinary code should use the - /// runtime entry point instead. The closure retains the identity in native Lua upvalues, not a mutable managed - /// field, so a function a script kept through disable, reset or re-enable cannot become current again. - /// - [EditorBrowsable(EditorBrowsableState.Advanced)] - public LuaStatus TryPushGeneratedFunction(LuaNativeFunction thunk, LuaStateIdentity identity, - bool requiresAttachedRuntime) - { - if (thunk.IsNull) throw new ArgumentException("The thunk is the null function.", nameof(thunk)); - - var top = Top; - var status = LuaHelpers.Push(Pointer, LuaHelper.Wrap); - if (!status.IsOk) return status; - - try - { - status = PushUncheckedGeneratedFunction(thunk, identity, requiresAttachedRuntime); - } - catch (InvalidOperationException) - { - // The wrapper occupies the top slot. Replace it with the documented allocation-free nil error value when - // the guarded closure's immediate stack reservation failed. - lua_settop(Pointer, -2); - lua_pushnil(Pointer); - return LuaStatus.MemoryError; - } - - // The protected bridge consumes the four upvalues when it reports a Lua failure, but the wrapper remains below - // its error value. Preserve that one error while dropping every registration intermediate; Try* callers must - // receive the original Lua status rather than a LuaException from the checked API. - if (!status.IsOk) return KeepProtectedError(top, status); - - return TryCall(1, 1); - } - - /// - /// Pushes a managed function without state, wrapped so that a failure it reports through - /// becomes a Lua error in the caller. - /// Stack: +1 (the - /// function) on success; +1 (the error value) on failure. - /// - /// The managed lua_CFunction; see for its rules. - /// The status of installing the helpers or running the wrapper. - /// is the null function. - /// - /// Allocates, inside Lua, the C closure and the wrapper closure: a registration-time cost. Assign the result to a - /// global with , or keep it with - /// . - /// - public LuaStatus TryPushFunction(LuaNativeFunction thunk) - { - if (thunk.IsNull) throw new ArgumentException("The thunk is the null function.", nameof(thunk)); - - var status = LuaHelpers.Push(Pointer, LuaHelper.Wrap); - if (!status.IsOk) return status; - - try - { - PushUncheckedFunction(thunk); - } - catch (InvalidOperationException) - { - // The wrapper occupies the top slot. Replace it with the documented allocation-free nil error value when - // the bare function's immediate stack reservation failed. - lua_settop(Pointer, -2); - lua_pushnil(Pointer); - return LuaStatus.MemoryError; - } - - return TryCall(1, 1); - } - - /// - /// Pushes a managed function as a bare C function (lua_pushcclosure with no upvalues): no error channel, - /// so what the thunk returns is exactly what the Lua caller receives. For functions that cannot fail. - /// - /// The managed lua_CFunction. - /// is the null function. - /// - /// Lua cannot reserve the one stack slot required for the light-C-function fast path. The stack is unchanged. - /// - /// - /// CE's pinned Lua 5.3 implementation stores a zero-upvalue C function as a light C function, so the push does - /// not allocate after the stack slot is reserved. This method performs that lua_checkstack(L, 1) check - /// immediately before the push; do not replace it with a generic closure call or move another Lua call between - /// the check and the push. - /// - [LuaStackEffect(1)] - public void PushUncheckedFunction(LuaNativeFunction thunk) - { - if (thunk.IsNull) throw new ArgumentException("The thunk is the null function.", nameof(thunk)); - if (lua_checkstack(Pointer, 1) == 0) - throw new InvalidOperationException( - "Lua could not reserve one stack slot for the bare C function; the stack is unchanged."); - - lua_pushcclosure(Pointer, thunk.Pointer, 0); - } - - // The generated-export dispatcher needs four upvalues: the original cdecl thunk, attach epoch, state generation - // and whether this registration was made under a real host attachment. Use the protected bridge for closure - // creation because lua_pushcclosure may allocate and must never longjmp across a managed frame. - private LuaStatus PushUncheckedGeneratedFunction(LuaNativeFunction thunk, LuaStateIdentity identity, - bool requiresAttachedRuntime) - { - if (lua_checkstack(Pointer, 4) == 0) - throw new InvalidOperationException( - "Lua could not reserve four stack slots for the guarded generated C function; the stack is unchanged."); - - lua_pushlightuserdata(Pointer, (void*)thunk.Address); - lua_pushinteger(Pointer, identity.AttachEpoch); - lua_pushinteger(Pointer, identity.StateGeneration); - lua_pushinteger(Pointer, requiresAttachedRuntime ? 1 : 0); - return new LuaStatus(LuaProtectedApi.PushClosure(Pointer, - (nint)(delegate* unmanaged[Cdecl])&DispatchGeneratedFunction, 4)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int DispatchGeneratedFunction(lua_State* pointer) - { - LuaState state = new(pointer); - try - { - var required = lua_tointegerx(pointer, lua_upvalueindex(4), null) != 0; - if (required) - { - var attachEpoch = checked((int)lua_tointegerx(pointer, lua_upvalueindex(2), null)); - var stateGeneration = checked((int)lua_tointegerx(pointer, lua_upvalueindex(3), null)); - if (!LuaRuntime.IsGeneratedFunctionRegistrationCurrent(attachEpoch, stateGeneration)) - return LuaThunk.Fail(state, "Lua function registration has expired"u8); - - if (!LuaRuntime.TryEnterCallbackOperation(out var operation)) - return LuaThunk.Fail(state, "the Lua runtime is stopping"u8); - - try - { - return InvokeGeneratedThunk(pointer, state); - } - finally - { - operation.Dispose(); - } - } - - return InvokeGeneratedThunk(pointer, state); - } - catch (Exception exception) - { - return LuaThunk.Fail(state, exception); - } - } - - private static unsafe int InvokeGeneratedThunk(lua_State* pointer, LuaState state) - { - var thunkAddress = (nint)lua_touserdata(pointer, lua_upvalueindex(1)); - if (thunkAddress == 0) return LuaThunk.Fail(state, "Lua function thunk is unavailable"u8); - - var thunk = (delegate* unmanaged[Cdecl])thunkAddress; - return thunk((nint)pointer); - } + /// + /// Pushes a generated [LuaFunction] closure whose invocation is bound to one host attachment and Lua + /// state generation. Stack: +1 (the function) on success; +1 (the error value) on failure. + /// + /// The managed lua_CFunction that implements the generated export. + /// The attachment and state-generation identity captured while registering the export. + /// + /// Whether an attached runtime must still own for the closure to enter plugin + /// code. Native-fixture registrations made while the runtime is detached deliberately pass + /// to preserve their standalone contract. + /// + /// The status of installing the helpers or running the wrapper. + /// + /// This is the implementation primitive behind . It is + /// public only because generated code is compiled into a consumer assembly; ordinary code should use the + /// runtime entry point instead. The closure retains the identity in native Lua upvalues, not a mutable managed + /// field, so a function a script kept through disable, reset or re-enable cannot become current again. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public LuaStatus TryPushGeneratedFunction(LuaNativeFunction thunk, LuaStateIdentity identity, + bool requiresAttachedRuntime) + { + if (thunk.IsNull) + { + throw new ArgumentException("The thunk is the null function.", nameof(thunk)); + } + + int top = Top; + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Wrap); + if (!status.IsOk) + { + return status; + } + + try + { + status = PushUncheckedGeneratedFunction(thunk, identity, requiresAttachedRuntime); + } + catch (InvalidOperationException) + { + // The wrapper occupies the top slot. Replace it with the documented allocation-free nil error value when + // the guarded closure's immediate stack reservation failed. + lua_settop(Pointer, -2); + lua_pushnil(Pointer); + return LuaStatus.MemoryError; + } + + // The protected bridge consumes the four upvalues when it reports a Lua failure, but the wrapper remains below + // its error value. Preserve that one error while dropping every registration intermediate; Try* callers must + // receive the original Lua status rather than a LuaException from the checked API. + if (!status.IsOk) + { + return KeepProtectedError(top, status); + } + + return TryCall(1, 1); + } + + /// + /// Pushes a managed function without state, wrapped so that a failure it reports through + /// becomes a Lua error in the caller. + /// Stack: +1 (the + /// function) on success; +1 (the error value) on failure. + /// + /// The managed lua_CFunction; see for its rules. + /// The status of installing the helpers or running the wrapper. + /// is the null function. + /// + /// Allocates, inside Lua, the C closure and the wrapper closure: a registration-time cost. Assign the result to a + /// global with , or keep it with + /// . + /// + public LuaStatus TryPushFunction(LuaNativeFunction thunk) + { + if (thunk.IsNull) + { + throw new ArgumentException("The thunk is the null function.", nameof(thunk)); + } + + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Wrap); + if (!status.IsOk) + { + return status; + } + + try + { + PushUncheckedFunction(thunk); + } + catch (InvalidOperationException) + { + // The wrapper occupies the top slot. Replace it with the documented allocation-free nil error value when + // the bare function's immediate stack reservation failed. + lua_settop(Pointer, -2); + lua_pushnil(Pointer); + return LuaStatus.MemoryError; + } + + return TryCall(1, 1); + } + + /// + /// Pushes a managed function as a bare C function (lua_pushcclosure with no upvalues): no error channel, + /// so what the thunk returns is exactly what the Lua caller receives. For functions that cannot fail. + /// + /// The managed lua_CFunction. + /// is the null function. + /// + /// Lua cannot reserve the one stack slot required for the light-C-function fast path. The stack is unchanged. + /// + /// + /// CE's pinned Lua 5.3 implementation stores a zero-upvalue C function as a light C function, so the push does + /// not allocate after the stack slot is reserved. This method performs that lua_checkstack(L, 1) check + /// immediately before the push; do not replace it with a generic closure call or move another Lua call between + /// the check and the push. + /// + [LuaStackEffect(1)] + public void PushUncheckedFunction(LuaNativeFunction thunk) + { + if (thunk.IsNull) + { + throw new ArgumentException("The thunk is the null function.", nameof(thunk)); + } + + if (lua_checkstack(Pointer, 1) == 0) + { + throw new InvalidOperationException( + "Lua could not reserve one stack slot for the bare C function; the stack is unchanged."); + } + + lua_pushcclosure(Pointer, thunk.Pointer, 0); + } + + // The generated-export dispatcher needs four upvalues: the original cdecl thunk, attach epoch, state generation + // and whether this registration was made under a real host attachment. Use the protected bridge for closure + // creation because lua_pushcclosure may allocate and must never longjmp across a managed frame. + private LuaStatus PushUncheckedGeneratedFunction(LuaNativeFunction thunk, LuaStateIdentity identity, + bool requiresAttachedRuntime) + { + if (lua_checkstack(Pointer, 4) == 0) + { + throw new InvalidOperationException( + "Lua could not reserve four stack slots for the guarded generated C function; the stack is unchanged."); + } + + lua_pushlightuserdata(Pointer, (void*) thunk.Address); + lua_pushinteger(Pointer, identity.AttachEpoch); + lua_pushinteger(Pointer, identity.StateGeneration); + lua_pushinteger(Pointer, requiresAttachedRuntime ? 1 : 0); + return new LuaStatus(LuaProtectedApi.PushClosure(Pointer, + (nint) (delegate* unmanaged[Cdecl]) &DispatchGeneratedFunction, 4)); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int DispatchGeneratedFunction(lua_State* pointer) + { + LuaState state = new(pointer); + try + { + bool required = lua_tointegerx(pointer, lua_upvalueindex(4), null) != 0; + if (required) + { + int attachEpoch = checked((int) lua_tointegerx(pointer, lua_upvalueindex(2), null)); + int stateGeneration = checked((int) lua_tointegerx(pointer, lua_upvalueindex(3), null)); + if (!LuaRuntime.IsGeneratedFunctionRegistrationCurrent(attachEpoch, stateGeneration)) + { + return LuaThunk.Fail(state, "Lua function registration has expired"u8); + } + + if (!LuaRuntime.TryEnterCallbackOperation(out LuaRuntimeOperation operation)) + { + return LuaThunk.Fail(state, "the Lua runtime is stopping"u8); + } + + try + { + return InvokeGeneratedThunk(pointer, state); + } + finally + { + operation.Dispose(); + } + } + + return InvokeGeneratedThunk(pointer, state); + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + + private static int InvokeGeneratedThunk(lua_State* pointer, LuaState state) + { + IntPtr thunkAddress = (nint) lua_touserdata(pointer, lua_upvalueindex(1)); + if (thunkAddress == 0) + { + return LuaThunk.Fail(state, "Lua function thunk is unavailable"u8); + } + + delegate* unmanaged[Cdecl] thunk = (delegate* unmanaged[Cdecl]) thunkAddress; + return thunk((nint) pointer); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Protected.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Protected.cs index 5c4c2ad6..32b37f5d 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Protected.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Protected.cs @@ -1,9 +1,11 @@ using System; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Protected; using CheatEngine.SDK.Lua.Text; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -17,396 +19,439 @@ namespace CheatEngine.SDK.Lua.State; // plus the Lua call itself. Key allocation uses the native bridge, including errors from pending finalizers. public readonly unsafe partial struct LuaState { - /// - /// Calls the function below the arguments under protection (lua_pcallk with no message handler). - /// Stack before: f, arg1 .. argN; after success: the results ( of them, or - /// all with ); after failure: one error value. - /// - /// Number of arguments above the function. - /// - /// Number of results to keep, adjusted with nils or truncated, or - /// . - /// - /// The status; when the results are on the stack. - /// - /// A managed callback reached by the call runs on this thread and this state; its exceptions come back as a - /// runtime error through the error channel, never as a managed exception. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public LuaStatus TryCall(int argumentCount, int resultCount) - { - return new LuaStatus(lua_pcallk(Pointer, argumentCount, resultCount, 0, 0, null)); - } - - /// - /// Calls the function below the arguments under protection, with a message handler (lua_pcallk): on error - /// the handler at is called with the error value and its result becomes - /// the error value on the stack, which is how a traceback is attached. - /// - /// Number of arguments above the function. - /// Number of results to keep, or . - /// A valid index of the handler function, below the called function. - /// The status. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public LuaStatus TryCall(int argumentCount, int resultCount, int messageHandlerIndex) - { - return new LuaStatus(lua_pcallk(Pointer, argumentCount, resultCount, messageHandlerIndex, 0, null)); - } - - /// - /// Compiles a text chunk without running it (luaL_loadbufferx in text mode, so a precompiled chunk is - /// rejected). Stack after success: the chunk as a function; after failure: the syntax error message. - /// - /// The Lua source, UTF-8. - /// - /// Name used in error messages, UTF-8; prefix with = to use it verbatim. Empty means - /// =?; longer than 511 bytes is truncated. - /// - /// The status: or on failure. - /// Loading never runs Lua code. The chunk's _ENV is the globals table. - [LuaStackEffect(1)] - [SkipLocalsInit] // The name buffer is written (copy + NUL) before Lua reads it; zeroing 512 bytes first would be dead stores. - public LuaStatus TryLoad(ReadOnlySpan source, ReadOnlySpan chunkName = default) - { - byte empty = 0; - fixed (byte* text = source) - fixed (byte* mode = "t"u8) - { - if (chunkName.IsEmpty) - fixed (byte* defaultName = "=?"u8) - { - return new LuaStatus(luaL_loadbufferx(Pointer, text is null ? &empty : text, (nuint)source.Length, - defaultName, mode)); - } - - // The chunk name must be NUL-terminated; a u8 literal is, an arbitrary span is not. Copy it to be sure. - Span name = stackalloc byte[Utf8Scratch.StackBufferSize]; - var length = Math.Min(chunkName.Length, name.Length - 1); - chunkName[..length].CopyTo(name); - name[length] = 0; - fixed (byte* namePointer = name) - { - return new LuaStatus(luaL_loadbufferx(Pointer, text is null ? &empty : text, (nuint)source.Length, - namePointer, mode)); - } - } - } - - /// - /// Compiles and runs a text chunk under protection: then - /// with no arguments. Stack after success: the results; after failure: one error value. - /// - /// The Lua source, UTF-8. - /// Number of results to keep, or . - /// Name used in error messages; see . - /// The status of the load or of the run. - public LuaStatus TryExecute(ReadOnlySpan source, int resultCount, ReadOnlySpan chunkName = default) - { - var status = TryLoad(source, chunkName); - return status.IsOk ? TryCall(0, resultCount) : status; - } - - /// - /// Pushes the global under protection (_ENV[name], honouring an __index - /// metamethod on the globals table). Stack after success: the value (nil when undefined). - /// - /// The global name, UTF-8; a "..."u8 literal for a fixed name. - /// The status. - public LuaStatus TryGetGlobal(ReadOnlySpan name) - { - var top = Top; - var status = LuaHelpers.Push(Pointer, LuaHelper.GetGlobal); - if (!status.IsOk) return status; - - status = TryPushString(name); - if (!status.IsOk) return KeepProtectedError(top, status); - return TryCall(1, 1); - } - - /// - /// Pops the value on top and assigns it to the global under protection (_ENV[name] = v - /// , - /// honouring a __newindex metamethod on the globals table). Stack after success: the value is gone. - /// - /// The global name, UTF-8. - /// The status. - public LuaStatus TrySetGlobal(ReadOnlySpan name) - { - var top = Top - 1; - // [.. v] -> [.. v helper] -> [.. v helper k] -> [.. helper k v] - var status = LuaHelpers.Push(Pointer, LuaHelper.SetGlobal); - if (!status.IsOk) - { - lua_rotate(Pointer, -2, 1); - lua_settop(Pointer, -2); - return status; - } - - status = TryPushString(name); - if (!status.IsOk) return KeepProtectedError(top, status); - lua_rotate(Pointer, -3, -1); - return TryCall(2, 0); - } - - /// - /// Pushes o[key] for the value at and a string key, under protection (this is how - /// a Cheat Engine object's property is read: its __index runs inside the call). Stack after success: the value. - /// - /// A valid index of the object; a relative index is taken before anything is pushed. - /// The key, UTF-8. - /// The status. - public LuaStatus TryGetField(int index, ReadOnlySpan key) - { - var top = Top; - var status = LuaHelpers.Push(Pointer, LuaHelper.Index); - if (!status.IsOk) return status; - - lua_pushvalue(Pointer, Shift(index, 1)); - status = TryPushString(key); - if (!status.IsOk) return KeepProtectedError(top, status); - return TryCall(2, 1); - } - - /// - /// Pops the value on top and does o[key] = v for the value at and a string key, - /// under protection (a Cheat Engine property write: its __newindex runs inside the call). - /// - /// - /// A valid index of the object, which must not be the top (the top is the value); a relative index is - /// taken before anything is pushed. - /// - /// The key, UTF-8. - /// The status. - public LuaStatus TrySetField(int index, ReadOnlySpan key) - { - var top = Top - 1; - // [.. v] -> [.. v h] -> [.. v h o] -> [.. v h o k] -> [.. h o k v] - var status = LuaHelpers.Push(Pointer, LuaHelper.NewIndex); - if (!status.IsOk) - { - lua_rotate(Pointer, -2, 1); - lua_settop(Pointer, -2); - return status; - } - - lua_pushvalue(Pointer, Shift(index, 1)); - status = TryPushString(key); - if (!status.IsOk) return KeepProtectedError(top, status); - lua_rotate(Pointer, -4, -1); - return TryCall(3, 0); - } - - internal LuaStatus KeepProtectedError(int top, LuaStatus status) - { - Copy(-1, top + 1); - SetTop(top + 1); - return status; - } - - /// - /// Pops the key on top and pushes o[key] for the value at , under protection, for - /// keys of any type. Stack after success: the value in place of the key. - /// - /// - /// A valid index of the object, which must not be the top (the top is the key); a relative index is - /// taken before anything is pushed. - /// - /// The status. - public LuaStatus TryGetTable(int index) - { - // [.. k] -> [.. k h] -> [.. k h o] -> [.. h o k] - var status = LuaHelpers.Push(Pointer, LuaHelper.Index); - if (!status.IsOk) - { - lua_rotate(Pointer, -2, 1); - lua_settop(Pointer, -2); - return status; - } - - lua_pushvalue(Pointer, Shift(index, 1)); - lua_rotate(Pointer, -3, -1); - return TryCall(2, 1); - } - - /// - /// Pops the value on top and the key below it and does o[key] = v for the value at , - /// under protection, for keys of any type. - /// - /// - /// A valid index of the object, below the key and the value; a relative index is taken before anything - /// is pushed. - /// - /// The status. - public LuaStatus TrySetTable(int index) - { - // [.. k v] -> [.. k v h] -> [.. k v h o] -> [.. h o k v] - var status = LuaHelpers.Push(Pointer, LuaHelper.NewIndex); - if (!status.IsOk) - { - lua_rotate(Pointer, -3, 1); - lua_settop(Pointer, -3); - return status; - } - - lua_pushvalue(Pointer, Shift(index, 1)); - lua_rotate(Pointer, -4, 2); - return TryCall(3, 0); - } - - /// - /// Pushes o[n] for the value at and an integer key, under protection. Stack - /// after success: the value. - /// - /// A valid index of the object; a relative index is taken before anything is pushed. - /// The integer key (Cheat Engine collections index from 0, Lua tables from 1). - /// The status. - public LuaStatus TryGetIndex(int index, long key) - { - var status = LuaHelpers.Push(Pointer, LuaHelper.Index); - if (!status.IsOk) return status; - - lua_pushvalue(Pointer, Shift(index, 1)); - lua_pushinteger(Pointer, key); - return TryCall(2, 1); - } - - /// - /// Pops the value on top and does o[n] = v for the value at and an integer key, - /// under protection. - /// - /// - /// A valid index of the object, which must not be the top; a relative index is taken before anything - /// is pushed. - /// - /// The integer key. - /// The status. - public LuaStatus TrySetIndex(int index, long key) - { - // [.. v] -> [.. v h] -> [.. v h o] -> [.. v h o n] -> [.. h o n v] - var status = LuaHelpers.Push(Pointer, LuaHelper.NewIndex); - if (!status.IsOk) - { - lua_rotate(Pointer, -2, 1); - lua_settop(Pointer, -2); - return status; - } - - lua_pushvalue(Pointer, Shift(index, 1)); - lua_pushinteger(Pointer, key); - lua_rotate(Pointer, -4, -1); - return TryCall(3, 0); - } - - /// - /// Pushes #o for the value at , honouring __len, under protection. Stack - /// after success: the length value (usually an integer). - /// - /// A valid index; a relative index is taken before anything is pushed. - /// The status. - /// is the metamethod-free, never-raising form. - public LuaStatus TryLength(int index) - { - var status = LuaHelpers.Push(Pointer, LuaHelper.Length); - if (!status.IsOk) return status; - - lua_pushvalue(Pointer, Shift(index, 1)); - return TryCall(1, 1); - } - - /// - /// Pushes tostring(v) for the value at , honouring __tostring and - /// __name, under protection. Stack after success: the string. - /// - /// A valid index; a relative index is taken before anything is pushed. - /// The status. - /// Uses the tostring that was global when the helpers were installed in this state. - public LuaStatus TryToString(int index) - { - var status = LuaHelpers.Push(Pointer, LuaHelper.ToString); - if (!status.IsOk) return status; - - lua_pushvalue(Pointer, Shift(index, 1)); - return TryCall(1, 1); - } - - /// - /// Compares two values with Lua semantics (__eq, __lt, __le; ordering values of different - /// types raises), under protection. Stack after success: unchanged; the result is returned through - /// . - /// - /// A valid index of the left operand; a relative index is taken before anything is pushed. - /// A valid index of the right operand; likewise. - /// The operator. - /// The comparison result; on failure. - /// The status. - /// is the metamethod-free, never-raising equality. - public LuaStatus TryCompare(int index1, int index2, LuaComparison comparison, out bool result) - { - result = false; - var status = LuaHelpers.Push(Pointer, LuaHelper.Compare); - if (!status.IsOk) return status; - - lua_pushinteger(Pointer, (int)comparison); - lua_pushvalue(Pointer, Shift(index1, 2)); - lua_pushvalue(Pointer, Shift(index2, 3)); - status = TryCall(3, 1); - if (!status.IsOk) return status; - result = lua_toboolean(Pointer, -1) != 0; - lua_settop(Pointer, -2); - - return status; - } - - /// - /// One step of a table traversal (next(t, k) under protection): pops the key on top and, when the table - /// has a next entry, pushes that entry's key and value; at the end of the traversal pushes nothing. Start with - /// nil as the key. Stack after success: +1 (key and value in place of the key) when - /// , - /// -1 (the key gone) otherwise. - /// - /// - /// A valid index of the table, which must not be the top (the top is the key); a relative index is - /// taken before anything is pushed. - /// - /// Whether a key and a value were pushed; at the end and on failure. - /// - /// The status; a runtime error when the key is not a key of the table (an entry added during the traversal, or a - /// key that never was one). - /// - /// - /// lua_next raises for an unknown key, so it is not exposed raw: the price is a protected call per step, - /// which is fine for the tables this SDK reads (bulk data never travels through tables). Uses the base library's - /// next as it was global when the helpers were installed, like uses - /// tostring: on a state without the base library the call fails with a runtime error. Never convert a key - /// in place with a string read: refuses numbers, or copy the key with - /// first. Assigning nil to existing fields during a traversal is allowed by Lua; - /// adding fields is not. - /// - public LuaStatus TryNext(int index, out bool hasNext) - { - // [.. k] -> [.. k h] -> [.. k h t] -> [.. h t k] - hasNext = false; - var status = LuaHelpers.Push(Pointer, LuaHelper.Next); - if (!status.IsOk) - { - lua_rotate(Pointer, -2, 1); - lua_settop(Pointer, -2); - return status; - } - - lua_pushvalue(Pointer, Shift(index, 1)); - lua_rotate(Pointer, -3, -1); - status = TryCall(2, 2); - if (!status.IsOk) return status; - - // next returns a single nil at the end, adjusted to (nil, nil) by the result count. - if (lua_type(Pointer, -2) == LUA_TNIL) - { - lua_settop(Pointer, -3); - return status; - } - - hasNext = true; - return status; - } + /// + /// Calls the function below the arguments under protection (lua_pcallk with no message handler). + /// Stack before: f, arg1 .. argN; after success: the results ( of them, or + /// all with ); after failure: one error value. + /// + /// Number of arguments above the function. + /// + /// Number of results to keep, adjusted with nils or truncated, or + /// . + /// + /// The status; when the results are on the stack. + /// + /// A managed callback reached by the call runs on this thread and this state; its exceptions come back as a + /// runtime error through the error channel, never as a managed exception. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LuaStatus TryCall(int argumentCount, int resultCount) + { + return new LuaStatus(lua_pcallk(Pointer, argumentCount, resultCount, 0, 0, null)); + } + + /// + /// Calls the function below the arguments under protection, with a message handler (lua_pcallk): on error + /// the handler at is called with the error value and its result becomes + /// the error value on the stack, which is how a traceback is attached. + /// + /// Number of arguments above the function. + /// Number of results to keep, or . + /// A valid index of the handler function, below the called function. + /// The status. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LuaStatus TryCall(int argumentCount, int resultCount, int messageHandlerIndex) + { + return new LuaStatus(lua_pcallk(Pointer, argumentCount, resultCount, messageHandlerIndex, 0, null)); + } + + /// + /// Compiles a text chunk without running it (luaL_loadbufferx in text mode, so a precompiled chunk is + /// rejected). Stack after success: the chunk as a function; after failure: the syntax error message. + /// + /// The Lua source, UTF-8. + /// + /// Name used in error messages, UTF-8; prefix with = to use it verbatim. Empty means + /// =?; longer than 511 bytes is truncated. + /// + /// The status: or on failure. + /// Loading never runs Lua code. The chunk's _ENV is the globals table. + [LuaStackEffect(1)] + [SkipLocalsInit] // The name buffer is written (copy + NUL) before Lua reads it; zeroing 512 bytes first would be dead stores. + public LuaStatus TryLoad(ReadOnlySpan source, ReadOnlySpan chunkName = default) + { + byte empty = 0; + fixed (byte* text = source) + fixed (byte* mode = "t"u8) + { + if (chunkName.IsEmpty) + { + fixed (byte* defaultName = "=?"u8) + { + return new LuaStatus(luaL_loadbufferx(Pointer, text is null ? &empty : text, (nuint) source.Length, + defaultName, mode)); + } + } + + // The chunk name must be NUL-terminated; a u8 literal is, an arbitrary span is not. Copy it to be sure. + Span name = stackalloc byte[Utf8Scratch.StackBufferSize]; + int length = Math.Min(chunkName.Length, name.Length - 1); + chunkName[..length].CopyTo(name); + name[length] = 0; + fixed (byte* namePointer = name) + { + return new LuaStatus(luaL_loadbufferx(Pointer, text is null ? &empty : text, (nuint) source.Length, + namePointer, mode)); + } + } + } + + /// + /// Compiles and runs a text chunk under protection: then + /// with no arguments. Stack after success: the results; after failure: one error value. + /// + /// The Lua source, UTF-8. + /// Number of results to keep, or . + /// Name used in error messages; see . + /// The status of the load or of the run. + public LuaStatus TryExecute(ReadOnlySpan source, int resultCount, ReadOnlySpan chunkName = default) + { + LuaStatus status = TryLoad(source, chunkName); + return status.IsOk ? TryCall(0, resultCount) : status; + } + + /// + /// Pushes the global under protection (_ENV[name], honouring an __index + /// metamethod on the globals table). Stack after success: the value (nil when undefined). + /// + /// The global name, UTF-8; a "..."u8 literal for a fixed name. + /// The status. + public LuaStatus TryGetGlobal(ReadOnlySpan name) + { + int top = Top; + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.GetGlobal); + if (!status.IsOk) + { + return status; + } + + status = TryPushString(name); + if (!status.IsOk) + { + return KeepProtectedError(top, status); + } + + return TryCall(1, 1); + } + + /// + /// Pops the value on top and assigns it to the global under protection (_ENV[name] = v + /// , + /// honouring a __newindex metamethod on the globals table). Stack after success: the value is gone. + /// + /// The global name, UTF-8. + /// The status. + public LuaStatus TrySetGlobal(ReadOnlySpan name) + { + int top = Top - 1; + // [.. v] -> [.. v helper] -> [.. v helper k] -> [.. helper k v] + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.SetGlobal); + if (!status.IsOk) + { + lua_rotate(Pointer, -2, 1); + lua_settop(Pointer, -2); + return status; + } + + status = TryPushString(name); + if (!status.IsOk) + { + return KeepProtectedError(top, status); + } + + lua_rotate(Pointer, -3, -1); + return TryCall(2, 0); + } + + /// + /// Pushes o[key] for the value at and a string key, under protection (this is how + /// a Cheat Engine object's property is read: its __index runs inside the call). Stack after success: the value. + /// + /// A valid index of the object; a relative index is taken before anything is pushed. + /// The key, UTF-8. + /// The status. + public LuaStatus TryGetField(int index, ReadOnlySpan key) + { + int top = Top; + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Index); + if (!status.IsOk) + { + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + status = TryPushString(key); + if (!status.IsOk) + { + return KeepProtectedError(top, status); + } + + return TryCall(2, 1); + } + + /// + /// Pops the value on top and does o[key] = v for the value at and a string key, + /// under protection (a Cheat Engine property write: its __newindex runs inside the call). + /// + /// + /// A valid index of the object, which must not be the top (the top is the value); a relative index is + /// taken before anything is pushed. + /// + /// The key, UTF-8. + /// The status. + public LuaStatus TrySetField(int index, ReadOnlySpan key) + { + int top = Top - 1; + // [.. v] -> [.. v h] -> [.. v h o] -> [.. v h o k] -> [.. h o k v] + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.NewIndex); + if (!status.IsOk) + { + lua_rotate(Pointer, -2, 1); + lua_settop(Pointer, -2); + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + status = TryPushString(key); + if (!status.IsOk) + { + return KeepProtectedError(top, status); + } + + lua_rotate(Pointer, -4, -1); + return TryCall(3, 0); + } + + internal LuaStatus KeepProtectedError(int top, LuaStatus status) + { + Copy(-1, top + 1); + SetTop(top + 1); + return status; + } + + /// + /// Pops the key on top and pushes o[key] for the value at , under protection, for + /// keys of any type. Stack after success: the value in place of the key. + /// + /// + /// A valid index of the object, which must not be the top (the top is the key); a relative index is + /// taken before anything is pushed. + /// + /// The status. + public LuaStatus TryGetTable(int index) + { + // [.. k] -> [.. k h] -> [.. k h o] -> [.. h o k] + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Index); + if (!status.IsOk) + { + lua_rotate(Pointer, -2, 1); + lua_settop(Pointer, -2); + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + lua_rotate(Pointer, -3, -1); + return TryCall(2, 1); + } + + /// + /// Pops the value on top and the key below it and does o[key] = v for the value at , + /// under protection, for keys of any type. + /// + /// + /// A valid index of the object, below the key and the value; a relative index is taken before anything + /// is pushed. + /// + /// The status. + public LuaStatus TrySetTable(int index) + { + // [.. k v] -> [.. k v h] -> [.. k v h o] -> [.. h o k v] + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.NewIndex); + if (!status.IsOk) + { + lua_rotate(Pointer, -3, 1); + lua_settop(Pointer, -3); + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + lua_rotate(Pointer, -4, 2); + return TryCall(3, 0); + } + + /// + /// Pushes o[n] for the value at and an integer key, under protection. Stack + /// after success: the value. + /// + /// A valid index of the object; a relative index is taken before anything is pushed. + /// The integer key (Cheat Engine collections index from 0, Lua tables from 1). + /// The status. + public LuaStatus TryGetIndex(int index, long key) + { + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Index); + if (!status.IsOk) + { + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + lua_pushinteger(Pointer, key); + return TryCall(2, 1); + } + + /// + /// Pops the value on top and does o[n] = v for the value at and an integer key, + /// under protection. + /// + /// + /// A valid index of the object, which must not be the top; a relative index is taken before anything + /// is pushed. + /// + /// The integer key. + /// The status. + public LuaStatus TrySetIndex(int index, long key) + { + // [.. v] -> [.. v h] -> [.. v h o] -> [.. v h o n] -> [.. h o n v] + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.NewIndex); + if (!status.IsOk) + { + lua_rotate(Pointer, -2, 1); + lua_settop(Pointer, -2); + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + lua_pushinteger(Pointer, key); + lua_rotate(Pointer, -4, -1); + return TryCall(3, 0); + } + + /// + /// Pushes #o for the value at , honouring __len, under protection. Stack + /// after success: the length value (usually an integer). + /// + /// A valid index; a relative index is taken before anything is pushed. + /// The status. + /// is the metamethod-free, never-raising form. + public LuaStatus TryLength(int index) + { + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Length); + if (!status.IsOk) + { + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + return TryCall(1, 1); + } + + /// + /// Pushes tostring(v) for the value at , honouring __tostring and + /// __name, under protection. Stack after success: the string. + /// + /// A valid index; a relative index is taken before anything is pushed. + /// The status. + /// Uses the tostring that was global when the helpers were installed in this state. + public LuaStatus TryToString(int index) + { + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.ToString); + if (!status.IsOk) + { + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + return TryCall(1, 1); + } + + /// + /// Compares two values with Lua semantics (__eq, __lt, __le; ordering values of different + /// types raises), under protection. Stack after success: unchanged; the result is returned through + /// . + /// + /// A valid index of the left operand; a relative index is taken before anything is pushed. + /// A valid index of the right operand; likewise. + /// The operator. + /// The comparison result; on failure. + /// The status. + /// is the metamethod-free, never-raising equality. + public LuaStatus TryCompare(int index1, int index2, LuaComparison comparison, out bool result) + { + result = false; + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Compare); + if (!status.IsOk) + { + return status; + } + + lua_pushinteger(Pointer, (int) comparison); + lua_pushvalue(Pointer, Shift(index1, 2)); + lua_pushvalue(Pointer, Shift(index2, 3)); + status = TryCall(3, 1); + if (!status.IsOk) + { + return status; + } + + result = lua_toboolean(Pointer, -1) != 0; + lua_settop(Pointer, -2); + + return status; + } + + /// + /// One step of a table traversal (next(t, k) under protection): pops the key on top and, when the table + /// has a next entry, pushes that entry's key and value; at the end of the traversal pushes nothing. Start with + /// nil as the key. Stack after success: +1 (key and value in place of the key) when + /// , + /// -1 (the key gone) otherwise. + /// + /// + /// A valid index of the table, which must not be the top (the top is the key); a relative index is + /// taken before anything is pushed. + /// + /// Whether a key and a value were pushed; at the end and on failure. + /// + /// The status; a runtime error when the key is not a key of the table (an entry added during the traversal, or a + /// key that never was one). + /// + /// + /// lua_next raises for an unknown key, so it is not exposed raw: the price is a protected call per step, + /// which is fine for the tables this SDK reads (bulk data never travels through tables). Uses the base library's + /// next as it was global when the helpers were installed, like uses + /// tostring: on a state without the base library the call fails with a runtime error. Never convert a key + /// in place with a string read: refuses numbers, or copy the key with + /// first. Assigning nil to existing fields during a traversal is allowed by Lua; + /// adding fields is not. + /// + public LuaStatus TryNext(int index, out bool hasNext) + { + // [.. k] -> [.. k h] -> [.. k h t] -> [.. h t k] + hasNext = false; + LuaStatus status = LuaHelpers.Push(Pointer, LuaHelper.Next); + if (!status.IsOk) + { + lua_rotate(Pointer, -2, 1); + lua_settop(Pointer, -2); + return status; + } + + lua_pushvalue(Pointer, Shift(index, 1)); + lua_rotate(Pointer, -3, -1); + status = TryCall(2, 2); + if (!status.IsOk) + { + return status; + } + + // next returns a single nil at the end, adjusted to (nil, nil) by the result count. + if (lua_type(Pointer, -2) == LUA_TNIL) + { + lua_settop(Pointer, -3); + return status; + } + + hasNext = true; + return status; + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Push.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Push.cs index 599bb329..2b085609 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Push.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Push.cs @@ -1,9 +1,11 @@ using System; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Protected; using CheatEngine.SDK.Lua.Text; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -11,119 +13,123 @@ namespace CheatEngine.SDK.Lua.State; // Scalar pushes do not run Lua code. Allocating string pushes use the native protection boundary. public readonly unsafe partial struct LuaState { - /// Pushes nil. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushNil() - { - lua_pushnil(Pointer); - } + /// Pushes nil. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushNil() + { + lua_pushnil(Pointer); + } + + /// Pushes an integer (lua_pushinteger). Lua integers are 64-bit. + /// The value. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushInteger(long value) + { + lua_pushinteger(Pointer, value); + } - /// Pushes an integer (lua_pushinteger). Lua integers are 64-bit. - /// The value. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushInteger(long value) - { - lua_pushinteger(Pointer, value); - } + /// Pushes a float (lua_pushnumber). Lua floats are doubles. + /// The value. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushNumber(double value) + { + lua_pushnumber(Pointer, value); + } - /// Pushes a float (lua_pushnumber). Lua floats are doubles. - /// The value. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushNumber(double value) - { - lua_pushnumber(Pointer, value); - } + /// Pushes a boolean (lua_pushboolean). + /// The value. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushBoolean(bool value) + { + lua_pushboolean(Pointer, value ? 1 : 0); + } - /// Pushes a boolean (lua_pushboolean). - /// The value. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushBoolean(bool value) - { - lua_pushboolean(Pointer, value ? 1 : 0); - } + /// + /// Pushes a light userdata: a bare pointer value with no identity or lifetime of its own ( + /// lua_pushlightuserdata). + /// + /// The pointer value; zero is a valid (null) light userdata. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushLightUserdata(nint address) + { + lua_pushlightuserdata(Pointer, (void*) address); + } - /// - /// Pushes a light userdata: a bare pointer value with no identity or lifetime of its own ( - /// lua_pushlightuserdata). - /// - /// The pointer value; zero is a valid (null) light userdata. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushLightUserdata(nint address) - { - lua_pushlightuserdata(Pointer, (void*)address); - } + /// Pushes the globals table (lua_pushglobaltable), for raw access to globals that bypasses metamethods. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushGlobalTable() + { + _ = lua_rawgeti(Pointer, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS); + } - /// Pushes the globals table (lua_pushglobaltable), for raw access to globals that bypasses metamethods. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushGlobalTable() - { - _ = lua_rawgeti(Pointer, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS); - } + /// + /// Pushes a string from its bytes (lua_pushlstring). Lua copies the bytes; embedded NULs are kept. The + /// SDK's convention is UTF-8, which is what a "..."u8 literal is. + /// + /// The bytes; may be empty. + /// + /// Allocates inside Lua under native protection. A failed allocation or finalizer becomes a managed + /// ; the original stack depth is preserved on failure. + /// + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushString(ReadOnlySpan utf8) + { + CheckProtectedResult(TryPushString(utf8)); + } - /// - /// Pushes a string from its bytes (lua_pushlstring). Lua copies the bytes; embedded NULs are kept. The - /// SDK's convention is UTF-8, which is what a "..."u8 literal is. - /// - /// The bytes; may be empty. - /// - /// Allocates inside Lua under native protection. A failed allocation or finalizer becomes a managed - /// ; the original stack depth is preserved on failure. - /// - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushString(ReadOnlySpan utf8) - { - CheckProtectedResult(TryPushString(utf8)); - } + /// Pushes bytes as a Lua string under native protection; on failure pushes one error value instead. + /// The bytes, including any embedded NULs. + /// The Lua status of the allocation and any finalizer it ran. + [LuaStackEffect(1)] + public LuaStatus TryPushString(ReadOnlySpan utf8) + { + return new LuaStatus(LuaProtectedApi.PushBytes(Pointer, utf8)); + } - /// Pushes bytes as a Lua string under native protection; on failure pushes one error value instead. - /// The bytes, including any embedded NULs. - /// The Lua status of the allocation and any finalizer it ran. - [LuaStackEffect(1)] - public LuaStatus TryPushString(ReadOnlySpan utf8) - { - return new LuaStatus(LuaProtectedApi.PushBytes(Pointer, utf8)); - } + // Throw only after native protection has returned; consuming the error preserves the operation's input contract. + internal void CheckProtectedResult(LuaStatus status) + { + if (status.IsOk) + { + return; + } - // Throw only after native protection has returned; consuming the error preserves the operation's input contract. - internal void CheckProtectedResult(LuaStatus status) - { - if (status.IsOk) return; - LuaError error; - try - { - error = LuaError.FromStack(this, status); - } - finally - { - Pop(1); - } + LuaError error; + try + { + error = LuaError.FromStack(this, status); + } + finally + { + Pop(1); + } - throw new LuaException(error); - } + throw new LuaException(error); + } - /// - /// Pushes a string from UTF-16 text (a converts implicitly), transcoded to UTF-8 through a - /// stack buffer, or a pooled array above bytes. Lone surrogates become - /// U+FFFD. - /// - /// The text; may be empty. - /// - /// The convenience form: hot paths push "..."u8 literals or cached UTF-8 through - /// . Allocates inside Lua; see there. - /// - [LuaStackEffect(1)] - [SkipLocalsInit] // Encode writes the bytes it reports before anything reads them; zeroing the 512-byte buffer first would be dead stores. - public void PushString(ReadOnlySpan text) - { - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode(text, scratch); - PushString(utf8.Bytes); - } + /// + /// Pushes a string from UTF-16 text (a converts implicitly), transcoded to UTF-8 through a + /// stack buffer, or a pooled array above bytes. Lone surrogates become + /// U+FFFD. + /// + /// The text; may be empty. + /// + /// The convenience form: hot paths push "..."u8 literals or cached UTF-8 through + /// . Allocates inside Lua; see there. + /// + [LuaStackEffect(1)] + [SkipLocalsInit] // Encode writes the bytes it reports before anything reads them; zeroing the 512-byte buffer first would be dead stores. + public void PushString(ReadOnlySpan text) + { + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(text, scratch); + PushString(utf8.Bytes); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Read.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Read.cs index e8d3d37e..7ceaa288 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Read.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Read.cs @@ -2,7 +2,9 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; + using CheatEngine.SDK.Annotations.Lua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -10,166 +12,166 @@ namespace CheatEngine.SDK.Lua.State; // Reads: one or two C API calls each, none can raise, none modifies the stack. Every member takes an acceptable index. public readonly unsafe partial struct LuaState { - /// - /// Reads the value at as a 64-bit integer (lua_tointegerx), with Lua's own - /// conversion rules: an integer, a float with an exact integral value, or a string Lua can convert. - /// - /// An acceptable index. - /// The integer, or 0 when the value is not convertible. - /// when holds the converted value. - /// - /// Use first when a float such as 3.0 must be rejected. The stack slot is not - /// modified. - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryReadInteger(int index, out long value) - { - int isNumber; - value = lua_tointegerx(Pointer, index, &isNumber); - return isNumber != 0; - } + /// + /// Reads the value at as a 64-bit integer (lua_tointegerx), with Lua's own + /// conversion rules: an integer, a float with an exact integral value, or a string Lua can convert. + /// + /// An acceptable index. + /// The integer, or 0 when the value is not convertible. + /// when holds the converted value. + /// + /// Use first when a float such as 3.0 must be rejected. The stack slot is not + /// modified. + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryReadInteger(int index, out long value) + { + int isNumber; + value = lua_tointegerx(Pointer, index, &isNumber); + return isNumber != 0; + } - /// - /// Reads the value at as a double (lua_tonumberx): any number, or a string Lua - /// can convert. - /// - /// An acceptable index. - /// The number, or 0 when the value is not convertible. - /// when holds the converted value. - /// The stack slot is not modified. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryReadNumber(int index, out double value) - { - int isNumber; - value = lua_tonumberx(Pointer, index, &isNumber); - return isNumber != 0; - } + /// + /// Reads the value at as a double (lua_tonumberx): any number, or a string Lua + /// can convert. + /// + /// An acceptable index. + /// The number, or 0 when the value is not convertible. + /// when holds the converted value. + /// The stack slot is not modified. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryReadNumber(int index, out double value) + { + int isNumber; + value = lua_tonumberx(Pointer, index, &isNumber); + return isNumber != 0; + } - /// - /// Lua truthiness of the value at (lua_toboolean): everything except nil, - /// and an absent value is . - /// - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool ToBoolean(int index) - { - return lua_toboolean(Pointer, index) != 0; - } + /// + /// Lua truthiness of the value at (lua_toboolean): everything except nil, + /// and an absent value is . + /// + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ToBoolean(int index) + { + return lua_toboolean(Pointer, index) != 0; + } - /// - /// Reads the bytes of the string at without copying (lua_tolstring). Only a - /// real string qualifies: a number is not converted, because the conversion would rewrite the stack slot. - /// - /// An acceptable index. - /// The bytes, embedded NULs included; empty when the value is not a string. - /// when the value is a string. - /// - /// Lifetime: the span points into Lua's memory and is valid only while the string stays on the stack (or is - /// otherwise reachable). Copy what must outlive the current frame. Never raises. - /// - [LuaStackEffect(0)] - public bool TryReadUtf8(int index, out ReadOnlySpan utf8) - { - if (lua_type(Pointer, index) != LUA_TSTRING) - { - utf8 = default; - return false; - } + /// + /// Reads the bytes of the string at without copying (lua_tolstring). Only a + /// real string qualifies: a number is not converted, because the conversion would rewrite the stack slot. + /// + /// An acceptable index. + /// The bytes, embedded NULs included; empty when the value is not a string. + /// when the value is a string. + /// + /// Lifetime: the span points into Lua's memory and is valid only while the string stays on the stack (or is + /// otherwise reachable). Copy what must outlive the current frame. Never raises. + /// + [LuaStackEffect(0)] + public bool TryReadUtf8(int index, out ReadOnlySpan utf8) + { + if (lua_type(Pointer, index) != LUA_TSTRING) + { + utf8 = default; + return false; + } - nuint length; - var bytes = lua_tolstring(Pointer, index, &length); - utf8 = new ReadOnlySpan(bytes, checked((int)length)); - return true; - } + nuint length; + byte* bytes = lua_tolstring(Pointer, index, &length); + utf8 = new ReadOnlySpan(bytes, checked((int) length)); + return true; + } - /// - /// Copies the bytes of the string at into : the form that - /// lets a caller pop the value and keep the text, without allocating. The copy-out shape for a string result of - /// a generated wrapper (Span<byte> destination, out int written, copied before the stack is restored). - /// - /// An acceptable index. - /// The buffer to copy into. - /// The number of bytes copied; 0 on failure. - /// - /// when the value is not a string, or the string does not fit - /// (nothing is copied then; gives the size needed). - /// - /// Strict like : a number is not converted. Two C API calls. Never raises. - [LuaStackEffect(0)] - public bool TryCopyUtf8(int index, Span destination, out int written) - { - if (!TryReadUtf8(index, out var utf8) || !utf8.TryCopyTo(destination)) - { - written = 0; - return false; - } + /// + /// Copies the bytes of the string at into : the form that + /// lets a caller pop the value and keep the text, without allocating. The copy-out shape for a string result of + /// a generated wrapper (Span<byte> destination, out int written, copied before the stack is restored). + /// + /// An acceptable index. + /// The buffer to copy into. + /// The number of bytes copied; 0 on failure. + /// + /// when the value is not a string, or the string does not fit + /// (nothing is copied then; gives the size needed). + /// + /// Strict like : a number is not converted. Two C API calls. Never raises. + [LuaStackEffect(0)] + public bool TryCopyUtf8(int index, Span destination, out int written) + { + if (!TryReadUtf8(index, out ReadOnlySpan utf8) || !utf8.TryCopyTo(destination)) + { + written = 0; + return false; + } - written = utf8.Length; - return true; - } + written = utf8.Length; + return true; + } - /// - /// Reads the string at as a managed , decoding UTF-8 (invalid - /// sequences become U+FFFD). Allocates the string: a convenience for cold paths, is the - /// primitive. - /// - /// An acceptable index. - /// The decoded text; when the value is not a string. - /// when the value is a string. - [LuaStackEffect(0)] - public bool TryReadString(int index, [NotNullWhen(true)] out string? value) - { - if (!TryReadUtf8(index, out var utf8)) - { - value = null; - return false; - } + /// + /// Reads the string at as a managed , decoding UTF-8 (invalid + /// sequences become U+FFFD). Allocates the string: a convenience for cold paths, is the + /// primitive. + /// + /// An acceptable index. + /// The decoded text; when the value is not a string. + /// when the value is a string. + [LuaStackEffect(0)] + public bool TryReadString(int index, [NotNullWhen(true)] out string? value) + { + if (!TryReadUtf8(index, out ReadOnlySpan utf8)) + { + value = null; + return false; + } - value = Encoding.UTF8.GetString(utf8); - return true; - } + value = Encoding.UTF8.GetString(utf8); + return true; + } - /// - /// The pointer behind the value at (lua_touserdata): the block of a full userdata, - /// the value of a light userdata, zero for anything else. - /// - /// An acceptable index. - /// - /// A full userdata block belongs to Lua and lives as long as the userdata is reachable: read through it before - /// popping the value. Never raises. - /// - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public nint ToUserdata(int index) - { - return (nint)lua_touserdata(Pointer, index); - } + /// + /// The pointer behind the value at (lua_touserdata): the block of a full userdata, + /// the value of a light userdata, zero for anything else. + /// + /// An acceptable index. + /// + /// A full userdata block belongs to Lua and lives as long as the userdata is reachable: read through it before + /// popping the value. Never raises. + /// + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public nint ToUserdata(int index) + { + return (nint) lua_touserdata(Pointer, index); + } - /// - /// A generic identity for the value at (lua_topointer): the address of a table, - /// function, thread or userdata, usable for equality and diagnostics only; zero for other types. - /// - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public nint ToPointer(int index) - { - return (nint)lua_topointer(Pointer, index); - } + /// + /// A generic identity for the value at (lua_topointer): the address of a table, + /// function, thread or userdata, usable for equality and diagnostics only; zero for other types. + /// + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public nint ToPointer(int index) + { + return (nint) lua_topointer(Pointer, index); + } - /// - /// The raw length of the value at (lua_rawlen): bytes of a string, border of a - /// table without __len, block size of a full userdata, 0 otherwise. - /// - /// An acceptable index. - /// Never raises. is the metamethod-aware form. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public nuint RawLength(int index) - { - return lua_rawlen(Pointer, index); - } + /// + /// The raw length of the value at (lua_rawlen): bytes of a string, border of a + /// table without __len, block size of a full userdata, 0 otherwise. + /// + /// An acceptable index. + /// Never raises. is the metamethod-aware form. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public nuint RawLength(int index) + { + return lua_rawlen(Pointer, index); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.References.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.References.cs index 42e6359e..6ab7b385 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.References.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.References.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; @@ -9,49 +10,53 @@ namespace CheatEngine.SDK.Lua.State; // References use a private registry table. The host and other plugins own separate free lists. public readonly partial struct LuaState { - /// - /// Pops the value on top and stores it in this SDK's private registry table, returning a reference stamped with the - /// current - /// (luaL_ref). - /// - /// A new, resolved reference; the caller owns it and releases it with . - /// - /// Allocates the object and, inside Lua, possibly a registry slot. Meant for values that - /// are pushed many times: never call it per operation. - /// - [LuaStackEffect(-1)] - public LuaRef CreateRef() - { - using var operation = LuaRuntime.EnterStateOperation(this); - var identity = LuaRuntime.CurrentStateIdentity; - CheckProtectedResult(LuaReferences.Create(this, out var reference)); - return new LuaRef(reference, identity); - } + /// + /// Pops the value on top and stores it in this SDK's private registry table, returning a reference stamped with the + /// current + /// (luaL_ref). + /// + /// A new, resolved reference; the caller owns it and releases it with . + /// + /// Allocates the object and, inside Lua, possibly a registry slot. Meant for values that + /// are pushed many times: never call it per operation. + /// + [LuaStackEffect(-1)] + public LuaRef CreateRef() + { + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(this); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + CheckProtectedResult(LuaReferences.Create(this, out int reference)); + return new LuaRef(reference, identity); + } + + internal LuaStatus TryCreateRef(out LuaRef? reference) + { + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(this); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + LuaStatus status = LuaReferences.Create(this, out int slot); + reference = status.IsOk ? new LuaRef(slot, identity) : null; + return status; + } - internal LuaStatus TryCreateRef(out LuaRef? reference) - { - using var operation = LuaRuntime.EnterStateOperation(this); - var identity = LuaRuntime.CurrentStateIdentity; - var status = LuaReferences.Create(this, out var slot); - reference = status.IsOk ? new LuaRef(slot, identity) : null; - return status; - } + /// + /// Pushes the value a reference designates (lua_rawgeti on the registry) when the reference is current. + /// Stack: +1 on , +0 on . + /// + /// The reference; may be unresolved or stale, in which case nothing is pushed. + /// when the value was pushed. + /// Lookup and release share a gate, so a slot cannot be reused between validation and pushing its value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryPushRef(LuaRef reference) + { + using LuaRuntimeOperation operation = LuaRuntime.EnterStateOperation(this); + lock (LuaReferences.Gate) + { + if (reference is null || !reference.TryGetCurrent(out int slot)) + { + return false; + } - /// - /// Pushes the value a reference designates (lua_rawgeti on the registry) when the reference is current. - /// Stack: +1 on , +0 on . - /// - /// The reference; may be unresolved or stale, in which case nothing is pushed. - /// when the value was pushed. - /// Lookup and release share a gate, so a slot cannot be reused between validation and pushing its value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryPushRef(LuaRef reference) - { - using var operation = LuaRuntime.EnterStateOperation(this); - lock (LuaReferences.Gate) - { - if (reference is null || !reference.TryGetCurrent(out var slot)) return false; - return LuaReferences.Push(this, slot); - } - } + return LuaReferences.Push(this, slot); + } + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Stack.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Stack.cs index 962edded..6aa38a37 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Stack.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Stack.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -8,120 +10,120 @@ namespace CheatEngine.SDK.Lua.State; // Stack manipulation: one C API call each, none of them can raise or run Lua code. public readonly unsafe partial struct LuaState { - /// Gets the index of the top element, which is also the number of elements on the stack (lua_gettop). - public int Top - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => lua_gettop(Pointer); - } + /// Gets the index of the top element, which is also the number of elements on the stack (lua_gettop). + public int Top + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => lua_gettop(Pointer); + } - /// - /// Sets the stack height (lua_settop): drops everything above , or fills with - /// nil when it grows. This is the restore half of the stack-balance invariant. - /// - /// The new top: a value previously read from , or a relative index. - /// Never raises. Growing beyond the space guaranteed by is undefined behaviour. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SetTop(int index) - { - lua_settop(Pointer, index); - } + /// + /// Sets the stack height (lua_settop): drops everything above , or fills with + /// nil when it grows. This is the restore half of the stack-balance invariant. + /// + /// The new top: a value previously read from , or a relative index. + /// Never raises. Growing beyond the space guaranteed by is undefined behaviour. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetTop(int index) + { + lua_settop(Pointer, index); + } - /// Drops the top elements (lua_pop). - /// Number of elements, at most . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Pop(int count) - { - Debug.Assert(count >= 0, "A negative count would grow the stack."); - lua_settop(Pointer, -count - 1); - } + /// Drops the top elements (lua_pop). + /// Number of elements, at most . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Pop(int count) + { + Debug.Assert(count >= 0, "A negative count would grow the stack."); + lua_settop(Pointer, -count - 1); + } - /// Pushes a copy of the value at (lua_pushvalue). - /// A valid index. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushValue(int index) - { - lua_pushvalue(Pointer, index); - } + /// Pushes a copy of the value at (lua_pushvalue). + /// A valid index. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushValue(int index) + { + lua_pushvalue(Pointer, index); + } - /// - /// Converts a relative index into an absolute one that stays correct while values are pushed (lua_absindex - /// ). - /// - /// An acceptable index. - /// The equivalent positive index; pseudo-indices are returned unchanged. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public int AbsoluteIndex(int index) - { - return lua_absindex(Pointer, index); - } + /// + /// Converts a relative index into an absolute one that stays correct while values are pushed (lua_absindex + /// ). + /// + /// An acceptable index. + /// The equivalent positive index; pseudo-indices are returned unchanged. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int AbsoluteIndex(int index) + { + return lua_absindex(Pointer, index); + } - /// - /// Makes sure more values can be pushed (lua_checkstack). - /// - /// Number of additional slots needed. - /// when the stack cannot grow that far; nothing changed. - /// - /// A C function starts with free slots. Every protected member of this type needs - /// at most four of them, except the first protected operation on a given Lua state, which installs the helper - /// chunk and checks for eleven itself (it grows the stack if it must). Call this before a body pushes more than - /// about sixteen values. Never raises. - /// - [LuaStackEffect(0)] - public bool TryEnsureStack(int extraSlots) - { - return lua_checkstack(Pointer, extraSlots) != 0; - } + /// + /// Makes sure more values can be pushed (lua_checkstack). + /// + /// Number of additional slots needed. + /// when the stack cannot grow that far; nothing changed. + /// + /// A C function starts with free slots. Every protected member of this type needs + /// at most four of them, except the first protected operation on a given Lua state, which installs the helper + /// chunk and checks for eleven itself (it grows the stack if it must). Call this before a body pushes more than + /// about sixteen values. Never raises. + /// + [LuaStackEffect(0)] + public bool TryEnsureStack(int extraSlots) + { + return lua_checkstack(Pointer, extraSlots) != 0; + } - /// Moves the top element into , shifting the elements above it up (lua_insert). - /// A valid index that is not a pseudo-index. - [LuaStackEffect(0)] - public void Insert(int index) - { - lua_rotate(Pointer, index, 1); - } + /// Moves the top element into , shifting the elements above it up (lua_insert). + /// A valid index that is not a pseudo-index. + [LuaStackEffect(0)] + public void Insert(int index) + { + lua_rotate(Pointer, index, 1); + } - /// Removes the element at , shifting the elements above it down (lua_remove). - /// A valid index that is not a pseudo-index. - [LuaStackEffect(-1)] - public void Remove(int index) - { - lua_rotate(Pointer, index, -1); - lua_settop(Pointer, -2); - } + /// Removes the element at , shifting the elements above it down (lua_remove). + /// A valid index that is not a pseudo-index. + [LuaStackEffect(-1)] + public void Remove(int index) + { + lua_rotate(Pointer, index, -1); + lua_settop(Pointer, -2); + } - /// Pops the top element into , overwriting what was there (lua_replace). - /// A valid index. - [LuaStackEffect(-1)] - public void Replace(int index) - { - lua_copy(Pointer, -1, index); - lua_settop(Pointer, -2); - } + /// Pops the top element into , overwriting what was there (lua_replace). + /// A valid index. + [LuaStackEffect(-1)] + public void Replace(int index) + { + lua_copy(Pointer, -1, index); + lua_settop(Pointer, -2); + } - /// - /// Copies the value at into the slot without moving - /// anything (lua_copy). - /// - /// A valid index. - /// A valid index. - [LuaStackEffect(0)] - public void Copy(int fromIndex, int toIndex) - { - lua_copy(Pointer, fromIndex, toIndex); - } + /// + /// Copies the value at into the slot without moving + /// anything (lua_copy). + /// + /// A valid index. + /// A valid index. + [LuaStackEffect(0)] + public void Copy(int fromIndex, int toIndex) + { + lua_copy(Pointer, fromIndex, toIndex); + } - /// - /// Rotates the elements between and the top by positions - /// towards the top, or towards the bottom when negative (lua_rotate). - /// - /// A valid index that is not a pseudo-index. - /// Positions; its magnitude must not exceed the size of the rotated slice. - [LuaStackEffect(0)] - public void Rotate(int index, int count) - { - lua_rotate(Pointer, index, count); - } + /// + /// Rotates the elements between and the top by positions + /// towards the top, or towards the bottom when negative (lua_rotate). + /// + /// A valid index that is not a pseudo-index. + /// Positions; its magnitude must not exceed the size of the rotated slice. + [LuaStackEffect(0)] + public void Rotate(int index, int count) + { + lua_rotate(Pointer, index, count); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs index 04be3b75..8728ead4 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs @@ -1,8 +1,10 @@ using System; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Protected; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -10,151 +12,151 @@ namespace CheatEngine.SDK.Lua.State; // Raw table access bypasses metamethods. Allocations can run finalizers and use the native protection boundary. public readonly unsafe partial struct LuaState { - /// Pushes a new empty table (lua_createtable), pre-sized when the hints are known. - /// Expected number of sequence elements. - /// Expected number of other keys. - /// Allocates inside Lua. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void CreateTable(int arraySlots = 0, int recordSlots = 0) - { - CheckProtectedResult(new LuaStatus(LuaProtectedApi.CreateTable(Pointer, arraySlots, recordSlots))); - } - - /// Pushes a one-based Lua sequence table containing the supplied bytes as Lua integers. - /// The byte values copied into the new Lua table. - /// Allocates and fills the table inside one native protected operation. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void PushByteTable(ReadOnlySpan bytes) - { - CheckProtectedResult(new LuaStatus(LuaProtectedApi.PushByteTable(Pointer, bytes))); - } - - /// Pops a key and pushes t[key] without metamethods (lua_rawget). - /// A valid index of a table. - /// The type of the pushed value. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public LuaType RawGet(int tableIndex) - { - return (LuaType)lua_rawget(Pointer, tableIndex); - } - - /// Pushes t[n] without metamethods (lua_rawgeti). - /// A valid index of a table, included. - /// The integer key. - /// The type of the pushed value. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public LuaType RawGetIndex(int tableIndex, long key) - { - return (LuaType)lua_rawgeti(Pointer, tableIndex, key); - } - - /// - /// Pushes t[p] for a light-userdata key without metamethods (lua_rawgetp); the idiom for registry - /// entries private to one library. - /// - /// A valid index of a table, included. - /// The pointer used as key. - /// The type of the pushed value. - [LuaStackEffect(1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public LuaType RawGetPointer(int tableIndex, nint key) - { - return (LuaType)lua_rawgetp(Pointer, tableIndex, (void*)key); - } - - /// - /// Pops a value and a key and does t[key] = value without metamethods (lua_rawset), unless the key - /// is nil or NaN, which Lua refuses by raising: those keys are refused here instead, before the C call. - /// - /// A valid index of a table. - /// - /// when the key was nil or NaN; the table is unchanged. The key and the value are - /// popped in every case. - /// - /// - /// Allocates inside Lua when the table grows. One lua_type call more than the raw C function (two more for - /// a float key). - /// - [LuaStackEffect(-2)] - public bool TryRawSet(int tableIndex) - { - var keyType = lua_type(Pointer, -2); - if (keyType == LUA_TNIL || (keyType == LUA_TNUMBER && lua_isinteger(Pointer, -2) == 0 && - double.IsNaN(lua_tonumberx(Pointer, -2, null)))) - { - lua_settop(Pointer, -3); - return false; - } - - var status = new LuaStatus(LuaProtectedApi.RawSet(Pointer, tableIndex)); - CheckProtectedResult(status); - return true; - } - - /// Pops a value and does t[n] = value without metamethods (lua_rawseti). - /// A valid index of a table, included. - /// The integer key. - /// Allocates inside Lua when the table grows. - [LuaStackEffect(-1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void RawSetIndex(int tableIndex, long key) - { - CheckProtectedResult(new LuaStatus(LuaProtectedApi.RawSetI(Pointer, tableIndex, key))); - } - - /// Pops a value and does t[p] = value for a light-userdata key without metamethods (lua_rawsetp). - /// A valid index of a table, included. - /// The pointer used as key. - /// Allocates inside Lua when the table grows. - [LuaStackEffect(-1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void RawSetPointer(int tableIndex, nint key) - { - CheckProtectedResult(new LuaStatus(LuaProtectedApi.RawSetP(Pointer, tableIndex, key))); - } - - /// Whether two values are primitively equal, without __eq (lua_rawequal). - /// An acceptable index. - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool RawEquals(int index1, int index2) - { - return lua_rawequal(Pointer, index1, index2) != 0; - } - - /// Pushes the metatable of the value at when it has one (lua_getmetatable). - /// A valid index. - /// and the metatable pushed; and nothing pushed. - public bool TryGetMetatable(int index) - { - return lua_getmetatable(Pointer, index) != 0; - } - - /// - /// Pops a table (or nil) and makes it the metatable of the value at ( - /// lua_setmetatable). - /// - /// A valid index. - [LuaStackEffect(-1)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SetMetatable(int index) - { - _ = lua_setmetatable(Pointer, index); - } - - /// Pushes a new full userdata of bytes and returns its block (lua_newuserdata). - /// Block size in bytes. - /// The address of the block: not zeroed, never moves, owned by Lua and freed by its collector. - /// Allocates inside Lua. - [LuaStackEffect(1)] - public nint NewUserdata(nuint size) - { - CheckProtectedResult(new LuaStatus(LuaProtectedApi.NewUserdata(Pointer, size))); - return (nint)lua_touserdata(Pointer, -1); - } + /// Pushes a new empty table (lua_createtable), pre-sized when the hints are known. + /// Expected number of sequence elements. + /// Expected number of other keys. + /// Allocates inside Lua. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void CreateTable(int arraySlots = 0, int recordSlots = 0) + { + CheckProtectedResult(new LuaStatus(LuaProtectedApi.CreateTable(Pointer, arraySlots, recordSlots))); + } + + /// Pushes a one-based Lua sequence table containing the supplied bytes as Lua integers. + /// The byte values copied into the new Lua table. + /// Allocates and fills the table inside one native protected operation. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushByteTable(ReadOnlySpan bytes) + { + CheckProtectedResult(new LuaStatus(LuaProtectedApi.PushByteTable(Pointer, bytes))); + } + + /// Pops a key and pushes t[key] without metamethods (lua_rawget). + /// A valid index of a table. + /// The type of the pushed value. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LuaType RawGet(int tableIndex) + { + return (LuaType) lua_rawget(Pointer, tableIndex); + } + + /// Pushes t[n] without metamethods (lua_rawgeti). + /// A valid index of a table, included. + /// The integer key. + /// The type of the pushed value. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LuaType RawGetIndex(int tableIndex, long key) + { + return (LuaType) lua_rawgeti(Pointer, tableIndex, key); + } + + /// + /// Pushes t[p] for a light-userdata key without metamethods (lua_rawgetp); the idiom for registry + /// entries private to one library. + /// + /// A valid index of a table, included. + /// The pointer used as key. + /// The type of the pushed value. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LuaType RawGetPointer(int tableIndex, nint key) + { + return (LuaType) lua_rawgetp(Pointer, tableIndex, (void*) key); + } + + /// + /// Pops a value and a key and does t[key] = value without metamethods (lua_rawset), unless the key + /// is nil or NaN, which Lua refuses by raising: those keys are refused here instead, before the C call. + /// + /// A valid index of a table. + /// + /// when the key was nil or NaN; the table is unchanged. The key and the value are + /// popped in every case. + /// + /// + /// Allocates inside Lua when the table grows. One lua_type call more than the raw C function (two more for + /// a float key). + /// + [LuaStackEffect(-2)] + public bool TryRawSet(int tableIndex) + { + int keyType = lua_type(Pointer, -2); + if (keyType == LUA_TNIL || (keyType == LUA_TNUMBER && lua_isinteger(Pointer, -2) == 0 && + double.IsNaN(lua_tonumberx(Pointer, -2, null)))) + { + lua_settop(Pointer, -3); + return false; + } + + LuaStatus status = new(LuaProtectedApi.RawSet(Pointer, tableIndex)); + CheckProtectedResult(status); + return true; + } + + /// Pops a value and does t[n] = value without metamethods (lua_rawseti). + /// A valid index of a table, included. + /// The integer key. + /// Allocates inside Lua when the table grows. + [LuaStackEffect(-1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void RawSetIndex(int tableIndex, long key) + { + CheckProtectedResult(new LuaStatus(LuaProtectedApi.RawSetI(Pointer, tableIndex, key))); + } + + /// Pops a value and does t[p] = value for a light-userdata key without metamethods (lua_rawsetp). + /// A valid index of a table, included. + /// The pointer used as key. + /// Allocates inside Lua when the table grows. + [LuaStackEffect(-1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void RawSetPointer(int tableIndex, nint key) + { + CheckProtectedResult(new LuaStatus(LuaProtectedApi.RawSetP(Pointer, tableIndex, key))); + } + + /// Whether two values are primitively equal, without __eq (lua_rawequal). + /// An acceptable index. + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool RawEquals(int index1, int index2) + { + return lua_rawequal(Pointer, index1, index2) != 0; + } + + /// Pushes the metatable of the value at when it has one (lua_getmetatable). + /// A valid index. + /// and the metatable pushed; and nothing pushed. + public bool TryGetMetatable(int index) + { + return lua_getmetatable(Pointer, index) != 0; + } + + /// + /// Pops a table (or nil) and makes it the metatable of the value at ( + /// lua_setmetatable). + /// + /// A valid index. + [LuaStackEffect(-1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetMetatable(int index) + { + _ = lua_setmetatable(Pointer, index); + } + + /// Pushes a new full userdata of bytes and returns its block (lua_newuserdata). + /// Block size in bytes. + /// The address of the block: not zeroed, never moves, owned by Lua and freed by its collector. + /// Allocates inside Lua. + [LuaStackEffect(1)] + public nint NewUserdata(nuint size) + { + CheckProtectedResult(new LuaStatus(LuaProtectedApi.NewUserdata(Pointer, size))); + return (nint) lua_touserdata(Pointer, -1); + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Types.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Types.cs index 780fb97e..59e62d9a 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Types.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Types.cs @@ -2,7 +2,9 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Annotations.Lua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.State; @@ -10,136 +12,136 @@ namespace CheatEngine.SDK.Lua.State; // Type tests: one C API call each, none can raise. Every member takes an acceptable index. public readonly unsafe partial struct LuaState { - /// Gets the basic type of the value at (lua_type). - /// An acceptable index. - /// The type tag; for an index beyond the top. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public LuaType TypeOf(int index) - { - return (LuaType)lua_type(Pointer, index); - } + /// Gets the basic type of the value at (lua_type). + /// An acceptable index. + /// The type tag; for an index beyond the top. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LuaType TypeOf(int index) + { + return (LuaType) lua_type(Pointer, index); + } - /// - /// The name Lua gives to the type of the value at (lua_typename of lua_type): - /// nil, boolean, number, - /// string, table, function, - /// userdata, thread, or no value beyond the - /// top. For error messages that name what a thunk or a wrapper - /// actually received, without allocating. - /// - /// An acceptable index. - /// - /// The name, ASCII, without its terminator; a view of a constant string inside the Lua library, valid as long as - /// the library is loaded. - /// - /// Two C API calls; never raises. - [LuaStackEffect(0)] - public ReadOnlySpan TypeName(int index) - { - return TypeName((LuaType)lua_type(Pointer, index)); - } + /// + /// The name Lua gives to the type of the value at (lua_typename of lua_type): + /// nil, boolean, number, + /// string, table, function, + /// userdata, thread, or no value beyond the + /// top. For error messages that name what a thunk or a wrapper + /// actually received, without allocating. + /// + /// An acceptable index. + /// + /// The name, ASCII, without its terminator; a view of a constant string inside the Lua library, valid as long as + /// the library is loaded. + /// + /// Two C API calls; never raises. + [LuaStackEffect(0)] + public ReadOnlySpan TypeName(int index) + { + return TypeName((LuaType) lua_type(Pointer, index)); + } - /// The name Lua gives to a type tag (lua_typename); see . - /// A type tag, included. - /// The name, ASCII, without its terminator. - /// One C API call; never raises for a tag of . - public ReadOnlySpan TypeName(LuaType type) - { - Debug.Assert(type >= LuaType.None && type <= LuaType.Thread, "Not a Lua 5.3 type tag."); - return MemoryMarshal.CreateReadOnlySpanFromNullTerminated(lua_typename(Pointer, (int)type)); - } + /// The name Lua gives to a type tag (lua_typename); see . + /// A type tag, included. + /// The name, ASCII, without its terminator. + /// One C API call; never raises for a tag of . + public ReadOnlySpan TypeName(LuaType type) + { + Debug.Assert(type >= LuaType.None && type <= LuaType.Thread, "Not a Lua 5.3 type tag."); + return MemoryMarshal.CreateReadOnlySpanFromNullTerminated(lua_typename(Pointer, (int) type)); + } - /// - /// Whether the value at is a number stored as an integer (lua_isinteger). A - /// float with an integral value is not. - /// - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsInteger(int index) - { - return lua_isinteger(Pointer, index) != 0; - } + /// + /// Whether the value at is a number stored as an integer (lua_isinteger). A + /// float with an integral value is not. + /// + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsInteger(int index) + { + return lua_isinteger(Pointer, index) != 0; + } - /// - /// Whether the value at is a number, or a string that Lua would convert to one ( - /// lua_isnumber). - /// - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsNumberConvertible(int index) - { - return lua_isnumber(Pointer, index) != 0; - } + /// + /// Whether the value at is a number, or a string that Lua would convert to one ( + /// lua_isnumber). + /// + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsNumberConvertible(int index) + { + return lua_isnumber(Pointer, index) != 0; + } - /// Whether the value at is nil. - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsNil(int index) - { - return lua_type(Pointer, index) == LUA_TNIL; - } + /// Whether the value at is nil. + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsNil(int index) + { + return lua_type(Pointer, index) == LUA_TNIL; + } - /// Whether lies beyond the top of the stack (an absent argument). - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsNone(int index) - { - return lua_type(Pointer, index) == LUA_TNONE; - } + /// Whether lies beyond the top of the stack (an absent argument). + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsNone(int index) + { + return lua_type(Pointer, index) == LUA_TNONE; + } - /// - /// Whether the value at is nil or absent: what an optional argument left out - /// looks like. - /// - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsNoneOrNil(int index) - { - return lua_type(Pointer, index) <= LUA_TNIL; - } + /// + /// Whether the value at is nil or absent: what an optional argument left out + /// looks like. + /// + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsNoneOrNil(int index) + { + return lua_type(Pointer, index) <= LUA_TNIL; + } - /// Whether the value at is a function, Lua or C. - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsFunction(int index) - { - return lua_type(Pointer, index) == LUA_TFUNCTION; - } + /// Whether the value at is a function, Lua or C. + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsFunction(int index) + { + return lua_type(Pointer, index) == LUA_TFUNCTION; + } - /// Whether the value at is a table. - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsTable(int index) - { - return lua_type(Pointer, index) == LUA_TTABLE; - } + /// Whether the value at is a table. + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsTable(int index) + { + return lua_type(Pointer, index) == LUA_TTABLE; + } - /// - /// Whether the value at is a full userdata (a Lua-owned block, which is what host - /// objects are). - /// - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsUserdata(int index) - { - return lua_type(Pointer, index) == LUA_TUSERDATA; - } + /// + /// Whether the value at is a full userdata (a Lua-owned block, which is what host + /// objects are). + /// + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsUserdata(int index) + { + return lua_type(Pointer, index) == LUA_TUSERDATA; + } - /// Whether the value at is a light userdata (a bare pointer). - /// An acceptable index. - [LuaStackEffect(0)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsLightUserdata(int index) - { - return lua_type(Pointer, index) == LUA_TLIGHTUSERDATA; - } + /// Whether the value at is a light userdata (a bare pointer). + /// An acceptable index. + [LuaStackEffect(0)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsLightUserdata(int index) + { + return lua_type(Pointer, index) == LUA_TLIGHTUSERDATA; + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.cs index cf3668fd..82750e25 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Types; @@ -48,94 +49,97 @@ namespace CheatEngine.SDK.Lua.State; /// public readonly unsafe partial struct LuaState : IEquatable { - /// Value for the result count of : keep every result (LUA_MULTRET). - public const int MultipleResults = LuaApi.LUA_MULTRET; - - /// - /// Slots that are free when Lua enters a C function (LUA_MINSTACK). Beyond that, call - /// . - /// - public const int MinimumFreeSlots = LuaApi.LUA_MINSTACK; - - /// Pseudo-index of the registry (LUA_REGISTRYINDEX), usable with the Raw* table members. - public const int RegistryIndex = LuaApi.LUA_REGISTRYINDEX; - - /// Wraps a native lua_State* given as an integer handle. - /// - /// The address of a live Lua state: the argument of a lua_CFunction thunk, or a state owned by a test. - /// It is not validated; zero gives the view, on which no member may be called. - /// - /// - /// Constructing a view is a pure value operation; it is the members that need - /// CheatEngine.SDK.Lua.Interop.Api.LuaApi to be bound. - /// - public LuaState(nint handle) - { - Pointer = (lua_State*)handle; - } - - internal LuaState(lua_State* pointer) - { - Pointer = pointer; - } - - /// - /// Gets the address of the native state, for code that talks to CheatEngine.SDK.Lua.Interop directly. - /// - public nint Handle => (nint)Pointer; - - /// Gets a value indicating whether this is the default view over no state. - public bool IsNull => Pointer is null; - - internal lua_State* Pointer { get; } - - /// Compares two views for identity of the native state. - /// First view. - /// Second view. - /// when both refer to the same lua_State*. - public static bool operator ==(LuaState left, LuaState right) - { - return left.Pointer == right.Pointer; - } - - /// Compares two views for identity of the native state. - /// First view. - /// Second view. - /// when they refer to different states. - public static bool operator !=(LuaState left, LuaState right) - { - return left.Pointer != right.Pointer; - } - - /// - public bool Equals(LuaState other) - { - return Pointer == other.Pointer; - } - - /// - public override bool Equals(object? obj) - { - return obj is LuaState other && Equals(other); - } - - /// - public override int GetHashCode() - { - return ((nint)Pointer).GetHashCode(); - } - - /// Formats the address of the native state, for diagnostics. - /// lua_State@0x.... - public override string ToString() - { - return "lua_State@0x" + ((nint)Pointer).ToString("X", CultureInfo.InvariantCulture); - } - - // A relative index keeps designating the same slot after 'pushed' more values were pushed above it. - // Absolute indices and pseudo-indices (registry, upvalues) do not move. - internal static int Shift(int index, int pushed) - { - return index is < 0 and > LuaApi.LUA_REGISTRYINDEX ? index - pushed : index; - } + /// Value for the result count of : keep every result (LUA_MULTRET). + public const int MultipleResults = LuaApi.LUA_MULTRET; + + /// + /// Slots that are free when Lua enters a C function (LUA_MINSTACK). Beyond that, call + /// . + /// + public const int MinimumFreeSlots = LuaApi.LUA_MINSTACK; + + /// Pseudo-index of the registry (LUA_REGISTRYINDEX), usable with the Raw* table members. + public const int RegistryIndex = LuaApi.LUA_REGISTRYINDEX; + + /// Wraps a native lua_State* given as an integer handle. + /// + /// The address of a live Lua state: the argument of a lua_CFunction thunk, or a state owned by a test. + /// It is not validated; zero gives the view, on which no member may be called. + /// + /// + /// Constructing a view is a pure value operation; it is the members that need + /// CheatEngine.SDK.Lua.Interop.Api.LuaApi to be bound. + /// + public LuaState(nint handle) + { + Pointer = (lua_State*) handle; + } + + internal LuaState(lua_State* pointer) + { + Pointer = pointer; + } + + /// + /// Gets the address of the native state, for code that talks to CheatEngine.SDK.Lua.Interop directly. + /// + public nint Handle => (nint) Pointer; + + /// Gets a value indicating whether this is the default view over no state. + public bool IsNull => Pointer is null; + + internal lua_State* Pointer + { + get; + } + + /// Compares two views for identity of the native state. + /// First view. + /// Second view. + /// when both refer to the same lua_State*. + public static bool operator ==(LuaState left, LuaState right) + { + return left.Pointer == right.Pointer; + } + + /// Compares two views for identity of the native state. + /// First view. + /// Second view. + /// when they refer to different states. + public static bool operator !=(LuaState left, LuaState right) + { + return left.Pointer != right.Pointer; + } + + /// + public bool Equals(LuaState other) + { + return Pointer == other.Pointer; + } + + /// + public override bool Equals(object? obj) + { + return obj is LuaState other && Equals(other); + } + + /// + public override int GetHashCode() + { + return ((nint) Pointer).GetHashCode(); + } + + /// Formats the address of the native state, for diagnostics. + /// lua_State@0x.... + public override string ToString() + { + return "lua_State@0x" + ((nint) Pointer).ToString("X", CultureInfo.InvariantCulture); + } + + // A relative index keeps designating the same slot after 'pushed' more values were pushed above it. + // Absolute indices and pseudo-indices (registry, upvalues) do not move. + internal static int Shift(int index, int pushed) + { + return index is < 0 and > LuaApi.LUA_REGISTRYINDEX ? index - pushed : index; + } } diff --git a/libs/CheatEngine.SDK.Lua/State/LuaType.cs b/libs/CheatEngine.SDK.Lua/State/LuaType.cs index becae8fc..618218aa 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaType.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaType.cs @@ -12,35 +12,35 @@ namespace CheatEngine.SDK.Lua.State; /// public enum LuaType { - /// The index is acceptable but refers to no value (beyond the top of the stack). - None = -1, + /// The index is acceptable but refers to no value (beyond the top of the stack). + None = -1, - /// nil. - Nil = 0, + /// nil. + Nil = 0, - /// or . - Boolean = 1, + /// or . + Boolean = 1, - /// A raw pointer value without identity, metatable or lifetime of its own. - LightUserdata = 2, + /// A raw pointer value without identity, metatable or lifetime of its own. + LightUserdata = 2, - /// An integer (64-bit) or a float (double). - Number = 3, + /// An integer (64-bit) or a float (double). + Number = 3, - /// An immutable, length-counted byte string. This SDK treats the bytes as UTF-8. - [SuppressMessage("Naming", "CA1720:Identifiers should not contain type names", - Justification = "The Lua type is called string; renaming the tag would hide the C API it mirrors.")] - String = 4, + /// An immutable, length-counted byte string. This SDK treats the bytes as UTF-8. + [SuppressMessage("Naming", "CA1720:Identifiers should not contain type names", + Justification = "The Lua type is called string; renaming the tag would hide the C API it mirrors.")] + String = 4, - /// A table. - Table = 5, + /// A table. + Table = 5, - /// A Lua function or a C function. - Function = 6, + /// A Lua function or a C function. + Function = 6, - /// A block of memory owned by Lua, optionally with a metatable. Host objects are of this type. - Userdata = 7, + /// A block of memory owned by Lua, optionally with a metatable. Host objects are of this type. + Userdata = 7, - /// A coroutine. - Thread = 8 + /// A coroutine. + Thread = 8 } diff --git a/libs/CheatEngine.SDK.Lua/Text/Utf8Scratch.cs b/libs/CheatEngine.SDK.Lua/Text/Utf8Scratch.cs index 8a4eefd3..653873c8 100644 --- a/libs/CheatEngine.SDK.Lua/Text/Utf8Scratch.cs +++ b/libs/CheatEngine.SDK.Lua/Text/Utf8Scratch.cs @@ -29,68 +29,78 @@ namespace CheatEngine.SDK.Lua.Text; /// internal ref struct Utf8Scratch : IDisposable { - /// - /// Size of the stack buffer the callers of this type allocate: room for 170 characters of any kind, or 512 - /// ASCII characters. Above that a pooled array is used. Kept under the 1 KiB that the runtime's unsafe-code - /// guidance considers a reasonable stackalloc bound. - /// - public const int StackBufferSize = 512; + /// + /// Size of the stack buffer the callers of this type allocate: room for 170 characters of any kind, or 512 + /// ASCII characters. Above that a pooled array is used. Kept under the 1 KiB that the runtime's unsafe-code + /// guidance considers a reasonable stackalloc bound. + /// + public const int StackBufferSize = 512; - private byte[]? _rented; + private byte[]? _rented; - private Utf8Scratch(byte[]? rented, ReadOnlySpan bytes) - { - _rented = rented; - Bytes = bytes; - } + private Utf8Scratch(byte[]? rented, ReadOnlySpan bytes) + { + _rented = rented; + Bytes = bytes; + } - /// Gets the encoded bytes. Valid until . - public ReadOnlySpan Bytes { get; private set; } + /// Gets the encoded bytes. Valid until . + public ReadOnlySpan Bytes + { + get; + private set; + } - /// Gets a value indicating whether the text did not fit the stack buffer and a pooled array was rented. - public readonly bool IsPooled => _rented is not null; + /// Gets a value indicating whether the text did not fit the stack buffer and a pooled array was rented. + public readonly bool IsPooled => _rented is not null; - /// - /// Encodes , into when the worst case fits, else into a - /// pooled array. - /// - /// The UTF-16 text; may be empty. - /// - /// A buffer the caller owns for the duration of the result, usually - /// stackalloc byte[StackBufferSize]. - /// - /// The transcoding result; dispose it when the bytes are no longer needed. - public static Utf8Scratch Encode(ReadOnlySpan text, Span stackBuffer) - { - if (text.IsEmpty) return new Utf8Scratch(null, ReadOnlySpan.Empty); + /// + /// Encodes , into when the worst case fits, else into a + /// pooled array. + /// + /// The UTF-16 text; may be empty. + /// + /// A buffer the caller owns for the duration of the result, usually + /// stackalloc byte[StackBufferSize]. + /// + /// The transcoding result; dispose it when the bytes are no longer needed. + public static Utf8Scratch Encode(ReadOnlySpan text, Span stackBuffer) + { + if (text.IsEmpty) + { + return new Utf8Scratch(null, ReadOnlySpan.Empty); + } - // The worst case (every char a 3-byte sequence, plus the encoder's slack) is cheap to compute and lets the - // common case skip the exact count. - var worstCase = Encoding.UTF8.GetMaxByteCount(text.Length); - if (worstCase <= stackBuffer.Length) - { - var written = Encoding.UTF8.GetBytes(text, stackBuffer); - return new Utf8Scratch(null, stackBuffer[..written]); - } + // The worst case (every char a 3-byte sequence, plus the encoder's slack) is cheap to compute and lets the + // common case skip the exact count. + int worstCase = Encoding.UTF8.GetMaxByteCount(text.Length); + if (worstCase <= stackBuffer.Length) + { + int written = Encoding.UTF8.GetBytes(text, stackBuffer); + return new Utf8Scratch(null, stackBuffer[..written]); + } - var exact = Encoding.UTF8.GetByteCount(text); - if (exact <= stackBuffer.Length) - { - var written = Encoding.UTF8.GetBytes(text, stackBuffer); - return new Utf8Scratch(null, stackBuffer[..written]); - } + int exact = Encoding.UTF8.GetByteCount(text); + if (exact <= stackBuffer.Length) + { + int written = Encoding.UTF8.GetBytes(text, stackBuffer); + return new Utf8Scratch(null, stackBuffer[..written]); + } - var rented = ArrayPool.Shared.Rent(exact); - var count = Encoding.UTF8.GetBytes(text, rented); - return new Utf8Scratch(rented, new ReadOnlySpan(rented, 0, count)); - } + byte[] rented = ArrayPool.Shared.Rent(exact); + int count = Encoding.UTF8.GetBytes(text, rented); + return new Utf8Scratch(rented, new ReadOnlySpan(rented, 0, count)); + } - /// Returns the pooled array, if any. Idempotent. - public void Dispose() - { - var rented = _rented; - _rented = null; - Bytes = ReadOnlySpan.Empty; - if (rented is not null) ArrayPool.Shared.Return(rented); - } + /// Returns the pooled array, if any. Idempotent. + public void Dispose() + { + byte[]? rented = _rented; + _rented = null; + Bytes = ReadOnlySpan.Empty; + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } } diff --git a/native/cheatengine-sdk-lua-bridge/README.md b/native/cheatengine-sdk-lua-bridge/README.md index d412b9ec..c277b15c 100644 --- a/native/cheatengine-sdk-lua-bridge/README.md +++ b/native/cheatengine-sdk-lua-bridge/README.md @@ -1,7 +1,10 @@ # CheatEngine.SDK Lua bridge This deliberately tiny **C11**, Windows x64 micro-kernel puts host Lua operations that can allocate, invoke a finalizer, -or call `lua_error` beneath a native `lua_pcallk` boundary. Lua 5.3 uses `setjmp`/`longjmp` for those failures; [Microsoft documents that .NET does not support that interop and a `longjmp` must not cross or skip a managed frame](https://learn.microsoft.com/dotnet/standard/native-interop/exceptions-interoperability#setjmplongjmp-behaviors). The public SDK remains C# 14/.NET 10 and AOT-friendly; this source exists only for the native control-flow boundary. +or call `lua_error` beneath a native `lua_pcallk` boundary. Lua 5.3 uses `setjmp`/`longjmp` for those +failures; [Microsoft documents that .NET does not support that interop and a +`longjmp` must not cross or skip a managed frame](https://learn.microsoft.com/dotnet/standard/native-interop/exceptions-interoperability#setjmplongjmp-behaviors). +The public SDK remains C# 14/.NET 10 and AOT-friendly; this source exists only for the native control-flow boundary. The bridge receives function pointers from Cheat Engine's already-loaded Lua module. It includes neither `lua.h` nor a Lua import library and has no `LoadLibrary`/`GetProcAddress` path, so it cannot create or load a second Lua runtime. @@ -35,7 +38,8 @@ library, no `lua.h` include and no runtime-loading code: it receives the already Before pushing its zero-upvalue light C closure, the bridge validates all 20 host export pointers, validates the input depth, and calls `lua_checkstack(L, 1)`. The pinned upstream CE Lua 5.3.0 -[`lapi.c`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/lua53/lua53/src/lapi.c#L99-L116) +[ +`lapi.c`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/lua53/lua53/src/lapi.c#L99-L116) shows that `lua_checkstack` uses `luaD_rawrunprotected` and reports an allocation/overflow failure as `0`; it does not unwind to the bridge caller. After that reservation `lua_pushcclosure(operation, 0)` is a non-allocating light C function, while every bridge-owned operation that can raise starts only after `lua_pcallk`. This is `PinnedUpstream` diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs index dda85302..f158860c 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs @@ -1,7 +1,9 @@ using System.Globalization; + using CheatEngine.SDK.SourceGenerators.EngineApi.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.EngineApi.Emit; @@ -21,324 +23,381 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Emit; /// internal static class EngineApiFileEmitter { - // The public type of a CE-side (target-process) address: the unsigned 64-bit Address wrapper, not a pointer-sized - // nuint. CheatEngine.SDK.Lua's pointer-typed marshallers are not reused for it on the public surface. Every entry that - // pushes one is split into a private nuint-typed core (LuaGlobalCallEmitter's unmodified call shape, through - // AddressMarshaller) and a public forwarding wrapper typed in this. - // See EmitAddressTypedWrapper. - private const string EngineAddressTypeName = "global::CheatEngine.SDK.Engine.Values.Address"; - - // Computed once: reads this generator assembly's name and version. - private static readonly string GeneratedCodeAttribute = - GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(EngineApiFileEmitter)); - - /// The hint name assigned to by Model/SpecFiles.AssignHintNames. - public static string HintName(SpecFileModel spec) - { - return spec.HintName; - } - - /// - /// Emits the file for . The caller must not call this for a file with zero - /// . - /// - public static SourceText Emit(SpecFileModel spec) - { - SourceWriter writer = new(4096); - GeneratedCodeText.WriteFileHeader(writer); - - var hasNamespace = spec.Namespace.Length > 0; - if (hasNamespace) - { - writer.Write("namespace "); - writer.WriteLine(spec.Namespace); - writer.OpenBlock(); - } - - writer.WriteLine( - "/// Wrapper members generated from the Cheat Engine API spec file curated for this type."); - writer.Write("public static partial class "); - writer.WriteLine(spec.TypeName); - writer.OpenBlock(); - - foreach (var global in spec.CachedGlobals) - { - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("private static readonly "); - writer.Write(LuaApiNames.LuaRef); - writer.Write(' '); - writer.Write(LuaGlobalCallModel.CacheFieldFor(global)); - writer.WriteLine(" = new();"); - } - - foreach (var entry in spec.Calls) - { - writer.WriteLine(); - if (NeedsAddressTypedWrapper(entry.Call)) - { - EmitAddressTypedWrapper(writer, entry); - } - else - { - writer.Write("/// "); - writer.Write(EscapeXmlText(entry.Summary)); - writer.WriteLine(""); - WriteContractRemarks(writer, entry.Contract, entry.Call); - writer.WriteLine(GeneratedCodeAttribute); - LuaGlobalCallEmitter.Emit(writer, entry.Call); - } - } - - writer.CloseBlock(); - if (hasNamespace) writer.CloseBlock(); - - return writer.ToSourceText(); - } - - // Minimal, sufficient for one line of curator-written prose: XML text content has no other special characters. - private static string EscapeXmlText(string value) - { - return value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); - } - - private static void WriteContractRemarks(SourceWriter writer, SpecContract? contract, LuaGlobalCallModel call) - { - if (contract is null) return; - - writer.Write("/// CE >= "); - writer.Write(EscapeXmlText(contract.MinimumCheatEngineVersion)); - writer.Write("; architecture: "); - writer.Write(EscapeXmlText(contract.Architecture)); - writer.Write("; thread: "); - writer.Write(EscapeXmlText(contract.ThreadAffinity)); - writer.Write("; ownership: "); - writer.Write(EscapeXmlText(contract.Ownership)); - writer.Write("; return: "); - writer.Write(EscapeXmlText(ReturnSemantics(call))); - writer.Write("; nil: "); - writer.Write(EscapeXmlText(contract.NilSemantics)); - writer.Write("; provenance: "); - writer.Write(EscapeXmlText(contract.Provenance)); - writer.WriteLine("."); - } - - private static string ReturnSemantics(LuaGlobalCallModel call) - { - if (call.Form == LuaCallForm.Try) return "bool with out results"; - if (call.ReturnKind is null) return "void or Lua exception"; - - return "throwing " + LuaValueKinds.TypeName(call.ReturnKind.Value, call.ReturnIsNullable); - } - - // Every target-process Address crosses this public boundary as Engine.Values.Address. The shared call emitter keeps - // its proven host-width nuint core, while this facade converts every argument, Try out value and throwing return; - // a new EngineApi spec can therefore never silently expose an address as an ambiguous nuint. - private static bool NeedsAddressTypedWrapper(LuaGlobalCallModel call) - { - foreach (var argument in call.Arguments) - if (argument.Kind == LuaValueKind.Address) - return true; - - foreach (var result in call.Results) - if (result.Kind == LuaValueKind.Address) - return true; - - return call.ReturnKind == LuaValueKind.Address; - } - - // Writes the private nuint-typed core (LuaGlobalCallEmitter's own call shape, byte-for-byte what the ordinary - // path would emit, just private and under a mangled name) and the public Address-typed wrapper that forwards - // to it. The core keeps every line of the protected-call body; the only added code is the two-line boundary - // conversion, exact and allocation-free because CheatEngine.SDK.Engine.Values.Address and nuint carry the same 64-bit pattern - // on this SDK's only supported architecture (x64). - private static void EmitAddressTypedWrapper(SourceWriter writer, SpecCallModel entry) - { - var call = entry.Call; - var core = call with { Modifiers = "private static", MethodName = CoreMethodName(call.MethodName) }; - - writer.Write("// Raw core of '"); - writer.Write(call.MethodName); - writer.WriteLine("': nuint address, through AddressMarshaller. Never called except by the wrapper below."); - writer.WriteLine(GeneratedCodeAttribute); - LuaGlobalCallEmitter.Emit(writer, core); - writer.WriteLine(); - - writer.Write("/// "); - writer.Write(EscapeXmlText(entry.Summary)); - writer.WriteLine(""); - WriteContractRemarks(writer, entry.Contract, call); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public static "); - writer.Write(PublicReturnTypeName(call)); - writer.Write(' '); - writer.Write(call.MethodName); - WriteAddressTypedParameterList(writer, call); - writer.WriteLine(); - writer.OpenBlock(); - WriteAddressTypedForwardingBody(writer, call, core); - writer.CloseBlock(); - } - - // '__Raw': a leading double underscore is not a reserved C# identifier form, the same convention - // LuaGlobalCallEmitter's own body locals use for the same reason: a collision is extremely unlikely, not - // impossible, and fails loudly (a collision with a curated 'method:' name is CS0111 in the generated file, not - // a silent miscompile). - private static string CoreMethodName(string methodName) - { - return "__" + (methodName[0] == '@' ? methodName[1..] : methodName) + "Raw"; - } - - // '( name, ..., out name, ...)': identical to LuaGlobalCallEmitter.WriteParameterList except every - // target Address is typed CheatEngine.SDK.Engine.Values.Address instead of the raw nuint used in the private core. - private static void WriteAddressTypedParameterList(SourceWriter writer, LuaGlobalCallModel call) - { - writer.Write('('); - var first = true; - foreach (var argument in call.Arguments) - { - if (argument.IsFixed) continue; - - if (!first) writer.Write(", "); - - first = false; - writer.Write(argument.Kind == LuaValueKind.Address - ? EngineAddressTypeName - : LuaValueKinds.TypeName(argument.Kind, argument.IsNullable)); - writer.Write(' '); - writer.Write(argument.Name); - } - - if (call.Form == LuaCallForm.Try) - for (var i = 0; i < call.Results.Length; i++) - { - var result = call.Results[i]; - if (!first) writer.Write(", "); - - first = false; - writer.Write("out "); - writer.Write(result.Kind == LuaValueKind.Address - ? EngineAddressTypeName - : LuaValueKinds.TypeName(result.Kind, result.IsNullable)); - writer.Write(' '); - writer.Write(result.Name); - } - - writer.Write(')'); - } - - // The raw core can write nuint only. Try wrappers with Address out values receive raw locals then convert every - // result (also on false, where the raw core deterministically assigned zero); throwing returns convert the raw - // value after the protected call succeeded. The simple argument-only forms retain their allocation-free direct - // forwarding body. - private static void WriteAddressTypedForwardingBody(SourceWriter writer, LuaGlobalCallModel call, - LuaGlobalCallModel core) - { - if (call.Form == LuaCallForm.Try && HasAddressResult(call)) - { - for (var i = 0; i < call.Results.Length; i++) - if (call.Results[i].Kind == LuaValueKind.Address) - { - writer.Write("nuint "); - writer.Write(RawResultName(i)); - writer.WriteLine(";"); - } - - writer.Write("bool __engineApiSucceeded = "); - WriteAddressTypedInvocation(writer, call, core, useRawAddressResults: true); - writer.WriteLine(";"); - - for (var i = 0; i < call.Results.Length; i++) - if (call.Results[i].Kind == LuaValueKind.Address) - { - writer.Write(call.Results[i].Name); - writer.Write(" = new "); - writer.Write(EngineAddressTypeName); - writer.Write("(unchecked((ulong)"); - writer.Write(RawResultName(i)); - writer.WriteLine("));"); - } - - writer.WriteLine("return __engineApiSucceeded;"); - return; - } - - if (call.Form == LuaCallForm.Throwing && call.ReturnKind == LuaValueKind.Address) - { - writer.Write("nuint __engineApiRawResult = "); - WriteAddressTypedInvocation(writer, call, core, useRawAddressResults: false); - writer.WriteLine(";"); - writer.Write("return new "); - writer.Write(EngineAddressTypeName); - writer.WriteLine("(unchecked((ulong)__engineApiRawResult));"); - return; - } - - var isVoid = call.Form == LuaCallForm.Throwing && call.ReturnKind is null; - if (!isVoid) writer.Write("return "); - - WriteAddressTypedInvocation(writer, call, core, useRawAddressResults: false); - writer.WriteLine(";"); - } - - private static void WriteAddressTypedInvocation(SourceWriter writer, LuaGlobalCallModel call, - LuaGlobalCallModel core, bool useRawAddressResults) - { - writer.Write(core.MethodName); - writer.Write('('); - var first = true; - foreach (var argument in call.Arguments) - { - if (argument.IsFixed) continue; - - if (!first) writer.Write(", "); - - first = false; - if (argument.Kind == LuaValueKind.Address) - { - writer.Write("unchecked((nuint)"); - writer.Write(argument.Name); - writer.Write(".ToUInt64())"); - } - else - { - writer.Write(argument.Name); - } - } - - if (call.Form == LuaCallForm.Try) - for (var i = 0; i < call.Results.Length; i++) - { - var result = call.Results[i]; - if (!first) writer.Write(", "); - - first = false; - writer.Write("out "); - writer.Write(useRawAddressResults && result.Kind == LuaValueKind.Address - ? RawResultName(i) - : result.Name); - } - - writer.Write(')'); - } - - private static bool HasAddressResult(LuaGlobalCallModel call) - { - foreach (var result in call.Results) - if (result.Kind == LuaValueKind.Address) - return true; - - return false; - } - - private static string PublicReturnTypeName(LuaGlobalCallModel call) - { - return call.Form == LuaCallForm.Throwing && call.ReturnKind == LuaValueKind.Address - ? EngineAddressTypeName - : LuaGlobalCallEmitter.ReturnTypeName(call); - } - - private static string RawResultName(int index) - { - return "__engineApiRawResult" + index.ToString(CultureInfo.InvariantCulture); - } + // The public type of a CE-side (target-process) address: the unsigned 64-bit Address wrapper, not a pointer-sized + // nuint. CheatEngine.SDK.Lua's pointer-typed marshallers are not reused for it on the public surface. Every entry that + // pushes one is split into a private nuint-typed core (LuaGlobalCallEmitter's unmodified call shape, through + // AddressMarshaller) and a public forwarding wrapper typed in this. + // See EmitAddressTypedWrapper. + private const string EngineAddressTypeName = "global::CheatEngine.SDK.Engine.Values.Address"; + + // Computed once: reads this generator assembly's name and version. + private static readonly string GeneratedCodeAttribute = + GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(EngineApiFileEmitter)); + + /// The hint name assigned to by Model/SpecFiles.AssignHintNames. + public static string HintName(SpecFileModel spec) + { + return spec.HintName; + } + + /// + /// Emits the file for . The caller must not call this for a file with zero + /// . + /// + public static SourceText Emit(SpecFileModel spec) + { + SourceWriter writer = new(4096); + GeneratedCodeText.WriteFileHeader(writer); + + bool hasNamespace = spec.Namespace.Length > 0; + if (hasNamespace) + { + writer.Write("namespace "); + writer.WriteLine(spec.Namespace); + writer.OpenBlock(); + } + + writer.WriteLine( + "/// Wrapper members generated from the Cheat Engine API spec file curated for this type."); + writer.Write("public static partial class "); + writer.WriteLine(spec.TypeName); + writer.OpenBlock(); + + foreach (string global in spec.CachedGlobals) + { + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("private static readonly "); + writer.Write(LuaApiNames.LuaRef); + writer.Write(' '); + writer.Write(LuaGlobalCallModel.CacheFieldFor(global)); + writer.WriteLine(" = new();"); + } + + foreach (SpecCallModel entry in spec.Calls) + { + writer.WriteLine(); + if (NeedsAddressTypedWrapper(entry.Call)) + { + EmitAddressTypedWrapper(writer, entry); + } + else + { + writer.Write("/// "); + writer.Write(EscapeXmlText(entry.Summary)); + writer.WriteLine(""); + WriteContractRemarks(writer, entry.Contract, entry.Call); + writer.WriteLine(GeneratedCodeAttribute); + LuaGlobalCallEmitter.Emit(writer, entry.Call); + } + } + + writer.CloseBlock(); + if (hasNamespace) + { + writer.CloseBlock(); + } + + return writer.ToSourceText(); + } + + // Minimal, sufficient for one line of curator-written prose: XML text content has no other special characters. + private static string EscapeXmlText(string value) + { + return value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + } + + private static void WriteContractRemarks(SourceWriter writer, SpecContract? contract, LuaGlobalCallModel call) + { + if (contract is null) + { + return; + } + + writer.Write("/// CE >= "); + writer.Write(EscapeXmlText(contract.MinimumCheatEngineVersion)); + writer.Write("; architecture: "); + writer.Write(EscapeXmlText(contract.Architecture)); + writer.Write("; thread: "); + writer.Write(EscapeXmlText(contract.ThreadAffinity)); + writer.Write("; ownership: "); + writer.Write(EscapeXmlText(contract.Ownership)); + writer.Write("; return: "); + writer.Write(EscapeXmlText(ReturnSemantics(call))); + writer.Write("; nil: "); + writer.Write(EscapeXmlText(contract.NilSemantics)); + writer.Write("; provenance: "); + writer.Write(EscapeXmlText(contract.Provenance)); + writer.WriteLine("."); + } + + private static string ReturnSemantics(LuaGlobalCallModel call) + { + if (call.Form == LuaCallForm.Try) + { + return "bool with out results"; + } + + if (call.ReturnKind is null) + { + return "void or Lua exception"; + } + + return "throwing " + LuaValueKinds.TypeName(call.ReturnKind.Value, call.ReturnIsNullable); + } + + // Every target-process Address crosses this public boundary as Engine.Values.Address. The shared call emitter keeps + // its proven host-width nuint core, while this facade converts every argument, Try out value and throwing return; + // a new EngineApi spec can therefore never silently expose an address as an ambiguous nuint. + private static bool NeedsAddressTypedWrapper(LuaGlobalCallModel call) + { + foreach (LuaArgumentModel argument in call.Arguments) + { + if (argument.Kind == LuaValueKind.Address) + { + return true; + } + } + + foreach (LuaResultModel result in call.Results) + { + if (result.Kind == LuaValueKind.Address) + { + return true; + } + } + + return call.ReturnKind == LuaValueKind.Address; + } + + // Writes the private nuint-typed core (LuaGlobalCallEmitter's own call shape, byte-for-byte what the ordinary + // path would emit, just private and under a mangled name) and the public Address-typed wrapper that forwards + // to it. The core keeps every line of the protected-call body; the only added code is the two-line boundary + // conversion, exact and allocation-free because CheatEngine.SDK.Engine.Values.Address and nuint carry the same 64-bit pattern + // on this SDK's only supported architecture (x64). + private static void EmitAddressTypedWrapper(SourceWriter writer, SpecCallModel entry) + { + LuaGlobalCallModel call = entry.Call; + LuaGlobalCallModel core = call with + { + Modifiers = "private static", MethodName = CoreMethodName(call.MethodName) + }; + + writer.Write("// Raw core of '"); + writer.Write(call.MethodName); + writer.WriteLine("': nuint address, through AddressMarshaller. Never called except by the wrapper below."); + writer.WriteLine(GeneratedCodeAttribute); + LuaGlobalCallEmitter.Emit(writer, core); + writer.WriteLine(); + + writer.Write("/// "); + writer.Write(EscapeXmlText(entry.Summary)); + writer.WriteLine(""); + WriteContractRemarks(writer, entry.Contract, call); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public static "); + writer.Write(PublicReturnTypeName(call)); + writer.Write(' '); + writer.Write(call.MethodName); + WriteAddressTypedParameterList(writer, call); + writer.WriteLine(); + writer.OpenBlock(); + WriteAddressTypedForwardingBody(writer, call, core); + writer.CloseBlock(); + } + + // '__Raw': a leading double underscore is not a reserved C# identifier form, the same convention + // LuaGlobalCallEmitter's own body locals use for the same reason: a collision is extremely unlikely, not + // impossible, and fails loudly (a collision with a curated 'method:' name is CS0111 in the generated file, not + // a silent miscompile). + private static string CoreMethodName(string methodName) + { + return "__" + (methodName[0] == '@' ? methodName[1..] : methodName) + "Raw"; + } + + // '( name, ..., out name, ...)': identical to LuaGlobalCallEmitter.WriteParameterList except every + // target Address is typed CheatEngine.SDK.Engine.Values.Address instead of the raw nuint used in the private core. + private static void WriteAddressTypedParameterList(SourceWriter writer, LuaGlobalCallModel call) + { + writer.Write('('); + bool first = true; + foreach (LuaArgumentModel argument in call.Arguments) + { + if (argument.IsFixed) + { + continue; + } + + if (!first) + { + writer.Write(", "); + } + + first = false; + writer.Write(argument.Kind == LuaValueKind.Address + ? EngineAddressTypeName + : LuaValueKinds.TypeName(argument.Kind, argument.IsNullable)); + writer.Write(' '); + writer.Write(argument.Name); + } + + if (call.Form == LuaCallForm.Try) + { + for (int i = 0; i < call.Results.Length; i++) + { + LuaResultModel result = call.Results[i]; + if (!first) + { + writer.Write(", "); + } + + first = false; + writer.Write("out "); + writer.Write(result.Kind == LuaValueKind.Address + ? EngineAddressTypeName + : LuaValueKinds.TypeName(result.Kind, result.IsNullable)); + writer.Write(' '); + writer.Write(result.Name); + } + } + + writer.Write(')'); + } + + // The raw core can write nuint only. Try wrappers with Address out values receive raw locals then convert every + // result (also on false, where the raw core deterministically assigned zero); throwing returns convert the raw + // value after the protected call succeeded. The simple argument-only forms retain their allocation-free direct + // forwarding body. + private static void WriteAddressTypedForwardingBody(SourceWriter writer, LuaGlobalCallModel call, + LuaGlobalCallModel core) + { + if (call.Form == LuaCallForm.Try && HasAddressResult(call)) + { + for (int i = 0; i < call.Results.Length; i++) + { + if (call.Results[i].Kind == LuaValueKind.Address) + { + writer.Write("nuint "); + writer.Write(RawResultName(i)); + writer.WriteLine(";"); + } + } + + writer.Write("bool __engineApiSucceeded = "); + WriteAddressTypedInvocation(writer, call, core, true); + writer.WriteLine(";"); + + for (int i = 0; i < call.Results.Length; i++) + { + if (call.Results[i].Kind == LuaValueKind.Address) + { + writer.Write(call.Results[i].Name); + writer.Write(" = new "); + writer.Write(EngineAddressTypeName); + writer.Write("(unchecked((ulong)"); + writer.Write(RawResultName(i)); + writer.WriteLine("));"); + } + } + + writer.WriteLine("return __engineApiSucceeded;"); + return; + } + + if (call.Form == LuaCallForm.Throwing && call.ReturnKind == LuaValueKind.Address) + { + writer.Write("nuint __engineApiRawResult = "); + WriteAddressTypedInvocation(writer, call, core, false); + writer.WriteLine(";"); + writer.Write("return new "); + writer.Write(EngineAddressTypeName); + writer.WriteLine("(unchecked((ulong)__engineApiRawResult));"); + return; + } + + bool isVoid = call.Form == LuaCallForm.Throwing && call.ReturnKind is null; + if (!isVoid) + { + writer.Write("return "); + } + + WriteAddressTypedInvocation(writer, call, core, false); + writer.WriteLine(";"); + } + + private static void WriteAddressTypedInvocation(SourceWriter writer, LuaGlobalCallModel call, + LuaGlobalCallModel core, bool useRawAddressResults) + { + writer.Write(core.MethodName); + writer.Write('('); + bool first = true; + foreach (LuaArgumentModel argument in call.Arguments) + { + if (argument.IsFixed) + { + continue; + } + + if (!first) + { + writer.Write(", "); + } + + first = false; + if (argument.Kind == LuaValueKind.Address) + { + writer.Write("unchecked((nuint)"); + writer.Write(argument.Name); + writer.Write(".ToUInt64())"); + } + else + { + writer.Write(argument.Name); + } + } + + if (call.Form == LuaCallForm.Try) + { + for (int i = 0; i < call.Results.Length; i++) + { + LuaResultModel result = call.Results[i]; + if (!first) + { + writer.Write(", "); + } + + first = false; + writer.Write("out "); + writer.Write(useRawAddressResults && result.Kind == LuaValueKind.Address + ? RawResultName(i) + : result.Name); + } + } + + writer.Write(')'); + } + + private static bool HasAddressResult(LuaGlobalCallModel call) + { + foreach (LuaResultModel result in call.Results) + { + if (result.Kind == LuaValueKind.Address) + { + return true; + } + } + + return false; + } + + private static string PublicReturnTypeName(LuaGlobalCallModel call) + { + return call.Form == LuaCallForm.Throwing && call.ReturnKind == LuaValueKind.Address + ? EngineAddressTypeName + : LuaGlobalCallEmitter.ReturnTypeName(call); + } + + private static string RawResultName(int index) + { + return "__engineApiRawResult" + index.ToString(CultureInfo.InvariantCulture); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs index 8680e584..30cf9aa8 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.SourceGenerators.EngineApi.Model; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -7,40 +8,40 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi; /// Descriptors and location construction for curated Engine API spec-file diagnostics. internal static class EngineApiDiagnostics { - private const string Category = "CheatEngine.SDK.EngineApi"; + private const string Category = "CheatEngine.SDK.EngineApi"; - private static readonly DiagnosticDescriptor InvalidSpec = new( - "CESDK3001", - "Engine API specification is invalid", - "Engine API specification: {0}", - Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "Correct the listed Engine API spec grammar error; invalid specs cannot remove generated API silently."); + private static readonly DiagnosticDescriptor InvalidSpec = new( + "CESDK3001", + "Engine API specification is invalid", + "Engine API specification: {0}", + Category, + DiagnosticSeverity.Error, + true, + "Correct the listed Engine API spec grammar error; invalid specs cannot remove generated API silently."); - private static readonly DiagnosticDescriptor ConflictingSpec = new( - "CESDK3002", - "Engine API specification has a generated-identity conflict", - "Engine API specification: {0}", - Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "One Engine API spec file owns each generated type and its members and cache fields."); + private static readonly DiagnosticDescriptor ConflictingSpec = new( + "CESDK3002", + "Engine API specification has a generated-identity conflict", + "Engine API specification: {0}", + Category, + DiagnosticSeverity.Error, + true, + "One Engine API spec file owns each generated type and its members and cache fields."); - /// Creates the compiler diagnostic for one Roslyn-free parsed issue. - public static Diagnostic Create(SpecFileModel spec, SpecIssue issue) - { - var line = issue.Line > 0 ? issue.Line - 1 : 0; - var column = issue.Column > 0 ? issue.Column - 1 : 0; - LinePosition position = new(line, column); - var location = Location.Create( - spec.SourcePath, - new TextSpan(0, 0), - new LinePositionSpan(position, position)); + /// Creates the compiler diagnostic for one Roslyn-free parsed issue. + public static Diagnostic Create(SpecFileModel spec, SpecIssue issue) + { + int line = issue.Line > 0 ? issue.Line - 1 : 0; + int column = issue.Column > 0 ? issue.Column - 1 : 0; + LinePosition position = new(line, column); + Location location = Location.Create( + spec.SourcePath, + new TextSpan(0, 0), + new LinePositionSpan(position, position)); - return Diagnostic.Create( - issue.Kind == SpecIssueKind.Conflict ? ConflictingSpec : InvalidSpec, - location, - issue.Message); - } + return Diagnostic.Create( + issue.Kind == SpecIssueKind.Conflict ? ConflictingSpec : InvalidSpec, + location, + issue.Message); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiGenerator.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiGenerator.cs index 3495b806..6ac0da89 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiGenerator.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiGenerator.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.SourceGenerators.EngineApi.Emit; using CheatEngine.SDK.SourceGenerators.EngineApi.Model; using CheatEngine.SDK.SourceGenerators.EngineApi.Parsing; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EngineApi; @@ -31,37 +32,39 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi; [Generator(LanguageNames.CSharp)] public sealed class EngineApiGenerator : IIncrementalGenerator { - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var specTexts = context.AdditionalTextsProvider - .Where(static text => SpecFileParser.IsSpecFile(text.Path)) - .WithTrackingName(EngineApiTrackingNames.SpecTextFile); + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider specTexts = context.AdditionalTextsProvider + .Where(static text => SpecFileParser.IsSpecFile(text.Path)) + .WithTrackingName(EngineApiTrackingNames.SpecTextFile); - var parsed = specTexts - .Select(static (text, cancellationToken) => - SpecFileParser.Parse(text.Path, text.GetText(cancellationToken)?.ToString())) - .WithTrackingName(EngineApiTrackingNames.ParsedSpec); + IncrementalValuesProvider parsed = specTexts + .Select(static (text, cancellationToken) => + SpecFileParser.Parse(text.Path, text.GetText(cancellationToken)?.ToString())) + .WithTrackingName(EngineApiTrackingNames.ParsedSpec); - var files = parsed - .Collect() - .WithTrackingName(EngineApiTrackingNames.CollectedSpecs) - .Select(static (specs, _) => SpecFiles.AssignHintNames(specs)) - .WithTrackingName(EngineApiTrackingNames.SpecFiles) - .SelectMany(static (specs, _) => specs.AsImmutableArray()) - .WithTrackingName(EngineApiTrackingNames.SpecFile); + IncrementalValuesProvider files = parsed + .Collect() + .WithTrackingName(EngineApiTrackingNames.CollectedSpecs) + .Select(static (specs, _) => SpecFiles.AssignHintNames(specs)) + .WithTrackingName(EngineApiTrackingNames.SpecFiles) + .SelectMany(static (specs, _) => specs.AsImmutableArray()) + .WithTrackingName(EngineApiTrackingNames.SpecFile); - context.RegisterSourceOutput(files, static (productionContext, spec) => - { - foreach (var issue in spec.Issues) - productionContext.ReportDiagnostic(EngineApiDiagnostics.Create(spec, issue)); - }); + context.RegisterSourceOutput(files, static (productionContext, spec) => + { + foreach (SpecIssue issue in spec.Issues) + { + productionContext.ReportDiagnostic(EngineApiDiagnostics.Create(spec, issue)); + } + }); - var outputs = files - .Where(static spec => spec.Calls.Length > 0 && !spec.IsSuppressed) - .WithTrackingName(EngineApiTrackingNames.SpecFileOutput); + IncrementalValuesProvider outputs = files + .Where(static spec => spec.Calls.Length > 0 && !spec.IsSuppressed) + .WithTrackingName(EngineApiTrackingNames.SpecFileOutput); - context.RegisterSourceOutput(outputs, static (productionContext, spec) => - productionContext.AddSource(EngineApiFileEmitter.HintName(spec), EngineApiFileEmitter.Emit(spec))); - } + context.RegisterSourceOutput(outputs, static (productionContext, spec) => + productionContext.AddSource(EngineApiFileEmitter.HintName(spec), EngineApiFileEmitter.Emit(spec))); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiTrackingNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiTrackingNames.cs index 33852f7b..65a7da35 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiTrackingNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiTrackingNames.cs @@ -14,27 +14,27 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi; /// internal static class EngineApiTrackingNames { - /// The additional text, after the *.cheatengine-sdk-api.txt filter. - public const string SpecTextFile = TrackingNames.Prefix + "EngineApi.SpecTextFile"; + /// The additional text, after the *.cheatengine-sdk-api.txt filter. + public const string SpecTextFile = TrackingNames.Prefix + "EngineApi.SpecTextFile"; - /// One file, parsed to a value-equatable model (hint name not yet assigned). - public const string ParsedSpec = TrackingNames.Prefix + "EngineApi.ParsedSpec"; + /// One file, parsed to a value-equatable model (hint name not yet assigned). + public const string ParsedSpec = TrackingNames.Prefix + "EngineApi.ParsedSpec"; - /// Every parsed file of the pass, collected. - public const string CollectedSpecs = TrackingNames.Prefix + "EngineApi.CollectedSpecs"; + /// Every parsed file of the pass, collected. + public const string CollectedSpecs = TrackingNames.Prefix + "EngineApi.CollectedSpecs"; - /// Every file with its hint name resolved (collision-safe across the whole pass). - public const string SpecFiles = TrackingNames.Prefix + "EngineApi.SpecFiles"; + /// Every file with its hint name resolved (collision-safe across the whole pass). + public const string SpecFiles = TrackingNames.Prefix + "EngineApi.SpecFiles"; - /// One file, hint name included: the per-file output unit. - public const string SpecFile = TrackingNames.Prefix + "EngineApi.SpecFile"; + /// One file, hint name included: the per-file output unit. + public const string SpecFile = TrackingNames.Prefix + "EngineApi.SpecFile"; - /// Files with valid entries and no cross-file conflict: what reaches the source-emission output. - public const string SpecFileOutput = TrackingNames.Prefix + "EngineApi.SpecFileOutput"; + /// Files with valid entries and no cross-file conflict: what reaches the source-emission output. + public const string SpecFileOutput = TrackingNames.Prefix + "EngineApi.SpecFileOutput"; - /// Every step name above, for the cacheability test. - public static readonly string[] All = - [ - SpecTextFile, ParsedSpec, CollectedSpecs, SpecFiles, SpecFile, SpecFileOutput - ]; + /// Every step name above, for the cacheability test. + public static readonly string[] All = + [ + SpecTextFile, ParsedSpec, CollectedSpecs, SpecFiles, SpecFile, SpecFileOutput + ]; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecCallModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecCallModel.cs index d25fc0d8..ac4b3cdd 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecCallModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecCallModel.cs @@ -20,11 +20,11 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// only for legacy in-repository fixtures while they are migrated. /// internal sealed record SpecCallModel( - int Line, - int MethodLine, - int MethodColumn, - int GlobalLine, - int GlobalColumn, - string Summary, - LuaGlobalCallModel Call, - SpecContract? Contract); + int Line, + int MethodLine, + int MethodColumn, + int GlobalLine, + int GlobalColumn, + string Summary, + LuaGlobalCallModel Call, + SpecContract? Contract); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecContract.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecContract.cs index 72610640..4eed0634 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecContract.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecContract.cs @@ -12,9 +12,9 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// The ownership token for the wrapper's CE/Lua result and parameters. /// How the CE Lua global represents absence or expected failure. internal sealed record SpecContract( - string Provenance, - string MinimumCheatEngineVersion, - string Architecture, - string ThreadAffinity, - string Ownership, - string NilSemantics); + string Provenance, + string MinimumCheatEngineVersion, + string Architecture, + string ThreadAffinity, + string Ownership, + string NilSemantics); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileContract.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileContract.cs index dc0813b1..466cce1c 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileContract.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileContract.cs @@ -7,8 +7,8 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// The declared thread-affinity token. /// The declared ownership token. internal sealed record SpecFileContract( - string Provenance, - string MinimumCheatEngineVersion, - string Architecture, - string ThreadAffinity, - string Ownership); + string Provenance, + string MinimumCheatEngineVersion, + string Architecture, + string ThreadAffinity, + string Ownership); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileModel.cs index 6a94441a..b4040b2e 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFileModel.cs @@ -40,18 +40,18 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// /// Whether a cross-file identity conflict makes the complete file ineligible for emission. internal sealed record SpecFileModel( - string SourcePath, - string Namespace, - string TypeName, - int TypeLine, - int TypeColumn, - SpecFileContract? Contract, - string HintName, - EquatableArray CachedGlobals, - EquatableArray Calls, - EquatableArray Issues, - bool IsSuppressed) + string SourcePath, + string Namespace, + string TypeName, + int TypeLine, + int TypeColumn, + SpecFileContract? Contract, + string HintName, + EquatableArray CachedGlobals, + EquatableArray Calls, + EquatableArray Issues, + bool IsSuppressed) { - /// Suffix of the hint name: memory-scalars.cheatengine-sdk-api.txt.EngineApi.g.cs. - public const string HintSuffix = ".EngineApi.g.cs"; + /// Suffix of the hint name: memory-scalars.cheatengine-sdk-api.txt.EngineApi.g.cs. + public const string HintSuffix = ".EngineApi.g.cs"; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs index eadcf75f..f6e7986f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; + using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; @@ -14,205 +15,238 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// internal static class SpecFiles { - /// - /// Sorts specs by source path, gives every one a unique deterministic hint name, then suppresses every file in - /// a cross-file generated-identity conflict while retaining a located issue on each participant. - /// - public static EquatableArray AssignHintNames(ImmutableArray specs) - { - if (specs.IsDefaultOrEmpty) return EquatableArray.Empty; - - List sorted = [.. specs]; - sorted.Sort(static (left, right) => string.CompareOrdinal(left.SourcePath, right.SourcePath)); - - AssignUniqueHintNames(sorted); - SuppressCrossFileConflicts(sorted); - - return new EquatableArray([.. sorted]); - } - - private static void AssignUniqueHintNames(List specs) - { - HashSet used = new(StringComparer.OrdinalIgnoreCase); - for (var i = 0; i < specs.Count; i++) - { - var baseName = FileName(specs[i].SourcePath); - var hintName = HintNames.ForType(baseName, SpecFileModel.HintSuffix); - if (!used.Add(hintName)) - { - var sourceIdentity = baseName + "." + SourcePathHash(specs[i].SourcePath); - hintName = HintNames.ForType(sourceIdentity, SpecFileModel.HintSuffix); - var disambiguator = 1; - while (!used.Add(hintName)) - { - hintName = HintNames.ForType( - sourceIdentity + "." + disambiguator.ToString(CultureInfo.InvariantCulture), - SpecFileModel.HintSuffix); - disambiguator++; - } - } - - specs[i] = specs[i] with { HintName = hintName }; - } - } - - private static void SuppressCrossFileConflicts(List specs) - { - Dictionary> filesByType = new(StringComparer.Ordinal); - for (var i = 0; i < specs.Count; i++) - { - var spec = specs[i]; - if (spec.TypeName.Length == 0) continue; - - var typeIdentity = spec.Namespace + "\u001f" + spec.TypeName; - if (!filesByType.TryGetValue(typeIdentity, out var indices)) - { - indices = []; - filesByType.Add(typeIdentity, indices); - } - - indices.Add(i); - } - - foreach (var group in filesByType) - { - var indices = group.Value; - if (indices.Count < 2) continue; - - AppendTypeConflictIssues(specs, indices); - AppendMemberConflictIssues(specs, indices); - AppendCacheConflictIssues(specs, indices); - - foreach (var index in indices) specs[index] = specs[index] with { IsSuppressed = true }; - } - } - - private static void AppendTypeConflictIssues(List specs, List indices) - { - foreach (var index in indices) - { - var spec = specs[index]; - AddConflict(ref spec, spec.TypeLine, spec.TypeColumn, - "Generated type '" + QualifiedTypeName(spec) + - "' is declared by multiple Engine API spec files; one spec file must own a generated type."); - specs[index] = spec; - } - } - - private static void AppendMemberConflictIssues(List specs, List indices) - { - Dictionary> owners = new(StringComparer.Ordinal); - foreach (var index in indices) - foreach (var call in specs[index].Calls) - { - AddOwner(owners, call.Call.MethodName, (index, call.MethodLine, call.MethodColumn)); - if (UsesAddressFacade(call.Call)) - AddOwner(owners, CoreMethodName(call.Call.MethodName), (index, call.MethodLine, call.MethodColumn)); - } - - foreach (var entry in owners) - { - if (entry.Value.Count < 2) continue; - - foreach (var (fileIndex, line, column) in entry.Value) - { - var spec = specs[fileIndex]; - AddConflict(ref spec, line, column, - "Generated member '" + entry.Key + "' is declared by multiple Engine API spec files for type '" + - QualifiedTypeName(spec) + "'."); - specs[fileIndex] = spec; - } - } - } - - private static void AppendCacheConflictIssues(List specs, List indices) - { - Dictionary> owners = new(StringComparer.Ordinal); - foreach (var index in indices) - { - HashSet seenInFile = new(StringComparer.Ordinal); - foreach (var call in specs[index].Calls) - { - var cacheField = LuaGlobalCallModel.CacheFieldFor(call.Call.GlobalName); - if (seenInFile.Add(cacheField)) - AddOwner(owners, cacheField, (index, call.GlobalLine, call.GlobalColumn)); - } - } - - foreach (var entry in owners) - { - if (entry.Value.Count < 2) continue; - - foreach (var (fileIndex, line, column) in entry.Value) - { - var spec = specs[fileIndex]; - AddConflict(ref spec, line, column, - "Generated cache field '" + entry.Key + - "' is declared by multiple Engine API spec files for type '" + QualifiedTypeName(spec) + "'."); - specs[fileIndex] = spec; - } - } - } - - private static void AddOwner(Dictionary> owners, - string identity, - (int FileIndex, int Line, int Column) owner) - { - if (!owners.TryGetValue(identity, out var values)) - { - values = []; - owners.Add(identity, values); - } - - values.Add(owner); - } - - private static void AddConflict(ref SpecFileModel spec, int line, int column, string message) - { - List issues = [.. spec.Issues]; - issues.Add(new SpecIssue(line, message, column, SpecIssueKind.Conflict)); - spec = spec with { Issues = new EquatableArray([.. issues]) }; - } - - private static string QualifiedTypeName(SpecFileModel spec) - { - return spec.Namespace.Length == 0 ? spec.TypeName : spec.Namespace + "." + spec.TypeName; - } - - private static bool UsesAddressFacade(LuaGlobalCallModel call) - { - foreach (var argument in call.Arguments) - if (argument.Kind == LuaValueKind.Address) - return true; - - foreach (var result in call.Results) - if (result.Kind == LuaValueKind.Address) - return true; - - return call.ReturnKind == LuaValueKind.Address; - } - - private static string CoreMethodName(string methodName) - { - return "__" + (methodName[0] == '@' ? methodName[1..] : methodName) + "Raw"; - } - - // FNV-1a over the source path's UTF-16 code units. This keeps a collision resolution stable when unrelated - // additional files are added before it in ordinal order, without using a process-randomized string hash or putting - // the complete directory path into a generated file name. - private static string SourcePathHash(string sourcePath) - { - var hash = 2166136261u; - foreach (var c in sourcePath) hash = unchecked((hash ^ c) * 16777619u); - - return hash.ToString("x8", CultureInfo.InvariantCulture); - } - - // The file name only (no directory), without touching the file system: AdditionalText.Path is a string the - // build system hands the compiler, never read from disk here. - private static string FileName(string path) - { - var slash = path.LastIndexOfAny(['/', '\\']); - return slash < 0 ? path : path[(slash + 1)..]; - } + /// + /// Sorts specs by source path, gives every one a unique deterministic hint name, then suppresses every file in + /// a cross-file generated-identity conflict while retaining a located issue on each participant. + /// + public static EquatableArray AssignHintNames(ImmutableArray specs) + { + if (specs.IsDefaultOrEmpty) + { + return EquatableArray.Empty; + } + + List sorted = [.. specs]; + sorted.Sort(static (left, right) => string.CompareOrdinal(left.SourcePath, right.SourcePath)); + + AssignUniqueHintNames(sorted); + SuppressCrossFileConflicts(sorted); + + return new EquatableArray([.. sorted]); + } + + private static void AssignUniqueHintNames(List specs) + { + HashSet used = new(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < specs.Count; i++) + { + string baseName = FileName(specs[i].SourcePath); + string hintName = HintNames.ForType(baseName, SpecFileModel.HintSuffix); + if (!used.Add(hintName)) + { + string sourceIdentity = baseName + "." + SourcePathHash(specs[i].SourcePath); + hintName = HintNames.ForType(sourceIdentity, SpecFileModel.HintSuffix); + int disambiguator = 1; + while (!used.Add(hintName)) + { + hintName = HintNames.ForType( + sourceIdentity + "." + disambiguator.ToString(CultureInfo.InvariantCulture), + SpecFileModel.HintSuffix); + disambiguator++; + } + } + + specs[i] = specs[i] with { HintName = hintName }; + } + } + + private static void SuppressCrossFileConflicts(List specs) + { + Dictionary> filesByType = new(StringComparer.Ordinal); + for (int i = 0; i < specs.Count; i++) + { + SpecFileModel spec = specs[i]; + if (spec.TypeName.Length == 0) + { + continue; + } + + string typeIdentity = spec.Namespace + "\u001f" + spec.TypeName; + if (!filesByType.TryGetValue(typeIdentity, out List? indices)) + { + indices = []; + filesByType.Add(typeIdentity, indices); + } + + indices.Add(i); + } + + foreach (KeyValuePair> group in filesByType) + { + List indices = group.Value; + if (indices.Count < 2) + { + continue; + } + + AppendTypeConflictIssues(specs, indices); + AppendMemberConflictIssues(specs, indices); + AppendCacheConflictIssues(specs, indices); + + foreach (int index in indices) + { + specs[index] = specs[index] with { IsSuppressed = true }; + } + } + } + + private static void AppendTypeConflictIssues(List specs, List indices) + { + foreach (int index in indices) + { + SpecFileModel spec = specs[index]; + AddConflict(ref spec, spec.TypeLine, spec.TypeColumn, + "Generated type '" + QualifiedTypeName(spec) + + "' is declared by multiple Engine API spec files; one spec file must own a generated type."); + specs[index] = spec; + } + } + + private static void AppendMemberConflictIssues(List specs, List indices) + { + Dictionary> owners = new(StringComparer.Ordinal); + foreach (int index in indices) + foreach (SpecCallModel call in specs[index].Calls) + { + AddOwner(owners, call.Call.MethodName, (index, call.MethodLine, call.MethodColumn)); + if (UsesAddressFacade(call.Call)) + { + AddOwner(owners, CoreMethodName(call.Call.MethodName), (index, call.MethodLine, call.MethodColumn)); + } + } + + foreach (KeyValuePair> entry in owners) + { + if (entry.Value.Count < 2) + { + continue; + } + + foreach ((int fileIndex, int line, int column) in entry.Value) + { + SpecFileModel spec = specs[fileIndex]; + AddConflict(ref spec, line, column, + "Generated member '" + entry.Key + "' is declared by multiple Engine API spec files for type '" + + QualifiedTypeName(spec) + "'."); + specs[fileIndex] = spec; + } + } + } + + private static void AppendCacheConflictIssues(List specs, List indices) + { + Dictionary> owners = new(StringComparer.Ordinal); + foreach (int index in indices) + { + HashSet seenInFile = new(StringComparer.Ordinal); + foreach (SpecCallModel call in specs[index].Calls) + { + string cacheField = LuaGlobalCallModel.CacheFieldFor(call.Call.GlobalName); + if (seenInFile.Add(cacheField)) + { + AddOwner(owners, cacheField, (index, call.GlobalLine, call.GlobalColumn)); + } + } + } + + foreach (KeyValuePair> entry in owners) + { + if (entry.Value.Count < 2) + { + continue; + } + + foreach ((int fileIndex, int line, int column) in entry.Value) + { + SpecFileModel spec = specs[fileIndex]; + AddConflict(ref spec, line, column, + "Generated cache field '" + entry.Key + + "' is declared by multiple Engine API spec files for type '" + QualifiedTypeName(spec) + "'."); + specs[fileIndex] = spec; + } + } + } + + private static void AddOwner(Dictionary> owners, + string identity, + (int FileIndex, int Line, int Column) owner) + { + if (!owners.TryGetValue(identity, out List<(int FileIndex, int Line, int Column)>? values)) + { + values = []; + owners.Add(identity, values); + } + + values.Add(owner); + } + + private static void AddConflict(ref SpecFileModel spec, int line, int column, string message) + { + List issues = [.. spec.Issues]; + issues.Add(new SpecIssue(line, message, column, SpecIssueKind.Conflict)); + spec = spec with { Issues = new EquatableArray([.. issues]) }; + } + + private static string QualifiedTypeName(SpecFileModel spec) + { + return spec.Namespace.Length == 0 ? spec.TypeName : spec.Namespace + "." + spec.TypeName; + } + + private static bool UsesAddressFacade(LuaGlobalCallModel call) + { + foreach (LuaArgumentModel argument in call.Arguments) + { + if (argument.Kind == LuaValueKind.Address) + { + return true; + } + } + + foreach (LuaResultModel result in call.Results) + { + if (result.Kind == LuaValueKind.Address) + { + return true; + } + } + + return call.ReturnKind == LuaValueKind.Address; + } + + private static string CoreMethodName(string methodName) + { + return "__" + (methodName[0] == '@' ? methodName[1..] : methodName) + "Raw"; + } + + // FNV-1a over the source path's UTF-16 code units. This keeps a collision resolution stable when unrelated + // additional files are added before it in ordinal order, without using a process-randomized string hash or putting + // the complete directory path into a generated file name. + private static string SourcePathHash(string sourcePath) + { + uint hash = 2166136261u; + foreach (char c in sourcePath) + { + hash = unchecked((hash ^ c) * 16777619u); + } + + return hash.ToString("x8", CultureInfo.InvariantCulture); + } + + // The file name only (no directory), without touching the file system: AdditionalText.Path is a string the + // build system hands the compiler, never read from disk here. + private static string FileName(string path) + { + int slash = path.LastIndexOfAny(['/', '\\']); + return slash < 0 ? path : path[(slash + 1)..]; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssue.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssue.cs index bed03ecc..7a926085 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssue.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssue.cs @@ -9,7 +9,7 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// 1-based column number inside the spec file that the issue is about. /// Whether this is malformed input or a conflict introduced while combining spec files. internal sealed record SpecIssue( - int Line, - string Message, - int Column = 1, - SpecIssueKind Kind = SpecIssueKind.Grammar); + int Line, + string Message, + int Column = 1, + SpecIssueKind Kind = SpecIssueKind.Grammar); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs index 70a0730b..f5f834f5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs @@ -3,9 +3,9 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; /// The diagnostic family a belongs to. internal enum SpecIssueKind { - /// The source text does not meet the curated spec grammar. - Grammar, + /// The source text does not meet the curated spec grammar. + Grammar, - /// Two otherwise valid specs would generate the same C# identity. - Conflict, + /// Two otherwise valid specs would generate the same C# identity. + Conflict } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs index d7052f42..c0489457 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; + using CheatEngine.SDK.SourceGenerators.EngineApi.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; @@ -32,1108 +33,1305 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Parsing; /// internal static class SpecFileParser { - /// The recognised spec-file extension (case-insensitive), the AdditionalTextsProvider filter. - public const string FileNameSuffix = ".cheatengine-sdk-api.txt"; - - /// Whether ends with . - public static bool IsSpecFile(string? path) - { - return path is not null && path.EndsWith(FileNameSuffix, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Parses the text of one spec file. Never throws; is empty (assigned later - /// by ). - /// - public static SpecFileModel Parse(string filePath, string? text) - { - List issues = []; - var blocks = SplitBlocks(text ?? string.Empty, issues); - - var headerOk = ParseHeader( - blocks, - issues, - out var ns, - out var typeName, - out var typeLine, - out var typeColumn, - out var fileContract); - - List parsed = []; - if (headerOk) - for (var i = 1; i < blocks.Count; i++) - { - var call = ParseEntry(blocks[i], issues, fileContract); - if (call is not null) parsed.Add(call); - } - - var calls = DropDuplicateMethodNames(parsed, issues); - calls = DropGeneratedMemberCollisions(calls, issues); - calls = DropCacheMemberCollisions(calls, issues); - calls = DropTypeMemberCollisions(calls, headerOk ? typeName : string.Empty, issues); - calls.Sort(static (left, right) => string.CompareOrdinal(left.Call.MethodName, right.Call.MethodName)); - - var cachedGlobals = CollectCachedGlobals(calls); - - return new SpecFileModel( - filePath, - headerOk ? ns : string.Empty, - headerOk ? typeName : string.Empty, - typeLine, - typeColumn, - headerOk ? fileContract : null, - string.Empty, - new EquatableArray([.. cachedGlobals]), - new EquatableArray([.. calls]), - new EquatableArray([.. issues]), - IsSuppressed: false); - } - - private static List CollectCachedGlobals(List calls) - { - List cachedGlobals = []; - HashSet seenGlobals = new(StringComparer.Ordinal); - foreach (var call in calls) - if (seenGlobals.Add(call.Call.GlobalName)) - cachedGlobals.Add(call.Call.GlobalName); - - cachedGlobals.Sort(StringComparer.Ordinal); - return cachedGlobals; - } - - // Blank lines (whitespace-only, after comment lines are dropped) separate blocks; '#' lines are comments and - // never affect block boundaries, wherever they appear. The first block is the header, every later one an entry. - private static List SplitBlocks(string text, List issues) - { - var normalized = text.Replace("\r\n", "\n").Replace('\r', '\n'); - var lines = normalized.Split('\n'); - - List blocks = []; - Block current = new(); - for (var i = 0; i < lines.Length; i++) AddLine(lines[i], i + 1, blocks, ref current, issues); - - if (!current.IsEmpty) blocks.Add(current); - - return blocks; - } - - private static void AddLine(string rawLine, int lineNumber, List blocks, ref Block current, - List issues) - { - var trimmed = rawLine.Trim(); - if (trimmed.Length == 0) - { - if (!current.IsEmpty) - { - blocks.Add(current); - current = new Block(); - } - - return; - } - - if (trimmed[0] == '#') return; - - var firstNonWhitespace = rawLine.Length - rawLine.TrimStart().Length; - if (current.StartLine == 0) - { - current.StartLine = lineNumber; - current.StartColumn = firstNonWhitespace + 1; - } - - var colon = trimmed.IndexOf(':'); - if (colon <= 0) - { - issues.Add(new SpecIssue(lineNumber, "Malformed line: expected 'key: value'.", firstNonWhitespace + 1)); - current.Malformed = true; - return; - } - - var key = trimmed[..colon].TrimEnd(); - var value = trimmed[(colon + 1)..].Trim(); - current.Fields.Add( - new SpecField(lineNumber, firstNonWhitespace + 1, firstNonWhitespace + colon + 3, key, value)); - } - - private static bool ParseHeader(List blocks, List issues, out string ns, out string typeName, - out int typeLine, out int typeColumn, out SpecFileContract? contract) - { - ns = string.Empty; - typeName = string.Empty; - typeLine = 1; - typeColumn = 1; - contract = null; - - if (blocks.Count == 0) - { - issues.Add(new SpecIssue(1, - "The spec file is empty: expected a header block with 'namespace' and 'type'.")); - return false; - } - - var header = blocks[0]; - if (header.Malformed) - { - issues.Add(new SpecIssue(header.StartLine, "The header block contains a malformed line.", - header.StartColumn)); - return false; - } - - if (!ReadHeaderFields( - header, - issues, - out var namespaceValue, - out var typeValue, - out typeLine, - out typeColumn, - out contract)) - return false; - - if (!ValidateHeaderIdentity(header, namespaceValue, typeValue, typeLine, typeColumn, issues)) return false; - - ns = namespaceValue!; - typeName = typeValue!; - return true; - } - - private static bool ValidateHeaderIdentity(Block header, string? namespaceValue, string? typeValue, int typeLine, - int typeColumn, List issues) - { - if (namespaceValue is null) - { - issues.Add(new SpecIssue(header.StartLine, - "The header is missing required key 'namespace' (use an empty value for the global namespace).", - header.StartColumn)); - return false; - } - - if (typeValue is null || typeValue.Length == 0) - { - issues.Add(new SpecIssue(header.StartLine, - "The header is missing required key 'type', or its value is empty.", header.StartColumn)); - return false; - } - - if (!SpecIdentifiers.IsValidNamespace(namespaceValue)) - { - issues.Add(new SpecIssue(header.StartLine, "'" + namespaceValue + "' is not a valid namespace.", - header.StartColumn)); - return false; - } - - if (SpecIdentifiers.IsValidTypeIdentifier(typeValue)) return true; - - issues.Add(new SpecIssue(typeLine, "'" + typeValue + "' is not a valid type name.", typeColumn)); - return false; - } - - private static bool ReadHeaderFields(Block header, List issues, out string? namespaceValue, - out string? typeValue, out int typeLine, out int typeColumn, out SpecFileContract? contract) - { - HeaderFields fields = new(); - HashSet seen = new(StringComparer.Ordinal); - var ok = true; - foreach (var field in header.Fields) - { - if (!seen.Add(field.Key)) - { - issues.Add(new SpecIssue(field.Line, "Duplicate header key '" + field.Key + "'.", field.KeyColumn)); - ok = false; - continue; - } - - if (!TrySetHeaderField(fields, field, issues)) ok = false; - } - - namespaceValue = fields.Namespace?.Value; - typeValue = fields.Type?.Value; - typeLine = fields.Type?.Line ?? header.StartLine; - typeColumn = fields.Type?.ValueColumn ?? header.StartColumn; - contract = null; - return ok && TryCreateContract(fields, header, issues, out contract); - } - - private static bool TrySetHeaderField(HeaderFields fields, SpecField field, List issues) - { - switch (field.Key) - { - case "namespace": - fields.Namespace = field; - return true; - case "type": - fields.Type = field; - return true; - case "contract": - fields.ContractSchema = field; - return true; - case "provenance": - fields.Provenance = field; - return true; - case "minimum-ce": - fields.MinimumCe = field; - return true; - case "architecture": - fields.Architecture = field; - return true; - case "thread": - fields.Thread = field; - return true; - case "ownership": - fields.Ownership = field; - return true; - default: - issues.Add(new SpecIssue(field.Line, "Unknown header key '" + field.Key + "'.", field.KeyColumn)); - return false; - } - } - - private static bool TryCreateContract(HeaderFields fields, Block header, List issues, - out SpecFileContract? contract) - { - contract = null; - if (fields.ContractSchema is null) return ValidateLegacyContractFields(fields, issues); - - var schema = fields.ContractSchema.Value; - if (!string.Equals(schema.Value, "ce77", StringComparison.Ordinal)) - { - issues.Add(new SpecIssue(schema.Line, - "'" + schema.Value + "' is not a valid Engine API contract: expected 'ce77'.", schema.ValueColumn)); - return false; - } - - return TryCreateCe77Contract(fields, header, issues, out contract); - } - - private static bool ValidateLegacyContractFields(HeaderFields fields, List issues) - { - var field = fields.FirstContractField; - if (field is null) return true; - - var value = field.Value; - issues.Add(new SpecIssue(value.Line, "Engine API contract fields require header 'contract: ce77'.", - value.KeyColumn)); - return false; - } - - private static bool TryCreateCe77Contract(HeaderFields fields, Block header, List issues, - out SpecFileContract? contract) - { - contract = null; - if (!TryRequireContractField(fields.Provenance, "provenance", header, issues, out var provenance) - || !TryRequireContractField(fields.MinimumCe, "minimum-ce", header, issues, out var minimumCe) - || !TryRequireContractField(fields.Architecture, "architecture", header, issues, out var architecture) - || !TryRequireContractField(fields.Thread, "thread", header, issues, out var thread) - || !TryRequireContractField(fields.Ownership, "ownership", header, issues, out var ownership)) - return false; - - if (!ValidateContractValues(provenance, minimumCe, architecture, thread, ownership, issues)) return false; - - contract = new SpecFileContract(provenance.Value, minimumCe.Value, architecture.Value, thread.Value, - ownership.Value); - return true; - } - - private static bool ValidateContractValues(SpecField provenance, SpecField minimumCe, SpecField architecture, - SpecField thread, SpecField ownership, List issues) - { - // Do not short-circuit: one malformed evidence header must report every independently actionable value on the - // AdditionalText. Otherwise fixing the first field would merely reveal the next one on a subsequent build. - var isValid = TryValidateProvenance(provenance, issues); - if (!TryValidateVersion(minimumCe, issues)) isValid = false; - if (!TryValidateArchitecture(architecture, issues)) isValid = false; - if (!TryValidateThread(thread, issues)) isValid = false; - if (!TryValidateOwnership(ownership, issues)) isValid = false; - return isValid; - } - - private static bool TryValidateProvenance(SpecField field, List issues) - { - if (IsValidProvenance(field.Value)) return true; - - issues.Add(new SpecIssue(field.Line, - "'" + field.Value + "' is not a valid provenance: use a proof status followed by ': '.", - field.ValueColumn)); - return false; - } - - private static bool TryValidateVersion(SpecField field, List issues) - { - if (IsFourPartVersion(field.Value)) return true; - - issues.Add(new SpecIssue(field.Line, - "'" + field.Value + "' is not a valid minimum CE version: expected four decimal parts.", - field.ValueColumn)); - return false; - } - - private static bool TryValidateArchitecture(SpecField field, List issues) - { - if (string.Equals(field.Value, "x64", StringComparison.Ordinal)) return true; - - issues.Add(new SpecIssue(field.Line, - "'" + field.Value + "' is not a supported Engine API architecture: expected 'x64'.", field.ValueColumn)); - return false; - } - - private static bool TryValidateThread(SpecField field, List issues) - { - if (IsThreadAffinity(field.Value)) return true; - - issues.Add(new SpecIssue(field.Line, - "'" + field.Value + "' is not a valid thread contract: expected 'any', 'main' or 'unknown'.", - field.ValueColumn)); - return false; - } - - private static bool TryValidateOwnership(SpecField field, List issues) - { - if (IsOwnership(field.Value)) return true; - - issues.Add(new SpecIssue(field.Line, - "'" + field.Value + "' is not a valid ownership contract: expected 'none', 'borrowed' or 'owned'.", - field.ValueColumn)); - return false; - } - - private static bool TryRequireContractField(SpecField? field, string key, Block header, List issues, - out SpecField value) - { - value = field.GetValueOrDefault(); - if (field is not null && value.Value.Length > 0) return true; - - var line = field?.Line ?? header.StartLine; - var column = field?.ValueColumn ?? header.StartColumn; - issues.Add(new SpecIssue(line, - "A 'contract: ce77' header is missing required key '" + key + "', or its value is empty.", column)); - return false; - } - - private static bool IsValidProvenance(string value) - { - var colon = value.IndexOf(':'); - if (colon <= 0 || colon == value.Length - 1 || value[(colon + 1)..].Trim().Length == 0) return false; - - var status = value[..colon]; - return string.Equals(status, "ExactBinary", StringComparison.Ordinal) - || string.Equals(status, "ExactInstalledFile", StringComparison.Ordinal) - || string.Equals(status, "PinnedUpstream", StringComparison.Ordinal) - || string.Equals(status, "ObservedLive", StringComparison.Ordinal) - || string.Equals(status, "Inferred", StringComparison.Ordinal) - || string.Equals(status, "Unknown", StringComparison.Ordinal); - } - - private static bool IsFourPartVersion(string value) - { - var parts = 1; - var digitsInPart = 0; - foreach (var character in value) - { - if (character == '.') - { - if (digitsInPart == 0 || parts == 4) return false; - - parts++; - digitsInPart = 0; - continue; - } - - if (character < '0' || character > '9') return false; - - digitsInPart++; - } - - return parts == 4 && digitsInPart > 0; - } - - private static bool IsThreadAffinity(string value) - { - return string.Equals(value, "any", StringComparison.Ordinal) - || string.Equals(value, "main", StringComparison.Ordinal) - || string.Equals(value, "unknown", StringComparison.Ordinal); - } - - private static bool IsOwnership(string value) - { - return string.Equals(value, "none", StringComparison.Ordinal) - || string.Equals(value, "borrowed", StringComparison.Ordinal) - || string.Equals(value, "owned", StringComparison.Ordinal); - } - - private static bool IsNilSemantics(string value) - { - return string.Equals(value, "none", StringComparison.Ordinal) - || string.Equals(value, "absence", StringComparison.Ordinal) - || string.Equals(value, "expected-failure", StringComparison.Ordinal) - || string.Equals(value, "lua-error", StringComparison.Ordinal); - } - - private static SpecCallModel? ParseEntry(Block block, List issues, SpecFileContract? fileContract) - { - if (block.Malformed) - { - issues.Add(new SpecIssue(block.StartLine, - "The entry contains a malformed line; the whole entry was skipped.", block.StartColumn)); - return null; - } - - if (!ReadEntryFields(block, issues, out var fields)) return null; - - if (!ValidateRequiredText(fields, block.StartLine, fileContract is not null, issues, out var isTry, - out var isThrowing)) - return null; - - if (!ValidateResultShape(fields, isTry, isThrowing, block.StartLine, issues)) return null; - - var arguments = ParseArguments(fields.ArgTokens, issues); - if (arguments is null) return null; - - var fixedArguments = ParseFixedArguments(fields.FixedTokens, issues); - if (fixedArguments is null) return null; - arguments.AddRange(fixedArguments); - - var results = ParseResults(fields.ResultTokens, issues); - if (results is null) return null; - - if (!TryParseReturnKind(fields, isThrowing, block.StartLine, issues, out var returnKind, - out var returnIsNullable)) return null; - - var escapedMethod = SpecIdentifiers.Escape(fields.Method!); - LuaGlobalCallModel call = new( - fields.Global!, - LuaGlobalCallModel.CacheFieldFor(fields.Global!), - "public static", - escapedMethod, - string.Empty, - new EquatableArray([.. arguments]), - isTry ? LuaCallForm.Try : LuaCallForm.Throwing, - new EquatableArray([.. results]), - returnKind, - returnIsNullable); - - if (!ValidateParameterAndLocalIdentities(arguments, results, call, block.StartLine, issues)) return null; - - return CreateSpecCallModel(block, fields, call, fileContract); - } - - private static SpecCallModel CreateSpecCallModel(Block block, EntryFields fields, LuaGlobalCallModel call, - SpecFileContract? fileContract) - { - var contract = fileContract is null - ? null - : new SpecContract( - fileContract.Provenance, - fileContract.MinimumCheatEngineVersion, - fileContract.Architecture, - fileContract.ThreadAffinity, - fileContract.Ownership, - fields.NilSemantics!); - - return new SpecCallModel(block.StartLine, fields.MethodLine, fields.MethodColumn, fields.GlobalLine, - fields.GlobalColumn, fields.Doc!, call, contract); - } - - private static bool ReadEntryFields(Block block, List issues, out EntryFields fields) - { - fields = new EntryFields(); - HashSet singular = new(StringComparer.Ordinal); - var ok = true; - - foreach (var field in block.Fields) - if (!TrySetEntryField(fields, singular, field, issues)) - ok = false; - - return ok; - } - - private static bool TrySetEntryField(EntryFields fields, HashSet singular, SpecField field, - List issues) - { - switch (field.Key) - { - case "global": return TrySetGlobal(fields, singular, field, issues); - case "method": return TrySetMethod(fields, singular, field, issues); - case "form": return TrySetForm(fields, singular, field, issues); - case "doc": return TrySetDoc(fields, singular, field, issues); - case "nil": return TrySetNil(fields, singular, field, issues); - case "return": return TrySetReturn(fields, singular, field, issues); - case "arg": - fields.ArgTokens.Add((field.Line, field.ValueColumn, field.Value)); - return true; - case "fixed": - fields.FixedTokens.Add((field.Line, field.ValueColumn, field.Value)); - return true; - case "result": - fields.ResultTokens.Add((field.Line, field.ValueColumn, field.Value)); - return true; - default: - issues.Add(new SpecIssue(field.Line, "Unknown entry key '" + field.Key + "'.", field.KeyColumn)); - return false; - } - } - - private static bool TrySetGlobal(EntryFields fields, HashSet singular, SpecField field, - List issues) - { - fields.Global = field.Value; - fields.GlobalLine = field.Line; - fields.GlobalColumn = field.ValueColumn; - return RequireOnce(singular, "global", field.Line, field.KeyColumn, issues); - } - - private static bool TrySetMethod(EntryFields fields, HashSet singular, SpecField field, - List issues) - { - fields.Method = field.Value; - fields.MethodLine = field.Line; - fields.MethodColumn = field.ValueColumn; - return RequireOnce(singular, "method", field.Line, field.KeyColumn, issues); - } - - private static bool TrySetForm(EntryFields fields, HashSet singular, SpecField field, - List issues) - { - fields.Form = field.Value; - fields.FormLine = field.Line; - fields.FormColumn = field.ValueColumn; - return RequireOnce(singular, "form", field.Line, field.KeyColumn, issues); - } - - private static bool TrySetDoc(EntryFields fields, HashSet singular, SpecField field, List issues) - { - fields.Doc = field.Value; - return RequireOnce(singular, "doc", field.Line, field.KeyColumn, issues); - } - - private static bool TrySetNil(EntryFields fields, HashSet singular, SpecField field, List issues) - { - fields.NilSemantics = field.Value; - fields.NilLine = field.Line; - fields.NilColumn = field.ValueColumn; - return RequireOnce(singular, "nil", field.Line, field.KeyColumn, issues); - } - - private static bool TrySetReturn(EntryFields fields, HashSet singular, SpecField field, - List issues) - { - fields.ReturnToken = field.Value; - fields.ReturnLine = field.Line; - fields.ReturnColumn = field.ValueColumn; - fields.SawReturn = true; - return RequireOnce(singular, "return", field.Line, field.KeyColumn, issues); - } - - private static bool ValidateRequiredText(EntryFields fields, int startLine, bool requiresCe77Contract, - List issues, out bool isTry, out bool isThrowing) - { - isTry = false; - isThrowing = false; - - if (!ValidateRequiredPresence(fields, startLine, issues) - || !ValidateNilContract(fields, startLine, requiresCe77Contract, issues)) - return false; - - if (!LuaNames.IsValidName(fields.Global)) - { - issues.Add(new SpecIssue(fields.GlobalLine, "'" + fields.Global + "' is not a valid Lua global name.", - fields.GlobalColumn)); - return false; - } - - if (!SpecIdentifiers.IsValidIdentifier(fields.Method)) - { - issues.Add(new SpecIssue(fields.MethodLine, "'" + fields.Method + "' is not a valid C# method name.", - fields.MethodColumn)); - return false; - } - - isTry = string.Equals(fields.Form, "try", StringComparison.Ordinal); - isThrowing = string.Equals(fields.Form, "throwing", StringComparison.Ordinal); - if (!isTry && !isThrowing) - { - issues.Add(new SpecIssue(fields.FormLine, - "'" + fields.Form + "' is not a valid form: expected 'try' or 'throwing'.", fields.FormColumn)); - return false; - } - - return true; - } - - private static bool ValidateResultShape(EntryFields fields, bool isTry, bool isThrowing, int startLine, - List issues) - { - if (isThrowing && fields.ResultTokens.Count > 0) - { - issues.Add(new SpecIssue(startLine, - "A 'throwing' entry must not declare 'result' (its value, if any, is 'return').")); - return false; - } - - if (isTry && fields.SawReturn) - { - issues.Add(new SpecIssue(startLine, "A 'try' entry must not declare 'return' (its values are 'result').")); - return false; - } - - if (isTry && fields.ResultTokens.Count == 0) - { - issues.Add(new SpecIssue(startLine, "A 'try' entry needs at least one 'result'.")); - return false; - } - - return true; - } - - private static bool TryParseReturnKind( - EntryFields fields, - bool isThrowing, - int startLine, - List issues, - out LuaValueKind? returnKind, - out bool returnIsNullable) - { - returnKind = null; - returnIsNullable = false; - if (!isThrowing || string.IsNullOrEmpty(fields.ReturnToken)) return true; - - if (!SpecValueKinds.TryParse(fields.ReturnToken!, out var kind, out var nullable)) - { - issues.Add(new SpecIssue(fields.ReturnLine, "'" + fields.ReturnToken + "' is not a valid return kind.", - fields.ReturnColumn)); - return false; - } - - if (!LuaValueKinds.CanBeResult(kind)) - { - issues.Add(new SpecIssue(fields.ReturnLine, - "'" + fields.ReturnToken + - "' cannot be a return type: the span would dangle once the stack is restored.", fields.ReturnColumn)); - return false; - } - - returnKind = kind; - returnIsNullable = nullable; - return true; - } - - private static List? ParseArguments(List<(int Line, int Column, string Value)> tokens, - List issues) - { - List arguments = new(tokens.Count); - foreach (var (line, column, value) in tokens) - { - if (!TryParseNamedValue(value, out var name, out var kindToken) - || !SpecIdentifiers.IsValidIdentifier(name) - || !SpecValueKinds.TryParse(kindToken, out var kind, out var nullable)) - { - issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' argument.", column)); - return null; - } - - arguments.Add(new LuaArgumentModel(SpecIdentifiers.Escape(name), kind, nullable)); - } - - return arguments; - } - - // A fixed argument has the narrow, host-facing grammar 'kind:value'. It is pushed in call order but deliberately - // omitted from the generated C# signature. Only boolean literals are needed by the curated CE surface today; keep - // that vocabulary explicit rather than accepting arbitrary C# expressions in a repository text file. - private static List? ParseFixedArguments(List<(int Line, int Column, string Value)> tokens, - List issues) - { - List arguments = new(tokens.Count); - foreach (var (line, column, value) in tokens) - { - if (!TryParseNamedValue(value, out var kindToken, out var literal) - || !string.Equals(kindToken, "boolean", StringComparison.Ordinal) - || !(string.Equals(literal, "true", StringComparison.Ordinal) - || string.Equals(literal, "false", StringComparison.Ordinal))) - { - issues.Add(new SpecIssue(line, - "'" + value + "' is not a valid fixed argument: expected 'boolean:true' or 'boolean:false'.", - column)); - return null; - } - - arguments.Add(new LuaArgumentModel(literal, LuaValueKind.Boolean, false, FixedValue: literal)); - } - - return arguments; - } - - private static List? ParseResults(List<(int Line, int Column, string Value)> tokens, - List issues) - { - List results = new(tokens.Count); - foreach (var (line, column, value) in tokens) - { - if (!TryParseNamedValue(value, out var name, out var kindToken) - || !SpecIdentifiers.IsValidIdentifier(name) - || !SpecValueKinds.TryParse(kindToken, out var kind, out var nullable)) - { - issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' result.", column)); - return null; - } - - if (!LuaValueKinds.CanBeResult(kind)) - { - issues.Add(new SpecIssue(line, - "'" + kindToken + "' cannot be a result: the span would dangle once the stack is restored.", - column)); - return null; - } - - results.Add(LuaResultModel.Value(kind, SpecIdentifiers.Escape(name), nullable)); - } - - return results; - } - - // "name:kind" (or "name:string?"): split on the FIRST colon, so the '?' of a nullable string kind is part of - // the kind token, not mistaken for another separator. - private static bool TryParseNamedValue(string raw, out string name, out string kind) - { - var colon = raw.IndexOf(':'); - if (colon <= 0 || colon == raw.Length - 1) - { - name = string.Empty; - kind = string.Empty; - return false; - } - - name = raw[..colon].Trim(); - kind = raw[(colon + 1)..].Trim(); - return name.Length > 0 && kind.Length > 0; - } - - private static bool RequireOnce(HashSet seen, string key, int line, int column, List issues) - { - if (seen.Add(key)) return true; - - issues.Add(new SpecIssue(line, "Duplicate entry key '" + key + "'.", column)); - return false; - } - - // A method name reused by more than one entry cannot be emitted (CS0111): every entry using it is dropped, one - // issue per line, mirroring CheatEngine.SDK.SourceGenerators.LuaBindings' duplicate-Lua-name rule (both members dropped). - private static List DropDuplicateMethodNames(List entries, - List issues) - { - Dictionary> linesByMethod = new(StringComparer.Ordinal); - foreach (var call in entries) - { - var name = call.Call.MethodName; - if (!linesByMethod.TryGetValue(name, out var lines)) - { - lines = []; - linesByMethod.Add(name, lines); - } - - lines.Add(call.Line); - } - - List result = new(entries.Count); - foreach (var call in entries) - if (linesByMethod[call.Call.MethodName].Count == 1) - result.Add(call); - - foreach (var group in linesByMethod) - { - if (group.Value.Count <= 1) continue; - - foreach (var line in group.Value) - issues.Add(new SpecIssue(line, - "Duplicate method name '" + group.Key + "': every entry using it was dropped.")); - } - - return result; - } - - private static bool ValidateParameterAndLocalIdentities( - List arguments, - List results, - LuaGlobalCallModel call, - int line, - List issues) - { - Dictionary parameters = new(StringComparer.Ordinal); - foreach (var argument in arguments) - { - if (argument.IsFixed) continue; - - if (parameters.ContainsKey(argument.Name)) - { - issues.Add(new SpecIssue(line, - "Generated parameter '" + argument.Name + "' is declared more than once in this entry.")); - return false; - } - - parameters.Add(argument.Name, 0); - } - - foreach (var result in results) - { - if (parameters.ContainsKey(result.Name)) - { - issues.Add(new SpecIssue(line, - "Generated parameter '" + result.Name + "' is declared more than once in this entry.")); - return false; - } - - parameters.Add(result.Name, 0); - } - - foreach (var name in parameters.Keys) - if (IsReservedBodyLocal(name, call)) - { - issues.Add(new SpecIssue(line, - "Generated parameter '" + name + "' conflicts with a reserved local in the emitted wrapper.")); - return false; - } - - return true; - } - - private static bool ValidateRequiredPresence(EntryFields fields, int startLine, List issues) - { - if (string.IsNullOrEmpty(fields.Global)) return ReportMissingEntryKey("global", startLine, issues); - if (string.IsNullOrEmpty(fields.Method)) return ReportMissingEntryKey("method", startLine, issues); - if (string.IsNullOrEmpty(fields.Form)) return ReportMissingEntryKey("form", startLine, issues); - if (string.IsNullOrEmpty(fields.Doc)) return ReportMissingEntryKey("doc", startLine, issues); - - return true; - } - - private static bool ReportMissingEntryKey(string key, int startLine, List issues) - { - issues.Add(new SpecIssue(startLine, "The entry is missing required key '" + key + "'.")); - return false; - } - - private static bool ValidateNilContract(EntryFields fields, int startLine, bool requiresCe77Contract, - List issues) - { - if (requiresCe77Contract && string.IsNullOrEmpty(fields.NilSemantics)) - { - issues.Add(new SpecIssue(startLine, "A 'contract: ce77' entry is missing required key 'nil'.")); - return false; - } - - if (!requiresCe77Contract && fields.NilSemantics is not null) - { - issues.Add(new SpecIssue(fields.NilLine, - "Entry key 'nil' requires header 'contract: ce77'.", fields.NilColumn)); - return false; - } - - if (!requiresCe77Contract || IsNilSemantics(fields.NilSemantics!)) return true; - - issues.Add(new SpecIssue(fields.NilLine, - "'" + fields.NilSemantics + - "' is not a valid nil contract: expected 'none', 'absence', 'expected-failure' or 'lua-error'.", - fields.NilColumn)); - return false; - } - - private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) - { - if (string.Equals(name, "__L", StringComparison.Ordinal) - || string.Equals(name, "__operation", StringComparison.Ordinal) - || string.Equals(name, "__top", StringComparison.Ordinal) - || string.Equals(name, "__ok", StringComparison.Ordinal) - || string.Equals(name, "__status", StringComparison.Ordinal) - || string.Equals(name, "__result", StringComparison.Ordinal)) - return true; - - if (!UsesAddressFacade(call)) return false; - - if (string.Equals(name, "__engineApiSucceeded", StringComparison.Ordinal) - || string.Equals(name, "__engineApiRawResult", StringComparison.Ordinal)) - return true; - - if (call.Form != LuaCallForm.Try) return false; - - for (var i = 0; i < call.Results.Length; i++) - if (call.Results[i].Kind == LuaValueKind.Address - && string.Equals(name, RawResultName(i), StringComparison.Ordinal)) - return true; - - return false; - } - - private static List DropGeneratedMemberCollisions(List entries, - List issues) - { - Dictionary> owners = new(StringComparer.Ordinal); - foreach (var entry in entries) - { - AddGeneratedMemberOwner(owners, entry.Call.MethodName, entry); - if (UsesAddressFacade(entry.Call)) - AddGeneratedMemberOwner(owners, CoreMethodName(entry.Call.MethodName), entry); - } - - HashSet invalid = []; - foreach (var pair in owners) - { - if (pair.Value.Count < 2) continue; - - foreach (var entry in pair.Value) - { - invalid.Add(entry); - issues.Add(new SpecIssue(entry.Line, - "Generated member '" + pair.Key + "' conflicts with another member emitted from this spec file.")); - } - } - - if (invalid.Count == 0) return entries; - - List valid = new(entries.Count - invalid.Count); - foreach (var entry in entries) - if (!invalid.Contains(entry)) - valid.Add(entry); - - return valid; - } - - private static List DropCacheMemberCollisions(List entries, - List issues) - { - HashSet cacheFields = new(StringComparer.Ordinal); - foreach (var entry in entries) cacheFields.Add(LuaGlobalCallModel.CacheFieldFor(entry.Call.GlobalName)); - - List valid = new(entries.Count); - foreach (var entry in entries) - { - var conflicts = cacheFields.Contains(entry.Call.MethodName) - || (UsesAddressFacade(entry.Call) && - cacheFields.Contains(CoreMethodName(entry.Call.MethodName))); - if (!conflicts) - { - valid.Add(entry); - continue; - } - - issues.Add(new SpecIssue(entry.Line, - "Generated method '" + entry.Call.MethodName + "' conflicts with a generated Lua-global cache field.")); - } - - return valid; - } - - private static List DropTypeMemberCollisions(List entries, string typeName, - List issues) - { - if (typeName.Length == 0) return entries; - - List valid = new(entries.Count); - foreach (var entry in entries) - { - var conflicts = string.Equals(entry.Call.MethodName, typeName, StringComparison.Ordinal) - || (UsesAddressFacade(entry.Call) - && string.Equals(CoreMethodName(entry.Call.MethodName), typeName, - StringComparison.Ordinal)); - if (!conflicts) - { - valid.Add(entry); - continue; - } - - issues.Add(new SpecIssue(entry.Line, - "Generated method '" + entry.Call.MethodName + "' conflicts with its containing type '" + typeName + - "'.")); - } - - return valid; - } - - private static void AddGeneratedMemberOwner(Dictionary> owners, string name, - SpecCallModel entry) - { - if (!owners.TryGetValue(name, out var entries)) - { - entries = []; - owners.Add(name, entries); - } - - entries.Add(entry); - } - - private static bool UsesAddressFacade(LuaGlobalCallModel call) - { - foreach (var argument in call.Arguments) - if (argument.Kind == LuaValueKind.Address) - return true; - - foreach (var result in call.Results) - if (result.Kind == LuaValueKind.Address) - return true; - - return call.ReturnKind == LuaValueKind.Address; - } - - private static string CoreMethodName(string methodName) - { - return "__" + (methodName[0] == '@' ? methodName[1..] : methodName) + "Raw"; - } - - private static string RawResultName(int index) - { - return "__engineApiRawResult" + index.ToString(CultureInfo.InvariantCulture); - } - - // One "key: value" line. A line without a colon (or an empty key) marks the whole block Malformed: the block is - // still collected (so the caller can report one issue at its start line) but ParseEntry/ParseHeader never look - // at a malformed block's fields. - private sealed class Block - { - public readonly List Fields = []; - public bool Malformed; - public int StartColumn; - public int StartLine; - - public bool IsEmpty => Fields.Count == 0 && !Malformed; - } - - // Header fields stay as source-positioned values until the ce77 contract has been validated, so every grammar - // diagnostic points at the additional-file key or value that needs correction. - private sealed class HeaderFields - { - public SpecField? Architecture; - public SpecField? ContractSchema; - public SpecField? MinimumCe; - public SpecField? Namespace; - public SpecField? Ownership; - public SpecField? Provenance; - public SpecField? Thread; - public SpecField? Type; - - public SpecField? FirstContractField => Provenance ?? MinimumCe ?? Architecture ?? Thread ?? Ownership; - } - - // The raw fields of one entry block, read once by ReadEntryFields and consumed by the validators below. - private sealed class EntryFields - { - public readonly List<(int Line, int Column, string Value)> ArgTokens = []; - public readonly List<(int Line, int Column, string Value)> FixedTokens = []; - public readonly List<(int Line, int Column, string Value)> ResultTokens = []; - public string? Doc; - public string? Form; - public int FormColumn; - public int FormLine; - public string? Global; - public int GlobalColumn; - public int GlobalLine; - public string? Method; - public int MethodColumn; - public int MethodLine; - public int NilColumn; - public int NilLine; - public string? NilSemantics; - public int ReturnColumn; - public int ReturnLine; - public string? ReturnToken; - public bool SawReturn; - } - - private readonly record struct SpecField(int Line, int KeyColumn, int ValueColumn, string Key, string Value); + /// The recognised spec-file extension (case-insensitive), the AdditionalTextsProvider filter. + public const string FileNameSuffix = ".cheatengine-sdk-api.txt"; + + /// Whether ends with . + public static bool IsSpecFile(string? path) + { + return path is not null && path.EndsWith(FileNameSuffix, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Parses the text of one spec file. Never throws; is empty (assigned later + /// by ). + /// + public static SpecFileModel Parse(string filePath, string? text) + { + List issues = []; + List blocks = SplitBlocks(text ?? string.Empty, issues); + + bool headerOk = ParseHeader( + blocks, + issues, + out string ns, + out string typeName, + out int typeLine, + out int typeColumn, + out SpecFileContract? fileContract); + + List parsed = []; + if (headerOk) + { + for (int i = 1; i < blocks.Count; i++) + { + SpecCallModel? call = ParseEntry(blocks[i], issues, fileContract); + if (call is not null) + { + parsed.Add(call); + } + } + } + + List calls = DropDuplicateMethodNames(parsed, issues); + calls = DropGeneratedMemberCollisions(calls, issues); + calls = DropCacheMemberCollisions(calls, issues); + calls = DropTypeMemberCollisions(calls, headerOk ? typeName : string.Empty, issues); + calls.Sort(static (left, right) => string.CompareOrdinal(left.Call.MethodName, right.Call.MethodName)); + + List cachedGlobals = CollectCachedGlobals(calls); + + return new SpecFileModel( + filePath, + headerOk ? ns : string.Empty, + headerOk ? typeName : string.Empty, + typeLine, + typeColumn, + headerOk ? fileContract : null, + string.Empty, + new EquatableArray([.. cachedGlobals]), + new EquatableArray([.. calls]), + new EquatableArray([.. issues]), + false); + } + + private static List CollectCachedGlobals(List calls) + { + List cachedGlobals = []; + HashSet seenGlobals = new(StringComparer.Ordinal); + foreach (SpecCallModel call in calls) + { + if (seenGlobals.Add(call.Call.GlobalName)) + { + cachedGlobals.Add(call.Call.GlobalName); + } + } + + cachedGlobals.Sort(StringComparer.Ordinal); + return cachedGlobals; + } + + // Blank lines (whitespace-only, after comment lines are dropped) separate blocks; '#' lines are comments and + // never affect block boundaries, wherever they appear. The first block is the header, every later one an entry. + private static List SplitBlocks(string text, List issues) + { + string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n'); + string[] lines = normalized.Split('\n'); + + List blocks = []; + Block current = new(); + for (int i = 0; i < lines.Length; i++) + { + AddLine(lines[i], i + 1, blocks, ref current, issues); + } + + if (!current.IsEmpty) + { + blocks.Add(current); + } + + return blocks; + } + + private static void AddLine(string rawLine, int lineNumber, List blocks, ref Block current, + List issues) + { + string trimmed = rawLine.Trim(); + if (trimmed.Length == 0) + { + if (!current.IsEmpty) + { + blocks.Add(current); + current = new Block(); + } + + return; + } + + if (trimmed[0] == '#') + { + return; + } + + int firstNonWhitespace = rawLine.Length - rawLine.TrimStart().Length; + if (current.StartLine == 0) + { + current.StartLine = lineNumber; + current.StartColumn = firstNonWhitespace + 1; + } + + int colon = trimmed.IndexOf(':'); + if (colon <= 0) + { + issues.Add(new SpecIssue(lineNumber, "Malformed line: expected 'key: value'.", firstNonWhitespace + 1)); + current.Malformed = true; + return; + } + + string key = trimmed[..colon].TrimEnd(); + string value = trimmed[(colon + 1)..].Trim(); + current.Fields.Add( + new SpecField(lineNumber, firstNonWhitespace + 1, firstNonWhitespace + colon + 3, key, value)); + } + + private static bool ParseHeader(List blocks, List issues, out string ns, out string typeName, + out int typeLine, out int typeColumn, out SpecFileContract? contract) + { + ns = string.Empty; + typeName = string.Empty; + typeLine = 1; + typeColumn = 1; + contract = null; + + if (blocks.Count == 0) + { + issues.Add(new SpecIssue(1, + "The spec file is empty: expected a header block with 'namespace' and 'type'.")); + return false; + } + + Block header = blocks[0]; + if (header.Malformed) + { + issues.Add(new SpecIssue(header.StartLine, "The header block contains a malformed line.", + header.StartColumn)); + return false; + } + + if (!ReadHeaderFields( + header, + issues, + out string? namespaceValue, + out string? typeValue, + out typeLine, + out typeColumn, + out contract)) + { + return false; + } + + if (!ValidateHeaderIdentity(header, namespaceValue, typeValue, typeLine, typeColumn, issues)) + { + return false; + } + + ns = namespaceValue!; + typeName = typeValue!; + return true; + } + + private static bool ValidateHeaderIdentity(Block header, string? namespaceValue, string? typeValue, int typeLine, + int typeColumn, List issues) + { + if (namespaceValue is null) + { + issues.Add(new SpecIssue(header.StartLine, + "The header is missing required key 'namespace' (use an empty value for the global namespace).", + header.StartColumn)); + return false; + } + + if (typeValue is null || typeValue.Length == 0) + { + issues.Add(new SpecIssue(header.StartLine, + "The header is missing required key 'type', or its value is empty.", header.StartColumn)); + return false; + } + + if (!SpecIdentifiers.IsValidNamespace(namespaceValue)) + { + issues.Add(new SpecIssue(header.StartLine, "'" + namespaceValue + "' is not a valid namespace.", + header.StartColumn)); + return false; + } + + if (SpecIdentifiers.IsValidTypeIdentifier(typeValue)) + { + return true; + } + + issues.Add(new SpecIssue(typeLine, "'" + typeValue + "' is not a valid type name.", typeColumn)); + return false; + } + + private static bool ReadHeaderFields(Block header, List issues, out string? namespaceValue, + out string? typeValue, out int typeLine, out int typeColumn, out SpecFileContract? contract) + { + HeaderFields fields = new(); + HashSet seen = new(StringComparer.Ordinal); + bool ok = true; + foreach (SpecField field in header.Fields) + { + if (!seen.Add(field.Key)) + { + issues.Add(new SpecIssue(field.Line, "Duplicate header key '" + field.Key + "'.", field.KeyColumn)); + ok = false; + continue; + } + + if (!TrySetHeaderField(fields, field, issues)) + { + ok = false; + } + } + + namespaceValue = fields.Namespace?.Value; + typeValue = fields.Type?.Value; + typeLine = fields.Type?.Line ?? header.StartLine; + typeColumn = fields.Type?.ValueColumn ?? header.StartColumn; + contract = null; + return ok && TryCreateContract(fields, header, issues, out contract); + } + + private static bool TrySetHeaderField(HeaderFields fields, SpecField field, List issues) + { + switch (field.Key) + { + case "namespace": + fields.Namespace = field; + return true; + case "type": + fields.Type = field; + return true; + case "contract": + fields.ContractSchema = field; + return true; + case "provenance": + fields.Provenance = field; + return true; + case "minimum-ce": + fields.MinimumCe = field; + return true; + case "architecture": + fields.Architecture = field; + return true; + case "thread": + fields.Thread = field; + return true; + case "ownership": + fields.Ownership = field; + return true; + default: + issues.Add(new SpecIssue(field.Line, "Unknown header key '" + field.Key + "'.", field.KeyColumn)); + return false; + } + } + + private static bool TryCreateContract(HeaderFields fields, Block header, List issues, + out SpecFileContract? contract) + { + contract = null; + if (fields.ContractSchema is null) + { + return ValidateLegacyContractFields(fields, issues); + } + + SpecField schema = fields.ContractSchema.Value; + if (!string.Equals(schema.Value, "ce77", StringComparison.Ordinal)) + { + issues.Add(new SpecIssue(schema.Line, + "'" + schema.Value + "' is not a valid Engine API contract: expected 'ce77'.", schema.ValueColumn)); + return false; + } + + return TryCreateCe77Contract(fields, header, issues, out contract); + } + + private static bool ValidateLegacyContractFields(HeaderFields fields, List issues) + { + SpecField? field = fields.FirstContractField; + if (field is null) + { + return true; + } + + SpecField value = field.Value; + issues.Add(new SpecIssue(value.Line, "Engine API contract fields require header 'contract: ce77'.", + value.KeyColumn)); + return false; + } + + private static bool TryCreateCe77Contract(HeaderFields fields, Block header, List issues, + out SpecFileContract? contract) + { + contract = null; + if (!TryRequireContractField(fields.Provenance, "provenance", header, issues, out SpecField provenance) + || !TryRequireContractField(fields.MinimumCe, "minimum-ce", header, issues, out SpecField minimumCe) + || !TryRequireContractField(fields.Architecture, "architecture", header, issues, out SpecField architecture) + || !TryRequireContractField(fields.Thread, "thread", header, issues, out SpecField thread) + || !TryRequireContractField(fields.Ownership, "ownership", header, issues, out SpecField ownership)) + { + return false; + } + + if (!ValidateContractValues(provenance, minimumCe, architecture, thread, ownership, issues)) + { + return false; + } + + contract = new SpecFileContract(provenance.Value, minimumCe.Value, architecture.Value, thread.Value, + ownership.Value); + return true; + } + + private static bool ValidateContractValues(SpecField provenance, SpecField minimumCe, SpecField architecture, + SpecField thread, SpecField ownership, List issues) + { + // Do not short-circuit: one malformed evidence header must report every independently actionable value on the + // AdditionalText. Otherwise fixing the first field would merely reveal the next one on a subsequent build. + bool isValid = TryValidateProvenance(provenance, issues); + if (!TryValidateVersion(minimumCe, issues)) + { + isValid = false; + } + + if (!TryValidateArchitecture(architecture, issues)) + { + isValid = false; + } + + if (!TryValidateThread(thread, issues)) + { + isValid = false; + } + + if (!TryValidateOwnership(ownership, issues)) + { + isValid = false; + } + + return isValid; + } + + private static bool TryValidateProvenance(SpecField field, List issues) + { + if (IsValidProvenance(field.Value)) + { + return true; + } + + issues.Add(new SpecIssue(field.Line, + "'" + field.Value + "' is not a valid provenance: use a proof status followed by ': '.", + field.ValueColumn)); + return false; + } + + private static bool TryValidateVersion(SpecField field, List issues) + { + if (IsFourPartVersion(field.Value)) + { + return true; + } + + issues.Add(new SpecIssue(field.Line, + "'" + field.Value + "' is not a valid minimum CE version: expected four decimal parts.", + field.ValueColumn)); + return false; + } + + private static bool TryValidateArchitecture(SpecField field, List issues) + { + if (string.Equals(field.Value, "x64", StringComparison.Ordinal)) + { + return true; + } + + issues.Add(new SpecIssue(field.Line, + "'" + field.Value + "' is not a supported Engine API architecture: expected 'x64'.", field.ValueColumn)); + return false; + } + + private static bool TryValidateThread(SpecField field, List issues) + { + if (IsThreadAffinity(field.Value)) + { + return true; + } + + issues.Add(new SpecIssue(field.Line, + "'" + field.Value + "' is not a valid thread contract: expected 'any', 'main' or 'unknown'.", + field.ValueColumn)); + return false; + } + + private static bool TryValidateOwnership(SpecField field, List issues) + { + if (IsOwnership(field.Value)) + { + return true; + } + + issues.Add(new SpecIssue(field.Line, + "'" + field.Value + "' is not a valid ownership contract: expected 'none', 'borrowed' or 'owned'.", + field.ValueColumn)); + return false; + } + + private static bool TryRequireContractField(SpecField? field, string key, Block header, List issues, + out SpecField value) + { + value = field.GetValueOrDefault(); + if (field is not null && value.Value.Length > 0) + { + return true; + } + + int line = field?.Line ?? header.StartLine; + int column = field?.ValueColumn ?? header.StartColumn; + issues.Add(new SpecIssue(line, + "A 'contract: ce77' header is missing required key '" + key + "', or its value is empty.", column)); + return false; + } + + private static bool IsValidProvenance(string value) + { + int colon = value.IndexOf(':'); + if (colon <= 0 || colon == value.Length - 1 || value[(colon + 1)..].Trim().Length == 0) + { + return false; + } + + string status = value[..colon]; + return string.Equals(status, "ExactBinary", StringComparison.Ordinal) + || string.Equals(status, "ExactInstalledFile", StringComparison.Ordinal) + || string.Equals(status, "PinnedUpstream", StringComparison.Ordinal) + || string.Equals(status, "ObservedLive", StringComparison.Ordinal) + || string.Equals(status, "Inferred", StringComparison.Ordinal) + || string.Equals(status, "Unknown", StringComparison.Ordinal); + } + + private static bool IsFourPartVersion(string value) + { + int parts = 1; + int digitsInPart = 0; + foreach (char character in value) + { + if (character == '.') + { + if (digitsInPart == 0 || parts == 4) + { + return false; + } + + parts++; + digitsInPart = 0; + continue; + } + + if (character < '0' || character > '9') + { + return false; + } + + digitsInPart++; + } + + return parts == 4 && digitsInPart > 0; + } + + private static bool IsThreadAffinity(string value) + { + return string.Equals(value, "any", StringComparison.Ordinal) + || string.Equals(value, "main", StringComparison.Ordinal) + || string.Equals(value, "unknown", StringComparison.Ordinal); + } + + private static bool IsOwnership(string value) + { + return string.Equals(value, "none", StringComparison.Ordinal) + || string.Equals(value, "borrowed", StringComparison.Ordinal) + || string.Equals(value, "owned", StringComparison.Ordinal); + } + + private static bool IsNilSemantics(string value) + { + return string.Equals(value, "none", StringComparison.Ordinal) + || string.Equals(value, "absence", StringComparison.Ordinal) + || string.Equals(value, "expected-failure", StringComparison.Ordinal) + || string.Equals(value, "lua-error", StringComparison.Ordinal); + } + + private static SpecCallModel? ParseEntry(Block block, List issues, SpecFileContract? fileContract) + { + if (block.Malformed) + { + issues.Add(new SpecIssue(block.StartLine, + "The entry contains a malformed line; the whole entry was skipped.", block.StartColumn)); + return null; + } + + if (!ReadEntryFields(block, issues, out EntryFields fields)) + { + return null; + } + + if (!ValidateRequiredText(fields, block.StartLine, fileContract is not null, issues, out bool isTry, + out bool isThrowing)) + { + return null; + } + + if (!ValidateResultShape(fields, isTry, isThrowing, block.StartLine, issues)) + { + return null; + } + + List? arguments = ParseArguments(fields.ArgTokens, issues); + if (arguments is null) + { + return null; + } + + List? fixedArguments = ParseFixedArguments(fields.FixedTokens, issues); + if (fixedArguments is null) + { + return null; + } + + arguments.AddRange(fixedArguments); + + List? results = ParseResults(fields.ResultTokens, issues); + if (results is null) + { + return null; + } + + if (!TryParseReturnKind(fields, isThrowing, block.StartLine, issues, out LuaValueKind? returnKind, + out bool returnIsNullable)) + { + return null; + } + + LuaGlobalCallModel call = CreateLuaGlobalCall(fields, arguments, isTry, results, returnKind, returnIsNullable); + + if (!ValidateParameterAndLocalIdentities(arguments, results, call, block.StartLine, issues)) + { + return null; + } + + return CreateSpecCallModel(block, fields, call, fileContract); + } + + private static LuaGlobalCallModel CreateLuaGlobalCall(EntryFields fields, List arguments, + bool isTry, List results, LuaValueKind? returnKind, bool returnIsNullable) + { + string globalName = fields.Global!; + return new LuaGlobalCallModel( + globalName, + LuaGlobalCallModel.CacheFieldFor(globalName), + "public static", + SpecIdentifiers.Escape(fields.Method!), + string.Empty, + new EquatableArray([.. arguments]), + isTry ? LuaCallForm.Try : LuaCallForm.Throwing, + new EquatableArray([.. results]), + returnKind, + returnIsNullable); + } + + private static SpecCallModel CreateSpecCallModel(Block block, EntryFields fields, LuaGlobalCallModel call, + SpecFileContract? fileContract) + { + SpecContract? contract = fileContract is null + ? null + : new SpecContract( + fileContract.Provenance, + fileContract.MinimumCheatEngineVersion, + fileContract.Architecture, + fileContract.ThreadAffinity, + fileContract.Ownership, + fields.NilSemantics!); + + return new SpecCallModel(block.StartLine, fields.MethodLine, fields.MethodColumn, fields.GlobalLine, + fields.GlobalColumn, fields.Doc!, call, contract); + } + + private static bool ReadEntryFields(Block block, List issues, out EntryFields fields) + { + fields = new EntryFields(); + HashSet singular = new(StringComparer.Ordinal); + bool ok = true; + + foreach (SpecField field in block.Fields) + { + if (!TrySetEntryField(fields, singular, field, issues)) + { + ok = false; + } + } + + return ok; + } + + private static bool TrySetEntryField(EntryFields fields, HashSet singular, SpecField field, + List issues) + { + switch (field.Key) + { + case "global": return TrySetGlobal(fields, singular, field, issues); + case "method": return TrySetMethod(fields, singular, field, issues); + case "form": return TrySetForm(fields, singular, field, issues); + case "doc": return TrySetDoc(fields, singular, field, issues); + case "nil": return TrySetNil(fields, singular, field, issues); + case "return": return TrySetReturn(fields, singular, field, issues); + case "arg": + fields.ArgTokens.Add((field.Line, field.ValueColumn, field.Value)); + return true; + case "fixed": + fields.FixedTokens.Add((field.Line, field.ValueColumn, field.Value)); + return true; + case "result": + fields.ResultTokens.Add((field.Line, field.ValueColumn, field.Value)); + return true; + default: + issues.Add(new SpecIssue(field.Line, "Unknown entry key '" + field.Key + "'.", field.KeyColumn)); + return false; + } + } + + private static bool TrySetGlobal(EntryFields fields, HashSet singular, SpecField field, + List issues) + { + fields.Global = field.Value; + fields.GlobalLine = field.Line; + fields.GlobalColumn = field.ValueColumn; + return RequireOnce(singular, "global", field.Line, field.KeyColumn, issues); + } + + private static bool TrySetMethod(EntryFields fields, HashSet singular, SpecField field, + List issues) + { + fields.Method = field.Value; + fields.MethodLine = field.Line; + fields.MethodColumn = field.ValueColumn; + return RequireOnce(singular, "method", field.Line, field.KeyColumn, issues); + } + + private static bool TrySetForm(EntryFields fields, HashSet singular, SpecField field, + List issues) + { + fields.Form = field.Value; + fields.FormLine = field.Line; + fields.FormColumn = field.ValueColumn; + return RequireOnce(singular, "form", field.Line, field.KeyColumn, issues); + } + + private static bool TrySetDoc(EntryFields fields, HashSet singular, SpecField field, List issues) + { + fields.Doc = field.Value; + return RequireOnce(singular, "doc", field.Line, field.KeyColumn, issues); + } + + private static bool TrySetNil(EntryFields fields, HashSet singular, SpecField field, List issues) + { + fields.NilSemantics = field.Value; + fields.NilLine = field.Line; + fields.NilColumn = field.ValueColumn; + return RequireOnce(singular, "nil", field.Line, field.KeyColumn, issues); + } + + private static bool TrySetReturn(EntryFields fields, HashSet singular, SpecField field, + List issues) + { + fields.ReturnToken = field.Value; + fields.ReturnLine = field.Line; + fields.ReturnColumn = field.ValueColumn; + fields.SawReturn = true; + return RequireOnce(singular, "return", field.Line, field.KeyColumn, issues); + } + + private static bool ValidateRequiredText(EntryFields fields, int startLine, bool requiresCe77Contract, + List issues, out bool isTry, out bool isThrowing) + { + isTry = false; + isThrowing = false; + + if (!ValidateRequiredPresence(fields, startLine, issues) + || !ValidateNilContract(fields, startLine, requiresCe77Contract, issues)) + { + return false; + } + + if (!LuaNames.IsValidName(fields.Global)) + { + issues.Add(new SpecIssue(fields.GlobalLine, "'" + fields.Global + "' is not a valid Lua global name.", + fields.GlobalColumn)); + return false; + } + + if (!SpecIdentifiers.IsValidIdentifier(fields.Method)) + { + issues.Add(new SpecIssue(fields.MethodLine, "'" + fields.Method + "' is not a valid C# method name.", + fields.MethodColumn)); + return false; + } + + isTry = string.Equals(fields.Form, "try", StringComparison.Ordinal); + isThrowing = string.Equals(fields.Form, "throwing", StringComparison.Ordinal); + if (!isTry && !isThrowing) + { + issues.Add(new SpecIssue(fields.FormLine, + "'" + fields.Form + "' is not a valid form: expected 'try' or 'throwing'.", fields.FormColumn)); + return false; + } + + return true; + } + + private static bool ValidateResultShape(EntryFields fields, bool isTry, bool isThrowing, int startLine, + List issues) + { + if (isThrowing && fields.ResultTokens.Count > 0) + { + issues.Add(new SpecIssue(startLine, + "A 'throwing' entry must not declare 'result' (its value, if any, is 'return').")); + return false; + } + + if (isTry && fields.SawReturn) + { + issues.Add(new SpecIssue(startLine, "A 'try' entry must not declare 'return' (its values are 'result').")); + return false; + } + + if (isTry && fields.ResultTokens.Count == 0) + { + issues.Add(new SpecIssue(startLine, "A 'try' entry needs at least one 'result'.")); + return false; + } + + return true; + } + + private static bool TryParseReturnKind( + EntryFields fields, + bool isThrowing, + int startLine, + List issues, + out LuaValueKind? returnKind, + out bool returnIsNullable) + { + returnKind = null; + returnIsNullable = false; + if (!isThrowing || string.IsNullOrEmpty(fields.ReturnToken)) + { + return true; + } + + if (!SpecValueKinds.TryParse(fields.ReturnToken!, out LuaValueKind kind, out bool nullable)) + { + issues.Add(new SpecIssue(fields.ReturnLine, "'" + fields.ReturnToken + "' is not a valid return kind.", + fields.ReturnColumn)); + return false; + } + + if (!LuaValueKinds.CanBeResult(kind)) + { + issues.Add(new SpecIssue(fields.ReturnLine, + "'" + fields.ReturnToken + + "' cannot be a return type: the span would dangle once the stack is restored.", fields.ReturnColumn)); + return false; + } + + returnKind = kind; + returnIsNullable = nullable; + return true; + } + + private static List? ParseArguments(List<(int Line, int Column, string Value)> tokens, + List issues) + { + List arguments = new(tokens.Count); + foreach ((int line, int column, string value) in tokens) + { + if (!TryParseNamedValue(value, out string name, out string kindToken) + || !SpecIdentifiers.IsValidIdentifier(name) + || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + { + issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' argument.", column)); + return null; + } + + arguments.Add(new LuaArgumentModel(SpecIdentifiers.Escape(name), kind, nullable)); + } + + return arguments; + } + + // A fixed argument has the narrow, host-facing grammar 'kind:value'. It is pushed in call order but deliberately + // omitted from the generated C# signature. Only boolean literals are needed by the curated CE surface today; keep + // that vocabulary explicit rather than accepting arbitrary C# expressions in a repository text file. + private static List? ParseFixedArguments(List<(int Line, int Column, string Value)> tokens, + List issues) + { + List arguments = new(tokens.Count); + foreach ((int line, int column, string value) in tokens) + { + if (!TryParseNamedValue(value, out string kindToken, out string literal) + || !string.Equals(kindToken, "boolean", StringComparison.Ordinal) + || !(string.Equals(literal, "true", StringComparison.Ordinal) + || string.Equals(literal, "false", StringComparison.Ordinal))) + { + issues.Add(new SpecIssue(line, + "'" + value + "' is not a valid fixed argument: expected 'boolean:true' or 'boolean:false'.", + column)); + return null; + } + + arguments.Add(new LuaArgumentModel(literal, LuaValueKind.Boolean, false, FixedValue: literal)); + } + + return arguments; + } + + private static List? ParseResults(List<(int Line, int Column, string Value)> tokens, + List issues) + { + List results = new(tokens.Count); + foreach ((int line, int column, string value) in tokens) + { + if (!TryParseNamedValue(value, out string name, out string kindToken) + || !SpecIdentifiers.IsValidIdentifier(name) + || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + { + issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' result.", column)); + return null; + } + + if (!LuaValueKinds.CanBeResult(kind)) + { + issues.Add(new SpecIssue(line, + "'" + kindToken + "' cannot be a result: the span would dangle once the stack is restored.", + column)); + return null; + } + + results.Add(LuaResultModel.Value(kind, SpecIdentifiers.Escape(name), nullable)); + } + + return results; + } + + // "name:kind" (or "name:string?"): split on the FIRST colon, so the '?' of a nullable string kind is part of + // the kind token, not mistaken for another separator. + private static bool TryParseNamedValue(string raw, out string name, out string kind) + { + int colon = raw.IndexOf(':'); + if (colon <= 0 || colon == raw.Length - 1) + { + name = string.Empty; + kind = string.Empty; + return false; + } + + name = raw[..colon].Trim(); + kind = raw[(colon + 1)..].Trim(); + return name.Length > 0 && kind.Length > 0; + } + + private static bool RequireOnce(HashSet seen, string key, int line, int column, List issues) + { + if (seen.Add(key)) + { + return true; + } + + issues.Add(new SpecIssue(line, "Duplicate entry key '" + key + "'.", column)); + return false; + } + + // A method name reused by more than one entry cannot be emitted (CS0111): every entry using it is dropped, one + // issue per line, mirroring CheatEngine.SDK.SourceGenerators.LuaBindings' duplicate-Lua-name rule (both members dropped). + private static List DropDuplicateMethodNames(List entries, + List issues) + { + Dictionary> linesByMethod = new(StringComparer.Ordinal); + foreach (SpecCallModel call in entries) + { + string name = call.Call.MethodName; + if (!linesByMethod.TryGetValue(name, out List? lines)) + { + lines = []; + linesByMethod.Add(name, lines); + } + + lines.Add(call.Line); + } + + List result = new(entries.Count); + foreach (SpecCallModel call in entries) + { + if (linesByMethod[call.Call.MethodName].Count == 1) + { + result.Add(call); + } + } + + foreach (KeyValuePair> group in linesByMethod) + { + if (group.Value.Count <= 1) + { + continue; + } + + foreach (int line in group.Value) + { + issues.Add(new SpecIssue(line, + "Duplicate method name '" + group.Key + "': every entry using it was dropped.")); + } + } + + return result; + } + + private static bool ValidateParameterAndLocalIdentities( + List arguments, + List results, + LuaGlobalCallModel call, + int line, + List issues) + { + Dictionary parameters = new(StringComparer.Ordinal); + foreach (LuaArgumentModel argument in arguments) + { + if (argument.IsFixed) + { + continue; + } + + if (parameters.ContainsKey(argument.Name)) + { + issues.Add(new SpecIssue(line, + "Generated parameter '" + argument.Name + "' is declared more than once in this entry.")); + return false; + } + + parameters.Add(argument.Name, 0); + } + + foreach (LuaResultModel result in results) + { + if (parameters.ContainsKey(result.Name)) + { + issues.Add(new SpecIssue(line, + "Generated parameter '" + result.Name + "' is declared more than once in this entry.")); + return false; + } + + parameters.Add(result.Name, 0); + } + + foreach (string name in parameters.Keys) + { + if (IsReservedBodyLocal(name, call)) + { + issues.Add(new SpecIssue(line, + "Generated parameter '" + name + "' conflicts with a reserved local in the emitted wrapper.")); + return false; + } + } + + return true; + } + + private static bool ValidateRequiredPresence(EntryFields fields, int startLine, List issues) + { + if (string.IsNullOrEmpty(fields.Global)) + { + return ReportMissingEntryKey("global", startLine, issues); + } + + if (string.IsNullOrEmpty(fields.Method)) + { + return ReportMissingEntryKey("method", startLine, issues); + } + + if (string.IsNullOrEmpty(fields.Form)) + { + return ReportMissingEntryKey("form", startLine, issues); + } + + if (string.IsNullOrEmpty(fields.Doc)) + { + return ReportMissingEntryKey("doc", startLine, issues); + } + + return true; + } + + private static bool ReportMissingEntryKey(string key, int startLine, List issues) + { + issues.Add(new SpecIssue(startLine, "The entry is missing required key '" + key + "'.")); + return false; + } + + private static bool ValidateNilContract(EntryFields fields, int startLine, bool requiresCe77Contract, + List issues) + { + if (requiresCe77Contract && string.IsNullOrEmpty(fields.NilSemantics)) + { + issues.Add(new SpecIssue(startLine, "A 'contract: ce77' entry is missing required key 'nil'.")); + return false; + } + + if (!requiresCe77Contract && fields.NilSemantics is not null) + { + issues.Add(new SpecIssue(fields.NilLine, + "Entry key 'nil' requires header 'contract: ce77'.", fields.NilColumn)); + return false; + } + + if (!requiresCe77Contract || IsNilSemantics(fields.NilSemantics!)) + { + return true; + } + + issues.Add(new SpecIssue(fields.NilLine, + "'" + fields.NilSemantics + + "' is not a valid nil contract: expected 'none', 'absence', 'expected-failure' or 'lua-error'.", + fields.NilColumn)); + return false; + } + + private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) + { + if (string.Equals(name, "__L", StringComparison.Ordinal) + || string.Equals(name, "__operation", StringComparison.Ordinal) + || string.Equals(name, "__top", StringComparison.Ordinal) + || string.Equals(name, "__ok", StringComparison.Ordinal) + || string.Equals(name, "__status", StringComparison.Ordinal) + || string.Equals(name, "__result", StringComparison.Ordinal)) + { + return true; + } + + if (!UsesAddressFacade(call)) + { + return false; + } + + if (string.Equals(name, "__engineApiSucceeded", StringComparison.Ordinal) + || string.Equals(name, "__engineApiRawResult", StringComparison.Ordinal)) + { + return true; + } + + if (call.Form != LuaCallForm.Try) + { + return false; + } + + for (int i = 0; i < call.Results.Length; i++) + { + if (call.Results[i].Kind == LuaValueKind.Address + && string.Equals(name, RawResultName(i), StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static List DropGeneratedMemberCollisions(List entries, + List issues) + { + Dictionary> owners = new(StringComparer.Ordinal); + foreach (SpecCallModel entry in entries) + { + AddGeneratedMemberOwner(owners, entry.Call.MethodName, entry); + if (UsesAddressFacade(entry.Call)) + { + AddGeneratedMemberOwner(owners, CoreMethodName(entry.Call.MethodName), entry); + } + } + + HashSet invalid = []; + foreach (KeyValuePair> pair in owners) + { + if (pair.Value.Count < 2) + { + continue; + } + + foreach (SpecCallModel entry in pair.Value) + { + invalid.Add(entry); + issues.Add(new SpecIssue(entry.Line, + "Generated member '" + pair.Key + "' conflicts with another member emitted from this spec file.")); + } + } + + if (invalid.Count == 0) + { + return entries; + } + + List valid = new(entries.Count - invalid.Count); + foreach (SpecCallModel entry in entries) + { + if (!invalid.Contains(entry)) + { + valid.Add(entry); + } + } + + return valid; + } + + private static List DropCacheMemberCollisions(List entries, + List issues) + { + HashSet cacheFields = new(StringComparer.Ordinal); + foreach (SpecCallModel entry in entries) + { + cacheFields.Add(LuaGlobalCallModel.CacheFieldFor(entry.Call.GlobalName)); + } + + List valid = new(entries.Count); + foreach (SpecCallModel entry in entries) + { + bool conflicts = cacheFields.Contains(entry.Call.MethodName) + || (UsesAddressFacade(entry.Call) && + cacheFields.Contains(CoreMethodName(entry.Call.MethodName))); + if (!conflicts) + { + valid.Add(entry); + continue; + } + + issues.Add(new SpecIssue(entry.Line, + "Generated method '" + entry.Call.MethodName + "' conflicts with a generated Lua-global cache field.")); + } + + return valid; + } + + private static List DropTypeMemberCollisions(List entries, string typeName, + List issues) + { + if (typeName.Length == 0) + { + return entries; + } + + List valid = new(entries.Count); + foreach (SpecCallModel entry in entries) + { + bool conflicts = string.Equals(entry.Call.MethodName, typeName, StringComparison.Ordinal) + || (UsesAddressFacade(entry.Call) + && string.Equals(CoreMethodName(entry.Call.MethodName), typeName, + StringComparison.Ordinal)); + if (!conflicts) + { + valid.Add(entry); + continue; + } + + issues.Add(new SpecIssue(entry.Line, + "Generated method '" + entry.Call.MethodName + "' conflicts with its containing type '" + typeName + + "'.")); + } + + return valid; + } + + private static void AddGeneratedMemberOwner(Dictionary> owners, string name, + SpecCallModel entry) + { + if (!owners.TryGetValue(name, out List? entries)) + { + entries = []; + owners.Add(name, entries); + } + + entries.Add(entry); + } + + private static bool UsesAddressFacade(LuaGlobalCallModel call) + { + foreach (LuaArgumentModel argument in call.Arguments) + { + if (argument.Kind == LuaValueKind.Address) + { + return true; + } + } + + foreach (LuaResultModel result in call.Results) + { + if (result.Kind == LuaValueKind.Address) + { + return true; + } + } + + return call.ReturnKind == LuaValueKind.Address; + } + + private static string CoreMethodName(string methodName) + { + return "__" + (methodName[0] == '@' ? methodName[1..] : methodName) + "Raw"; + } + + private static string RawResultName(int index) + { + return "__engineApiRawResult" + index.ToString(CultureInfo.InvariantCulture); + } + + // One "key: value" line. A line without a colon (or an empty key) marks the whole block Malformed: the block is + // still collected (so the caller can report one issue at its start line) but ParseEntry/ParseHeader never look + // at a malformed block's fields. + private sealed class Block + { + public readonly List Fields = []; + public bool Malformed; + public int StartColumn; + public int StartLine; + + public bool IsEmpty => Fields.Count == 0 && !Malformed; + } + + // Header fields stay as source-positioned values until the ce77 contract has been validated, so every grammar + // diagnostic points at the additional-file key or value that needs correction. + private sealed class HeaderFields + { + public SpecField? Architecture; + public SpecField? ContractSchema; + public SpecField? MinimumCe; + public SpecField? Namespace; + public SpecField? Ownership; + public SpecField? Provenance; + public SpecField? Thread; + public SpecField? Type; + + public SpecField? FirstContractField => Provenance ?? MinimumCe ?? Architecture ?? Thread ?? Ownership; + } + + // The raw fields of one entry block, read once by ReadEntryFields and consumed by the validators below. + private sealed class EntryFields + { + public readonly List<(int Line, int Column, string Value)> ArgTokens = []; + public readonly List<(int Line, int Column, string Value)> FixedTokens = []; + public readonly List<(int Line, int Column, string Value)> ResultTokens = []; + public string? Doc; + public string? Form; + public int FormColumn; + public int FormLine; + public string? Global; + public int GlobalColumn; + public int GlobalLine; + public string? Method; + public int MethodColumn; + public int MethodLine; + public int NilColumn; + public int NilLine; + public string? NilSemantics; + public int ReturnColumn; + public int ReturnLine; + public string? ReturnToken; + public bool SawReturn; + } + + private readonly record struct SpecField(int Line, int KeyColumn, int ValueColumn, string Key, string Value); } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecIdentifiers.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecIdentifiers.cs index 0f037646..ee692c81 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecIdentifiers.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecIdentifiers.cs @@ -15,68 +15,82 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Parsing; /// internal static class SpecIdentifiers { - // The reserved words of the C# language (ECMA-334, section 6.4.4), which need an '@' prefix to name a declaration. - // Contextual keywords ('partial', 'var', 'async', ...) are not reserved and need no escape. - private static readonly string[] Keywords = - [ - "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", - "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", - "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", - "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", - "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", - "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", - "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" - ]; + // The reserved words of the C# language (ECMA-334, section 6.4.4), which need an '@' prefix to name a declaration. + // Contextual keywords ('partial', 'var', 'async', ...) are not reserved and need no escape. + private static readonly string[] Keywords = + [ + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", + "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", + "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", + "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", + "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", + "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", + "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" + ]; - /// - /// Whether is a plain (unescaped) C# identifier: ASCII letter or _, then - /// letters, digits, _. - /// - public static bool IsValidIdentifier(string? value) - { - if (string.IsNullOrEmpty(value) || !IsStart(value![0])) return false; + /// + /// Whether is a plain (unescaped) C# identifier: ASCII letter or _, then + /// letters, digits, _. + /// + public static bool IsValidIdentifier(string? value) + { + if (string.IsNullOrEmpty(value) || !IsStart(value![0])) + { + return false; + } - for (var i = 1; i < value.Length; i++) - if (!IsPart(value[i])) - return false; + for (int i = 1; i < value.Length; i++) + { + if (!IsPart(value[i])) + { + return false; + } + } - return true; - } + return true; + } - /// Whether is valid where the generator cannot emit an @ escape. - public static bool IsValidTypeIdentifier(string? value) - { - return IsValidIdentifier(value) && Array.IndexOf(Keywords, value) < 0; - } + /// Whether is valid where the generator cannot emit an @ escape. + public static bool IsValidTypeIdentifier(string? value) + { + return IsValidIdentifier(value) && Array.IndexOf(Keywords, value) < 0; + } - /// - /// Whether is a dotted sequence of valid identifiers, or empty for the global - /// namespace. - /// - public static bool IsValidNamespace(string value) - { - if (value.Length == 0) return true; + /// + /// Whether is a dotted sequence of valid identifiers, or empty for the global + /// namespace. + /// + public static bool IsValidNamespace(string value) + { + if (value.Length == 0) + { + return true; + } - foreach (var part in value.Split('.')) - if (!IsValidTypeIdentifier(part)) - return false; + foreach (string part in value.Split('.')) + { + if (!IsValidTypeIdentifier(part)) + { + return false; + } + } - return true; - } + return true; + } - /// , @-prefixed when it is a reserved word. - public static string Escape(string identifier) - { - return Array.IndexOf(Keywords, identifier) >= 0 ? "@" + identifier : identifier; - } + /// , @-prefixed when it is a reserved word. + public static string Escape(string identifier) + { + return Array.IndexOf(Keywords, identifier) >= 0 ? "@" + identifier : identifier; + } - private static bool IsStart(char c) - { - return c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or '_'; - } + private static bool IsStart(char c) + { + return c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or '_'; + } - private static bool IsPart(char c) - { - return IsStart(c) || c is >= '0' and <= '9'; - } + private static bool IsPart(char c) + { + return IsStart(c) || c is >= '0' and <= '9'; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecValueKinds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecValueKinds.cs index b5514d9e..7afb52d3 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecValueKinds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecValueKinds.cs @@ -15,54 +15,54 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Parsing; /// internal static class SpecValueKinds { - /// - /// Parses one kind token; for anything else, / - /// then undefined. - /// - public static bool TryParse(string token, out LuaValueKind kind, out bool isNullable) - { - switch (token) - { - case "int32": - kind = LuaValueKind.Int32; - isNullable = false; - return true; - case "int64": - kind = LuaValueKind.Int64; - isNullable = false; - return true; - case "single": - kind = LuaValueKind.Single; - isNullable = false; - return true; - case "double": - kind = LuaValueKind.Double; - isNullable = false; - return true; - case "boolean": - kind = LuaValueKind.Boolean; - isNullable = false; - return true; - case "address": - kind = LuaValueKind.Address; - isNullable = false; - return true; - case "utf8": - kind = LuaValueKind.Utf8; - isNullable = false; - return true; - case "string": - kind = LuaValueKind.String; - isNullable = false; - return true; - case "string?": - kind = LuaValueKind.String; - isNullable = true; - return true; - default: - kind = default; - isNullable = false; - return false; - } - } + /// + /// Parses one kind token; for anything else, / + /// then undefined. + /// + public static bool TryParse(string token, out LuaValueKind kind, out bool isNullable) + { + switch (token) + { + case "int32": + kind = LuaValueKind.Int32; + isNullable = false; + return true; + case "int64": + kind = LuaValueKind.Int64; + isNullable = false; + return true; + case "single": + kind = LuaValueKind.Single; + isNullable = false; + return true; + case "double": + kind = LuaValueKind.Double; + isNullable = false; + return true; + case "boolean": + kind = LuaValueKind.Boolean; + isNullable = false; + return true; + case "address": + kind = LuaValueKind.Address; + isNullable = false; + return true; + case "utf8": + kind = LuaValueKind.Utf8; + isNullable = false; + return true; + case "string": + kind = LuaValueKind.String; + isNullable = false; + return true; + case "string?": + kind = LuaValueKind.String; + isNullable = true; + return true; + default: + kind = default; + isNullable = false; + return false; + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs index 075bc4a4..ffbb1dcd 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs @@ -1,6 +1,8 @@ using System; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Model; using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Emit; @@ -31,132 +33,132 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Emit; /// internal static class BootstrapEmitter { - /// Hint name of the emitted file: constant, because at most one file is ever produced. - public const string HintName = "CheatEngine.SDK.EntryPoint.g.cs"; - - /// Name of the file-local factory. - internal const string FactoryName = "PluginFactory"; - - /// Name of the factory when the plugin class itself occupies CESDK.PluginFactory. - internal const string AlternateFactoryName = "GeneratedPluginFactory"; - - private const string QualifiedFactoryName = "global::CESDK." + FactoryName; - - private const string EditorBrowsableNever = - "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"; - - // Constructing a plugin class that its author marked [Obsolete] must not warn in a file the author cannot edit - // (and would fail a build that treats warnings as errors). - private const string ObsoleteWarningsOff = - "#pragma warning disable CS0612, CS0618 // the plugin class may be marked [Obsolete]"; - - // Computed once: reads the assembly name and version of this generator. - private static readonly string GeneratedCodeAttribute = - GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(BootstrapEmitter)); - - /// Emits the bootstrap for . - public static SourceText Emit(BootstrapModel model) - { - SourceWriter writer = new(2048); - var factoryName = ChooseFactoryName(model.FullyQualifiedTypeName); - - GeneratedCodeText.WriteFileHeader(writer); - writer.WriteLine(ObsoleteWarningsOff); - if (model.DeclaredDiagnosticIds.Length > 0) - { - // Same reason, for IDs only the plugin class knows: [Experimental("ID")] is an error by default. - writer.Write("#pragma warning disable "); - writer.Write(model.DeclaredDiagnosticIds); - writer.WriteLine(" // declared by the plugin class: [Experimental] or [Obsolete(DiagnosticId = ...)]"); - } - - writer.WriteLine(); - writer.WriteLine($"namespace {ManagedEntryPointNames.Namespace}"); - writer.OpenBlock(); - WriteEntryPoint(writer, factoryName); - writer.WriteLine(); - WriteFactory(writer, model, factoryName); - writer.CloseBlock(); - - return writer.ToSourceText(); - } - - /// - /// , unless the plugin class is, or is nested in, a type CESDK.PluginFactory. - /// - /// - /// A file-local type wins name lookup in its own file, even through a global::-qualified name. That is - /// what keeps an unrelated user type CESDK.PluginFactory harmless, and what would make - /// new global::CESDK.PluginFactory() construct the factory instead of the plugin. The plugin type is the - /// only user-chosen name in the file, so stepping aside for it is enough. - /// - internal static string ChooseFactoryName(string fullyQualifiedPluginTypeName) - { - var collides = fullyQualifiedPluginTypeName.StartsWith(QualifiedFactoryName, StringComparison.Ordinal) - && (fullyQualifiedPluginTypeName.Length == QualifiedFactoryName.Length - || fullyQualifiedPluginTypeName[QualifiedFactoryName.Length] == '.'); - - return collides ? AlternateFactoryName : FactoryName; - } - - private static void WriteEntryPoint(SourceWriter writer, string factoryName) - { - writer.WriteLine("/// "); - writer.WriteLine( - "/// Managed entry point of this plugin assembly. Cheat Engine looks up the type CESDK.CESDK and its"); - writer.WriteLine( - "/// method CEPluginInitialize by name, in the plugin assembly itself: both names are imposed by the host."); - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.WriteLine(EditorBrowsableNever); - writer.WriteLine($"internal static class {ManagedEntryPointNames.TypeName}"); - writer.OpenBlock(); - writer.WriteLine("/// "); - writer.WriteLine( - "/// Called by the host, more than once per load. Forwards to the hosting runtime, which is idempotent, and never"); - writer.WriteLine("/// lets an exception reach native code."); - writer.WriteLine("/// "); - writer.WriteLine("/// The first opaque value supplied by the host."); - writer.WriteLine( - "/// The second opaque value supplied by the host, forwarded without interpretation."); - writer.WriteLine("/// 1 on success, 0 on failure."); - writer.WriteLine( - $"public static int {ManagedEntryPointNames.MethodName}(global::System.IntPtr args, int opaqueArgument)"); - writer.OpenBlock(); - writer.WriteLine("try"); - writer.OpenBlock(); - writer.Write("return global::CheatEngine.SDK.Hosting.Bootstrap.PluginHost.InitializeManaged<"); - writer.Write(factoryName); - writer.WriteLine(">(args, opaqueArgument);"); - writer.CloseBlock(); - writer.WriteLine("catch (global::System.Exception)"); - writer.OpenBlock(); - writer.WriteLine("return 0;"); - writer.CloseBlock(); - writer.CloseBlock(); - writer.CloseBlock(); - } - - private static void WriteFactory(SourceWriter writer, BootstrapModel model, string factoryName) - { - writer.WriteLine( - "/// Constructs the plugin class without reflection and carries its display name as UTF-8."); - writer.WriteLine(GeneratedCodeAttribute); - writer.WriteLine(EditorBrowsableNever); - writer.Write("file sealed class "); - writer.Write(factoryName); - writer.WriteLine(" : global::CheatEngine.SDK.Hosting.Plugin.IPluginFactory"); - writer.OpenBlock(); - - writer.Write("public static global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin Create() => new "); - writer.Write(model.FullyQualifiedTypeName); - writer.WriteLine("();"); - writer.WriteLine(); - - writer.Write("public static global::System.ReadOnlySpan Utf8Name => "); - writer.Write(CSharpLiteral.ToUtf8Literal(model.DisplayName)); - writer.WriteLine(";"); - - writer.CloseBlock(); - } + /// Hint name of the emitted file: constant, because at most one file is ever produced. + public const string HintName = "CheatEngine.SDK.EntryPoint.g.cs"; + + /// Name of the file-local factory. + internal const string FactoryName = "PluginFactory"; + + /// Name of the factory when the plugin class itself occupies CESDK.PluginFactory. + internal const string AlternateFactoryName = "GeneratedPluginFactory"; + + private const string QualifiedFactoryName = "global::CESDK." + FactoryName; + + private const string EditorBrowsableNever = + "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"; + + // Constructing a plugin class that its author marked [Obsolete] must not warn in a file the author cannot edit + // (and would fail a build that treats warnings as errors). + private const string ObsoleteWarningsOff = + "#pragma warning disable CS0612, CS0618 // the plugin class may be marked [Obsolete]"; + + // Computed once: reads the assembly name and version of this generator. + private static readonly string GeneratedCodeAttribute = + GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(BootstrapEmitter)); + + /// Emits the bootstrap for . + public static SourceText Emit(BootstrapModel model) + { + SourceWriter writer = new(2048); + string factoryName = ChooseFactoryName(model.FullyQualifiedTypeName); + + GeneratedCodeText.WriteFileHeader(writer); + writer.WriteLine(ObsoleteWarningsOff); + if (model.DeclaredDiagnosticIds.Length > 0) + { + // Same reason, for IDs only the plugin class knows: [Experimental("ID")] is an error by default. + writer.Write("#pragma warning disable "); + writer.Write(model.DeclaredDiagnosticIds); + writer.WriteLine(" // declared by the plugin class: [Experimental] or [Obsolete(DiagnosticId = ...)]"); + } + + writer.WriteLine(); + writer.WriteLine($"namespace {ManagedEntryPointNames.Namespace}"); + writer.OpenBlock(); + WriteEntryPoint(writer, factoryName); + writer.WriteLine(); + WriteFactory(writer, model, factoryName); + writer.CloseBlock(); + + return writer.ToSourceText(); + } + + /// + /// , unless the plugin class is, or is nested in, a type CESDK.PluginFactory. + /// + /// + /// A file-local type wins name lookup in its own file, even through a global::-qualified name. That is + /// what keeps an unrelated user type CESDK.PluginFactory harmless, and what would make + /// new global::CESDK.PluginFactory() construct the factory instead of the plugin. The plugin type is the + /// only user-chosen name in the file, so stepping aside for it is enough. + /// + internal static string ChooseFactoryName(string fullyQualifiedPluginTypeName) + { + bool collides = fullyQualifiedPluginTypeName.StartsWith(QualifiedFactoryName, StringComparison.Ordinal) + && (fullyQualifiedPluginTypeName.Length == QualifiedFactoryName.Length + || fullyQualifiedPluginTypeName[QualifiedFactoryName.Length] == '.'); + + return collides ? AlternateFactoryName : FactoryName; + } + + private static void WriteEntryPoint(SourceWriter writer, string factoryName) + { + writer.WriteLine("/// "); + writer.WriteLine( + "/// Managed entry point of this plugin assembly. Cheat Engine looks up the type CESDK.CESDK and its"); + writer.WriteLine( + "/// method CEPluginInitialize by name, in the plugin assembly itself: both names are imposed by the host."); + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.WriteLine(EditorBrowsableNever); + writer.WriteLine($"internal static class {ManagedEntryPointNames.TypeName}"); + writer.OpenBlock(); + writer.WriteLine("/// "); + writer.WriteLine( + "/// Called by the host, more than once per load. Forwards to the hosting runtime, which is idempotent, and never"); + writer.WriteLine("/// lets an exception reach native code."); + writer.WriteLine("/// "); + writer.WriteLine("/// The first opaque value supplied by the host."); + writer.WriteLine( + "/// The second opaque value supplied by the host, forwarded without interpretation."); + writer.WriteLine("/// 1 on success, 0 on failure."); + writer.WriteLine( + $"public static int {ManagedEntryPointNames.MethodName}(global::System.IntPtr args, int opaqueArgument)"); + writer.OpenBlock(); + writer.WriteLine("try"); + writer.OpenBlock(); + writer.Write("return global::CheatEngine.SDK.Hosting.Bootstrap.PluginHost.InitializeManaged<"); + writer.Write(factoryName); + writer.WriteLine(">(args, opaqueArgument);"); + writer.CloseBlock(); + writer.WriteLine("catch (global::System.Exception)"); + writer.OpenBlock(); + writer.WriteLine("return 0;"); + writer.CloseBlock(); + writer.CloseBlock(); + writer.CloseBlock(); + } + + private static void WriteFactory(SourceWriter writer, BootstrapModel model, string factoryName) + { + writer.WriteLine( + "/// Constructs the plugin class without reflection and carries its display name as UTF-8."); + writer.WriteLine(GeneratedCodeAttribute); + writer.WriteLine(EditorBrowsableNever); + writer.Write("file sealed class "); + writer.Write(factoryName); + writer.WriteLine(" : global::CheatEngine.SDK.Hosting.Plugin.IPluginFactory"); + writer.OpenBlock(); + + writer.Write("public static global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin Create() => new "); + writer.Write(model.FullyQualifiedTypeName); + writer.WriteLine("();"); + writer.WriteLine(); + + writer.Write("public static global::System.ReadOnlySpan Utf8Name => "); + writer.Write(CSharpLiteral.ToUtf8Literal(model.DisplayName)); + writer.WriteLine(";"); + + writer.CloseBlock(); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointGenerator.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointGenerator.cs index 65d20ece..9676d0e0 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointGenerator.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointGenerator.cs @@ -2,6 +2,7 @@ using CheatEngine.SDK.SourceGenerators.EntryPoint.Model; using CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -29,52 +30,55 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint; [Generator(LanguageNames.CSharp)] public sealed class EntryPointGenerator : IIncrementalGenerator { - /// Metadata name of the marker attribute (declared by CheatEngine.SDK.Annotations). - internal const string PluginAttributeMetadataName = AnnotationsMetadataNames.CheatEnginePluginAttribute; + /// Metadata name of the marker attribute (declared by CheatEngine.SDK.Annotations). + internal const string PluginAttributeMetadataName = AnnotationsMetadataNames.CheatEnginePluginAttribute; - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // Discovery is attribute-driven only (never a base-type scan): the compiler indexes attribute names, and the - // predicate is purely syntactic. The transform is the single place where symbols are read. - var plugin = context.SyntaxProvider - .ForAttributeWithMetadataName( - PluginAttributeMetadataName, - static (node, _) => node is ClassDeclarationSyntax, - static (attributeContext, cancellationToken) => - PluginParser.Parse(attributeContext, cancellationToken)) - .WithTrackingName(EntryPointTrackingNames.Plugin); + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Discovery is attribute-driven only (never a base-type scan): the compiler indexes attribute names, and the + // predicate is purely syntactic. The transform is the single place where symbols are read. + IncrementalValuesProvider plugin = context.SyntaxProvider + .ForAttributeWithMetadataName( + PluginAttributeMetadataName, + static (node, _) => node is ClassDeclarationSyntax, + static (attributeContext, cancellationToken) => + PluginParser.Parse(attributeContext, cancellationToken)) + .WithTrackingName(EntryPointTrackingNames.Plugin); - var plugins = plugin - .Collect() - .WithTrackingName(EntryPointTrackingNames.CollectedPlugins) - .Select(static (models, _) => new EquatableArray(models)) - .WithTrackingName(EntryPointTrackingNames.Plugins); + IncrementalValueProvider> plugins = plugin + .Collect() + .WithTrackingName(EntryPointTrackingNames.CollectedPlugins) + .Select(static (models, _) => new EquatableArray(models)) + .WithTrackingName(EntryPointTrackingNames.Plugins); - // Reduced to a value before it is combined: the options provider object itself never compares equal. - var options = context.AnalyzerConfigOptionsProvider - .Select(static (provider, _) => EntryPointOptions.From(provider.GlobalOptions)) - .WithTrackingName(EntryPointTrackingNames.Options); + // Reduced to a value before it is combined: the options provider object itself never compares equal. + IncrementalValueProvider options = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => EntryPointOptions.From(provider.GlobalOptions)) + .WithTrackingName(EntryPointTrackingNames.Options); - // A hand-written CESDK.CESDK is a source-identity collision, even when it is not itself a plugin class. Keep - // this as a scalar projection so an unrelated compilation edit can leave the final BootstrapModel unchanged. - var entryPointTypeCollision = context.CompilationProvider - .Select(static (compilation, _) => EntryPointGeneratedIdentity.HasEntryPointTypeCollision(compilation)) - .WithTrackingName(EntryPointTrackingNames.EntryPointTypeCollision); + // A hand-written CESDK.CESDK is a source-identity collision, even when it is not itself a plugin class. Keep + // this as a scalar projection so an unrelated compilation edit can leave the final BootstrapModel unchanged. + IncrementalValueProvider entryPointTypeCollision = context.CompilationProvider + .Select(static (compilation, _) => EntryPointGeneratedIdentity.HasEntryPointTypeCollision(compilation)) + .WithTrackingName(EntryPointTrackingNames.EntryPointTypeCollision); - // One more projection instead of deciding inside the output: the source output then depends on three strings - // only, so a second (invalid) plugin class or an unrelated option never re-emits the file. - var bootstrap = plugins - .Combine(options) - .WithTrackingName(EntryPointTrackingNames.PluginsAndOptions) - .Combine(entryPointTypeCollision) - .WithTrackingName(EntryPointTrackingNames.PluginsOptionsAndCollision) - .Select(static (pair, _) => BootstrapModel.Select(pair.Left.Left, pair.Left.Right, pair.Right)) - .WithTrackingName(EntryPointTrackingNames.Bootstrap); + // One more projection instead of deciding inside the output: the source output then depends on three strings + // only, so a second (invalid) plugin class or an unrelated option never re-emits the file. + IncrementalValueProvider bootstrap = plugins + .Combine(options) + .WithTrackingName(EntryPointTrackingNames.PluginsAndOptions) + .Combine(entryPointTypeCollision) + .WithTrackingName(EntryPointTrackingNames.PluginsOptionsAndCollision) + .Select(static (pair, _) => BootstrapModel.Select(pair.Left.Left, pair.Left.Right, pair.Right)) + .WithTrackingName(EntryPointTrackingNames.Bootstrap); - context.RegisterSourceOutput(bootstrap, static (productionContext, model) => - { - if (model is not null) productionContext.AddSource(BootstrapEmitter.HintName, BootstrapEmitter.Emit(model)); - }); - } + context.RegisterSourceOutput(bootstrap, static (productionContext, model) => + { + if (model is not null) + { + productionContext.AddSource(BootstrapEmitter.HintName, BootstrapEmitter.Emit(model)); + } + }); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointTrackingNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointTrackingNames.cs index 33592be7..94eeebb6 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointTrackingNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/EntryPointTrackingNames.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; namespace CheatEngine.SDK.SourceGenerators.EntryPoint; @@ -9,37 +10,37 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint; /// internal static class EntryPointTrackingNames { - /// ForAttributeWithMetadataName transform: one PluginModel per attributed class. - public const string Plugin = Prefix + "Plugin"; + /// ForAttributeWithMetadataName transform: one PluginModel per attributed class. + public const string Plugin = Prefix + "Plugin"; - /// Collect: all plugin models as an ImmutableArray. - public const string CollectedPlugins = Prefix + "CollectedPlugins"; + /// Collect: all plugin models as an ImmutableArray. + public const string CollectedPlugins = Prefix + "CollectedPlugins"; - /// The collected models wrapped in an EquatableArray (value equality). - public const string Plugins = Prefix + "Plugins"; + /// The collected models wrapped in an EquatableArray (value equality). + public const string Plugins = Prefix + "Plugins"; - /// The MSBuild switches, parsed. - public const string Options = Prefix + "Options"; + /// The MSBuild switches, parsed. + public const string Options = Prefix + "Options"; - /// Combine of and . - public const string PluginsAndOptions = Prefix + "PluginsAndOptions"; + /// Combine of and . + public const string PluginsAndOptions = Prefix + "PluginsAndOptions"; - /// Checks whether user source already owns the host-mandated generated type identity. - public const string EntryPointTypeCollision = Prefix + "EntryPointTypeCollision"; + /// Checks whether user source already owns the host-mandated generated type identity. + public const string EntryPointTypeCollision = Prefix + "EntryPointTypeCollision"; - /// Combines candidate plugins, the build switch and the generated-type collision result. - public const string PluginsOptionsAndCollision = Prefix + "PluginsOptionsAndCollision"; + /// Combines candidate plugins, the build switch and the generated-type collision result. + public const string PluginsOptionsAndCollision = Prefix + "PluginsOptionsAndCollision"; - /// The final BootstrapModel (or null): the only input of the source output. - public const string Bootstrap = Prefix + "Bootstrap"; + /// The final BootstrapModel (or null): the only input of the source output. + public const string Bootstrap = Prefix + "Bootstrap"; - private const string Prefix = TrackingNames.Prefix + "EntryPoint."; + private const string Prefix = TrackingNames.Prefix + "EntryPoint."; - /// All of the above, for tests that must not miss a step. - public static readonly ImmutableArray All = - [ - Plugin, CollectedPlugins, Plugins, Options, PluginsAndOptions, EntryPointTypeCollision, - PluginsOptionsAndCollision, - Bootstrap, - ]; + /// All of the above, for tests that must not miss a step. + public static readonly ImmutableArray All = + [ + Plugin, CollectedPlugins, Plugins, Options, PluginsAndOptions, EntryPointTypeCollision, + PluginsOptionsAndCollision, + Bootstrap + ]; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/BootstrapModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/BootstrapModel.cs index 117da4ec..c29ccde6 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/BootstrapModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/BootstrapModel.cs @@ -13,40 +13,49 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Model; /// internal sealed record BootstrapModel(string FullyQualifiedTypeName, string DisplayName, string DeclaredDiagnosticIds) { - /// - /// Decides whether there is something to emit: generation is on and exactly one valid plugin class - /// exists. Every other situation yields and is explained by the analyzers - /// (CESDK0001 invalid shape, CESDK0002 several plugins). - /// - public static BootstrapModel? Select(EquatableArray plugins, EntryPointOptions options) - { - return Select(plugins, options, entryPointTypeCollision: false); - } + /// + /// Decides whether there is something to emit: generation is on and exactly one valid plugin class + /// exists. Every other situation yields and is explained by the analyzers + /// (CESDK0001 invalid shape, CESDK0002 several plugins). + /// + public static BootstrapModel? Select(EquatableArray plugins, EntryPointOptions options) + { + return Select(plugins, options, false); + } - /// - /// Decides whether there is something to emit while accounting for a user-declared host-mandated bootstrap - /// type. A collision leaves the generator silent so that the analyzer can report the source location instead of - /// generated code causing a duplicate-type error. - /// - public static BootstrapModel? Select( - EquatableArray plugins, - EntryPointOptions options, - bool entryPointTypeCollision) - { - if (!options.GenerateEntryPoint || entryPointTypeCollision) return null; + /// + /// Decides whether there is something to emit while accounting for a user-declared host-mandated bootstrap + /// type. A collision leaves the generator silent so that the analyzer can report the source location instead of + /// generated code causing a duplicate-type error. + /// + public static BootstrapModel? Select( + EquatableArray plugins, + EntryPointOptions options, + bool entryPointTypeCollision) + { + if (!options.GenerateEntryPoint || entryPointTypeCollision) + { + return null; + } - PluginModel? single = null; - foreach (var plugin in plugins) - { - if (!plugin.IsValid) continue; + PluginModel? single = null; + foreach (PluginModel plugin in plugins) + { + if (!plugin.IsValid) + { + continue; + } - if (single is not null) return null; + if (single is not null) + { + return null; + } - single = plugin; - } + single = plugin; + } - return single is null - ? null - : new BootstrapModel(single.FullyQualifiedTypeName, single.DisplayName, single.DeclaredDiagnosticIds); - } + return single is null + ? null + : new BootstrapModel(single.FullyQualifiedTypeName, single.DisplayName, single.DeclaredDiagnosticIds); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/EntryPointOptions.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/EntryPointOptions.cs index 4fbbe139..18736632 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/EntryPointOptions.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/EntryPointOptions.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis.Diagnostics; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Model; @@ -12,12 +13,12 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Model; /// internal readonly record struct EntryPointOptions(bool GenerateEntryPoint) { - /// Global analyzer-config key of the switch. - public const string GenerateEntryPointKey = BuildProperty.KeyPrefix + "CheatEngineSdkGenerateEntryPoint"; + /// Global analyzer-config key of the switch. + public const string GenerateEntryPointKey = BuildProperty.KeyPrefix + "CheatEngineSdkGenerateEntryPoint"; - /// Reads the switches from AnalyzerConfigOptionsProvider.GlobalOptions. - public static EntryPointOptions From(AnalyzerConfigOptions globalOptions) - { - return new EntryPointOptions(BuildProperty.ReadBoolean(globalOptions, GenerateEntryPointKey, defaultValue: false)); - } + /// Reads the switches from AnalyzerConfigOptionsProvider.GlobalOptions. + public static EntryPointOptions From(AnalyzerConfigOptions globalOptions) + { + return new EntryPointOptions(BuildProperty.ReadBoolean(globalOptions, GenerateEntryPointKey, false)); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/PluginModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/PluginModel.cs index 043b3c57..c80a8354 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/PluginModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Model/PluginModel.cs @@ -17,11 +17,11 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Model; /// /// Why the class cannot be bootstrapped; when it can. internal sealed record PluginModel( - string FullyQualifiedTypeName, - string DisplayName, - string DeclaredDiagnosticIds, - PluginShapeIssues Issues) + string FullyQualifiedTypeName, + string DisplayName, + string DeclaredDiagnosticIds, + PluginShapeIssues Issues) { - /// when the generated factory can construct this class. - public bool IsValid => Issues == PluginShapeIssues.None; + /// when the generated factory can construct this class. + public bool IsValid => Issues == PluginShapeIssues.None; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs index 2d0a70cf..7832ce47 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs @@ -1,5 +1,7 @@ using System; + using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; @@ -16,68 +18,87 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; /// internal readonly struct EntryPointContractSymbols { - private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; - private const string HostingAssemblyName = "CheatEngine.SDK.Hosting"; + private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; + private const string HostingAssemblyName = "CheatEngine.SDK.Hosting"; - private const string SetsRequiredMembersAttributeMetadataName = - "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute"; + private const string SetsRequiredMembersAttributeMetadataName = + "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute"; - private const string ObsoleteAttributeMetadataName = "System.ObsoleteAttribute"; - private const string ExperimentalAttributeMetadataName = "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"; + private const string ObsoleteAttributeMetadataName = "System.ObsoleteAttribute"; + private const string ExperimentalAttributeMetadataName = "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"; - private EntryPointContractSymbols( - INamedTypeSymbol? pluginAttribute, - INamedTypeSymbol? pluginBase, - INamedTypeSymbol? setsRequiredMembersAttribute, - INamedTypeSymbol? obsoleteAttribute, - INamedTypeSymbol? experimentalAttribute) - { - PluginAttribute = pluginAttribute; - PluginBase = pluginBase; - SetsRequiredMembersAttribute = setsRequiredMembersAttribute; - ObsoleteAttribute = obsoleteAttribute; - ExperimentalAttribute = experimentalAttribute; - } + private EntryPointContractSymbols( + INamedTypeSymbol? pluginAttribute, + INamedTypeSymbol? pluginBase, + INamedTypeSymbol? setsRequiredMembersAttribute, + INamedTypeSymbol? obsoleteAttribute, + INamedTypeSymbol? experimentalAttribute) + { + PluginAttribute = pluginAttribute; + PluginBase = pluginBase; + SetsRequiredMembersAttribute = setsRequiredMembersAttribute; + ObsoleteAttribute = obsoleteAttribute; + ExperimentalAttribute = experimentalAttribute; + } - /// The actual CheatEnginePluginAttribute symbol from CheatEngine.SDK.Annotations. - public INamedTypeSymbol? PluginAttribute { get; } + /// The actual CheatEnginePluginAttribute symbol from CheatEngine.SDK.Annotations. + public INamedTypeSymbol? PluginAttribute + { + get; + } - /// The actual CheatEnginePlugin symbol from CheatEngine.SDK.Hosting. - public INamedTypeSymbol? PluginBase { get; } + /// The actual CheatEnginePlugin symbol from CheatEngine.SDK.Hosting. + public INamedTypeSymbol? PluginBase + { + get; + } - /// The BCL marker that permits a selected constructor to satisfy required members. - public INamedTypeSymbol? SetsRequiredMembersAttribute { get; } + /// The BCL marker that permits a selected constructor to satisfy required members. + public INamedTypeSymbol? SetsRequiredMembersAttribute + { + get; + } - /// The BCL attribute that can make construction an unsuppressible error. - public INamedTypeSymbol? ObsoleteAttribute { get; } + /// The BCL attribute that can make construction an unsuppressible error. + public INamedTypeSymbol? ObsoleteAttribute + { + get; + } - /// The BCL attribute whose caller-supplied diagnostic ID must be disabled in generated code. - public INamedTypeSymbol? ExperimentalAttribute { get; } + /// The BCL attribute whose caller-supplied diagnostic ID must be disabled in generated code. + public INamedTypeSymbol? ExperimentalAttribute + { + get; + } - /// Resolves the SDK contract and BCL symbols for one source-generator transform. - public static EntryPointContractSymbols Resolve(Compilation compilation) - { - return new EntryPointContractSymbols( - FindReferencedType( - compilation, - AnnotationsAssemblyName, - AnnotationsMetadataNames.CheatEnginePluginAttribute), - FindReferencedType(compilation, HostingAssemblyName, AnnotationsMetadataNames.CheatEnginePluginBase), - compilation.GetTypeByMetadataName(SetsRequiredMembersAttributeMetadataName), - compilation.GetTypeByMetadataName(ObsoleteAttributeMetadataName), - compilation.GetTypeByMetadataName(ExperimentalAttributeMetadataName)); - } + /// Resolves the SDK contract and BCL symbols for one source-generator transform. + public static EntryPointContractSymbols Resolve(Compilation compilation) + { + return new EntryPointContractSymbols( + FindReferencedType( + compilation, + AnnotationsAssemblyName, + AnnotationsMetadataNames.CheatEnginePluginAttribute), + FindReferencedType(compilation, HostingAssemblyName, AnnotationsMetadataNames.CheatEnginePluginBase), + compilation.GetTypeByMetadataName(SetsRequiredMembersAttributeMetadataName), + compilation.GetTypeByMetadataName(ObsoleteAttributeMetadataName), + compilation.GetTypeByMetadataName(ExperimentalAttributeMetadataName)); + } - private static INamedTypeSymbol? FindReferencedType( - Compilation compilation, - string assemblyName, - string metadataName) - { - foreach (var reference in compilation.References) - if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly - && string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) - return assembly.GetTypeByMetadataName(metadataName); + private static INamedTypeSymbol? FindReferencedType( + Compilation compilation, + string assemblyName, + string metadataName) + { + foreach (MetadataReference reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly + && string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) + { + return assembly.GetTypeByMetadataName(metadataName); + } + } - return null; - } + return null; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs index db1e3152..5ba484e2 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -31,88 +32,104 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; /// internal static class EntryPointDeclaredDiagnosticIds { - private const string Separator = ", "; + private const string Separator = ", "; - /// - /// The IDs, outermost type first and constructor last, without duplicates, joined with ", ": the operand - /// of a #pragma warning disable. Empty when the class declares none. Never throws on malformed attributes. - /// - public static string Collect( - INamedTypeSymbol type, - IMethodSymbol? parameterlessConstructor, - INamedTypeSymbol? experimentalAttribute, - INamedTypeSymbol? obsoleteAttribute) - { - List? ids = null; - CollectFromTypeAndContainers(type, experimentalAttribute, obsoleteAttribute, ref ids); + /// + /// The IDs, outermost type first and constructor last, without duplicates, joined with ", ": the operand + /// of a #pragma warning disable. Empty when the class declares none. Never throws on malformed attributes. + /// + public static string Collect( + INamedTypeSymbol type, + IMethodSymbol? parameterlessConstructor, + INamedTypeSymbol? experimentalAttribute, + INamedTypeSymbol? obsoleteAttribute) + { + List? ids = null; + CollectFromTypeAndContainers(type, experimentalAttribute, obsoleteAttribute, ref ids); - if (parameterlessConstructor is not null) - CollectFrom( - parameterlessConstructor.GetAttributes(), - experimentalAttribute, - obsoleteAttribute, - ref ids); + if (parameterlessConstructor is not null) + { + CollectFrom( + parameterlessConstructor.GetAttributes(), + experimentalAttribute, + obsoleteAttribute, + ref ids); + } - return ids is null ? string.Empty : string.Join(Separator, ids); - } + return ids is null ? string.Empty : string.Join(Separator, ids); + } - private static void CollectFromTypeAndContainers( - INamedTypeSymbol type, - INamedTypeSymbol? experimentalAttribute, - INamedTypeSymbol? obsoleteAttribute, - ref List? ids) - { - if (type.ContainingType is { } containing) - CollectFromTypeAndContainers(containing, experimentalAttribute, obsoleteAttribute, ref ids); + private static void CollectFromTypeAndContainers( + INamedTypeSymbol type, + INamedTypeSymbol? experimentalAttribute, + INamedTypeSymbol? obsoleteAttribute, + ref List? ids) + { + if (type.ContainingType is { } containing) + { + CollectFromTypeAndContainers(containing, experimentalAttribute, obsoleteAttribute, ref ids); + } - CollectFrom(type.GetAttributes(), experimentalAttribute, obsoleteAttribute, ref ids); - } + CollectFrom(type.GetAttributes(), experimentalAttribute, obsoleteAttribute, ref ids); + } - private static void CollectFrom( - ImmutableArray attributes, - INamedTypeSymbol? experimentalAttribute, - INamedTypeSymbol? obsoleteAttribute, - ref List? ids) - { - foreach (var attribute in attributes) - if (ReadDeclaredId(attribute, experimentalAttribute, obsoleteAttribute) is { } id - && IsUsableInPragma(id) - && (ids is null || !ids.Contains(id))) - (ids ??= []).Add(id); - } + private static void CollectFrom( + ImmutableArray attributes, + INamedTypeSymbol? experimentalAttribute, + INamedTypeSymbol? obsoleteAttribute, + ref List? ids) + { + foreach (AttributeData attribute in attributes) + { + if (ReadDeclaredId(attribute, experimentalAttribute, obsoleteAttribute) is { } id + && IsUsableInPragma(id) + && (ids is null || !ids.Contains(id))) + { + (ids ??= []).Add(id); + } + } + } - private static string? ReadDeclaredId( - AttributeData attribute, - INamedTypeSymbol? experimentalAttribute, - INamedTypeSymbol? obsoleteAttribute) - { - if (experimentalAttribute is not null - && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, experimentalAttribute)) - // [Experimental(string diagnosticId)] - return attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0] is - { Kind: TypedConstantKind.Primitive, Value: string experimentalId } - ? experimentalId - : null; + private static string? ReadDeclaredId( + AttributeData attribute, + INamedTypeSymbol? experimentalAttribute, + INamedTypeSymbol? obsoleteAttribute) + { + if (experimentalAttribute is not null + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, experimentalAttribute)) + // [Experimental(string diagnosticId)] + { + return attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0] is + { Kind: TypedConstantKind.Primitive, Value: string experimentalId } + ? experimentalId + : null; + } - if (obsoleteAttribute is not null - && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute)) - // [Obsolete(..., DiagnosticId = "ID")] - foreach (var argument in attribute.NamedArguments) - if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) - && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) - return obsoleteId; + if (obsoleteAttribute is not null + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute)) + // [Obsolete(..., DiagnosticId = "ID")] + { + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) + && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) + { + return obsoleteId; + } + } + } - return null; - } + return null; + } - // The ID goes into a '#pragma warning disable' line as it is, so it must be one identifier token there: no - // white space, line break or comment marker (text injection), no C# or preprocessor keyword. The compiler already - // rejects an [Experimental] ID that is not an identifier (CS9211); whatever is dropped here stays a loud error. - private static bool IsUsableInPragma(string id) - { - return SyntaxFacts.IsValidIdentifier(id) - && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None - && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; - } + // The ID goes into a '#pragma warning disable' line as it is, so it must be one identifier token there: no + // white space, line break or comment marker (text injection), no C# or preprocessor keyword. The compiler already + // rejects an [Experimental] ID that is not an identifier (CS9211); whatever is dropped here stays a loud error. + private static bool IsUsableInPragma(string id) + { + return SyntaxFacts.IsValidIdentifier(id) + && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None + && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs index 4de81aad..4620c2ca 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs @@ -1,5 +1,7 @@ using System; + using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; @@ -11,23 +13,30 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; /// internal static class EntryPointGeneratedIdentity { - /// - /// Gets whether the current assembly declares the non-generic, non-file-local CESDK.CESDK type identity - /// that generated code would declare. - /// - public static bool HasEntryPointTypeCollision(Compilation compilation) - { - foreach (var @namespace in compilation.GlobalNamespace.GetNamespaceMembers()) - { - if (!string.Equals(@namespace.Name, ManagedEntryPointNames.Namespace, StringComparison.Ordinal)) continue; + /// + /// Gets whether the current assembly declares the non-generic, non-file-local CESDK.CESDK type identity + /// that generated code would declare. + /// + public static bool HasEntryPointTypeCollision(Compilation compilation) + { + foreach (INamespaceSymbol @namespace in compilation.GlobalNamespace.GetNamespaceMembers()) + { + if (!string.Equals(@namespace.Name, ManagedEntryPointNames.Namespace, StringComparison.Ordinal)) + { + continue; + } - foreach (var type in @namespace.GetTypeMembers(ManagedEntryPointNames.TypeName)) - if (type.Arity == 0 - && !type.IsFileLocal - && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) - return true; - } + foreach (INamedTypeSymbol type in @namespace.GetTypeMembers(ManagedEntryPointNames.TypeName)) + { + if (type.Arity == 0 + && !type.IsFileLocal + && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) + { + return true; + } + } + } - return false; - } + return false; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/PluginParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/PluginParser.cs index 81726ee0..2323fb73 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/PluginParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/PluginParser.cs @@ -1,7 +1,9 @@ using System.Collections.Immutable; using System.Threading; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Model; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; @@ -12,48 +14,55 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Parsing; /// internal static class PluginParser { - /// Builds the model of one attributed class. - public static PluginModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var type = (INamedTypeSymbol)context.TargetSymbol; - var compilation = context.SemanticModel.Compilation; - var symbols = EntryPointContractSymbols.Resolve(compilation); - var attribute = FindAttribute(context.Attributes, symbols.PluginAttribute); - - var issues = PluginShape.Inspect( - type, - attribute, - symbols.PluginBase, - symbols.SetsRequiredMembersAttribute, - symbols.ObsoleteAttribute, - out var displayName, - out var parameterlessConstructor); - - // FullyQualifiedFormat: 'global::' prefix, containing types, escaped keyword identifiers. Identifiers come - // out as declared (a non-ASCII letter is not turned into a \uXXXX escape): the generated file is UTF-8. - return new PluginModel( - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - displayName, - EntryPointDeclaredDiagnosticIds.Collect( - type, - parameterlessConstructor, - symbols.ExperimentalAttribute, - symbols.ObsoleteAttribute), - issues); - } - - private static AttributeData? FindAttribute( - ImmutableArray attributes, - INamedTypeSymbol? pluginAttribute) - { - if (pluginAttribute is null) return null; - - foreach (var attribute in attributes) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, pluginAttribute)) - return attribute; - - return null; - } + /// Builds the model of one attributed class. + public static PluginModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + INamedTypeSymbol type = (INamedTypeSymbol) context.TargetSymbol; + Compilation compilation = context.SemanticModel.Compilation; + EntryPointContractSymbols symbols = EntryPointContractSymbols.Resolve(compilation); + AttributeData? attribute = FindAttribute(context.Attributes, symbols.PluginAttribute); + + PluginShapeIssues issues = PluginShape.Inspect( + type, + attribute, + symbols.PluginBase, + symbols.SetsRequiredMembersAttribute, + symbols.ObsoleteAttribute, + out string displayName, + out IMethodSymbol? parameterlessConstructor); + + // FullyQualifiedFormat: 'global::' prefix, containing types, escaped keyword identifiers. Identifiers come + // out as declared (a non-ASCII letter is not turned into a \uXXXX escape): the generated file is UTF-8. + return new PluginModel( + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + displayName, + EntryPointDeclaredDiagnosticIds.Collect( + type, + parameterlessConstructor, + symbols.ExperimentalAttribute, + symbols.ObsoleteAttribute), + issues); + } + + private static AttributeData? FindAttribute( + ImmutableArray attributes, + INamedTypeSymbol? pluginAttribute) + { + if (pluginAttribute is null) + { + return null; + } + + foreach (AttributeData attribute in attributes) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, pluginAttribute)) + { + return attribute; + } + } + + return null; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaClassFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaClassFileEmitter.cs index 008330c9..e87c4436 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaClassFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaClassFileEmitter.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; @@ -8,219 +9,228 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; /// Emits the borrowed-handle implementation for one valid [LuaClass] readonly partial struct. internal static class LuaClassFileEmitter { - /// Stable suffix for generated class-handle files. - public const string HintSuffix = ".LuaClass.g.cs"; - - private static readonly string GeneratedCodeAttribute = - GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaClassFileEmitter)); - - /// Returns the deterministic source hint name. - public static string HintName(LuaClassModel model) - { - return model.HintName; - } - - /// Writes the complete generated partial part. - public static SourceText Emit(LuaClassModel model) - { - SourceWriter writer = new(2048); - GeneratedCodeText.WriteFileHeader(writer); - OpenHandleType(writer, model.ContainingType); - WriteMembers(writer, model.ContainingType.FullyQualifiedName, model.ContainingType.Declarations[^1].Name); - CloseHandleType(writer, model.ContainingType); - return writer.ToSourceText(); - } - - private static void OpenHandleType(SourceWriter writer, ContainingTypeModel type) - { - if (type.Namespace.Length > 0) - { - writer.Write("namespace "); - writer.WriteLine(type.Namespace); - writer.OpenBlock(); - } - - for (var i = 0; i < type.Declarations.Length; i++) - { - var declaration = type.Declarations[i]; - if (declaration.IsReadOnly) writer.Write("readonly "); - - writer.Write("partial "); - writer.Write(declaration.Keyword); - writer.Write(' '); - writer.Write(declaration.Name); - if (i == type.Declarations.Length - 1) - { - writer.Write(" : global::System.IEquatable<"); - writer.Write(type.FullyQualifiedName); - writer.Write(">, "); - writer.Write(LuaApiNames.ICEObject); - writer.Write('<'); - writer.Write(type.FullyQualifiedName); - writer.Write(">, "); - writer.Write(LuaApiNames.ILuaMarshaller); - writer.Write('<'); - writer.Write(type.FullyQualifiedName); - writer.Write('>'); - } - - writer.WriteLine(); - writer.OpenBlock(); - } - } - - private static void CloseHandleType(SourceWriter writer, ContainingTypeModel type) - { - for (var i = 0; i < type.Declarations.Length; i++) writer.CloseBlock(); - - if (type.Namespace.Length > 0) writer.CloseBlock(); - } - - private static void WriteMembers(SourceWriter writer, string selfType, string constructorName) - { - WriteStorage(writer, selfType, constructorName); - WriteHandleConversion(writer, selfType); - WriteEquality(writer, selfType); - WriteMarshaller(writer, selfType); - } - - private static void WriteStorage(SourceWriter writer, string selfType, string constructorName) - { - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("private readonly "); - writer.Write(LuaApiNames.CEObject); - writer.WriteLine(" _handle;"); - writer.WriteLine(); - - writer.WriteLine(GeneratedCodeAttribute); - writer.WriteLine("[global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]"); - writer.Write("private "); - writer.Write(constructorName); - writer.Write('('); - writer.Write(LuaApiNames.CEObject); - writer.WriteLine(" handle)"); - writer.OpenBlock(); - writer.WriteLine("_handle = handle;"); - writer.CloseBlock(); - writer.WriteLine(); - } - - private static void WriteHandleConversion(SourceWriter writer, string selfType) - { - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public "); - writer.Write(LuaApiNames.CEObject); - writer.WriteLine(" Handle => _handle;"); - writer.WriteLine(); - - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public static "); - writer.Write(selfType); - writer.Write(" FromHandle("); - writer.Write(LuaApiNames.CEObject); - writer.WriteLine(" handle)"); - writer.OpenBlock(); - writer.WriteLine("return new(handle);"); - writer.CloseBlock(); - writer.WriteLine(); - } - - private static void WriteEquality(SourceWriter writer, string selfType) - { - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public bool Equals("); - writer.Write(selfType); - writer.WriteLine(" other)"); - writer.OpenBlock(); - writer.WriteLine("return _handle == other._handle;"); - writer.CloseBlock(); - writer.WriteLine(); - - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.WriteLine("public override bool Equals(object? obj)"); - writer.OpenBlock(); - writer.Write("return obj is "); - writer.Write(selfType); - writer.WriteLine(" other && Equals(other);"); - writer.CloseBlock(); - writer.WriteLine(); - - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.WriteLine("public override int GetHashCode()"); - writer.OpenBlock(); - writer.WriteLine("return _handle.GetHashCode();"); - writer.CloseBlock(); - writer.WriteLine(); - - WriteOperators(writer, selfType); - } - - private static void WriteOperators(SourceWriter writer, string selfType) - { - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public static bool operator ==("); - writer.Write(selfType); - writer.Write(" left, "); - writer.Write(selfType); - writer.WriteLine(" right)"); - writer.OpenBlock(); - writer.WriteLine("return left.Equals(right);"); - writer.CloseBlock(); - writer.WriteLine(); - - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public static bool operator !=("); - writer.Write(selfType); - writer.Write(" left, "); - writer.Write(selfType); - writer.WriteLine(" right)"); - writer.OpenBlock(); - writer.WriteLine("return !left.Equals(right);"); - writer.CloseBlock(); - writer.WriteLine(); - } - - private static void WriteMarshaller(SourceWriter writer, string selfType) - { - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public static void Push("); - writer.Write(LuaApiNames.LuaState); - writer.Write(" state, "); - writer.Write(selfType); - writer.WriteLine(" value)"); - writer.OpenBlock(); - writer.WriteLine("value._handle.Push(state);"); - writer.CloseBlock(); - writer.WriteLine(); - - writer.WriteLine("/// "); - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("public static bool TryRead("); - writer.Write(LuaApiNames.LuaState); - writer.Write(" state, int index, out "); - writer.Write(selfType); - writer.WriteLine(" value)"); - writer.OpenBlock(); - writer.Write(LuaApiNames.CEObject); - writer.WriteLine(" __handle;"); - writer.Write("if ("); - writer.Write(LuaApiNames.CEObject); - writer.WriteLine(".TryRead(state, index, out __handle))"); - writer.OpenBlock(); - writer.WriteLine("value = new(__handle);"); - writer.WriteLine("return true;"); - writer.CloseBlock(); - writer.WriteLine(); - writer.WriteLine("value = default;"); - writer.WriteLine("return false;"); - writer.CloseBlock(); - } + /// Stable suffix for generated class-handle files. + public const string HintSuffix = ".LuaClass.g.cs"; + + private static readonly string GeneratedCodeAttribute = + GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaClassFileEmitter)); + + /// Returns the deterministic source hint name. + public static string HintName(LuaClassModel model) + { + return model.HintName; + } + + /// Writes the complete generated partial part. + public static SourceText Emit(LuaClassModel model) + { + SourceWriter writer = new(2048); + GeneratedCodeText.WriteFileHeader(writer); + OpenHandleType(writer, model.ContainingType); + WriteMembers(writer, model.ContainingType.FullyQualifiedName, model.ContainingType.Declarations[^1].Name); + CloseHandleType(writer, model.ContainingType); + return writer.ToSourceText(); + } + + private static void OpenHandleType(SourceWriter writer, ContainingTypeModel type) + { + if (type.Namespace.Length > 0) + { + writer.Write("namespace "); + writer.WriteLine(type.Namespace); + writer.OpenBlock(); + } + + for (int i = 0; i < type.Declarations.Length; i++) + { + TypeDeclarationModel declaration = type.Declarations[i]; + if (declaration.IsReadOnly) + { + writer.Write("readonly "); + } + + writer.Write("partial "); + writer.Write(declaration.Keyword); + writer.Write(' '); + writer.Write(declaration.Name); + if (i == type.Declarations.Length - 1) + { + writer.Write(" : global::System.IEquatable<"); + writer.Write(type.FullyQualifiedName); + writer.Write(">, "); + writer.Write(LuaApiNames.ICEObject); + writer.Write('<'); + writer.Write(type.FullyQualifiedName); + writer.Write(">, "); + writer.Write(LuaApiNames.ILuaMarshaller); + writer.Write('<'); + writer.Write(type.FullyQualifiedName); + writer.Write('>'); + } + + writer.WriteLine(); + writer.OpenBlock(); + } + } + + private static void CloseHandleType(SourceWriter writer, ContainingTypeModel type) + { + for (int i = 0; i < type.Declarations.Length; i++) + { + writer.CloseBlock(); + } + + if (type.Namespace.Length > 0) + { + writer.CloseBlock(); + } + } + + private static void WriteMembers(SourceWriter writer, string selfType, string constructorName) + { + WriteStorage(writer, selfType, constructorName); + WriteHandleConversion(writer, selfType); + WriteEquality(writer, selfType); + WriteMarshaller(writer, selfType); + } + + private static void WriteStorage(SourceWriter writer, string selfType, string constructorName) + { + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("private readonly "); + writer.Write(LuaApiNames.CEObject); + writer.WriteLine(" _handle;"); + writer.WriteLine(); + + writer.WriteLine(GeneratedCodeAttribute); + writer.WriteLine("[global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]"); + writer.Write("private "); + writer.Write(constructorName); + writer.Write('('); + writer.Write(LuaApiNames.CEObject); + writer.WriteLine(" handle)"); + writer.OpenBlock(); + writer.WriteLine("_handle = handle;"); + writer.CloseBlock(); + writer.WriteLine(); + } + + private static void WriteHandleConversion(SourceWriter writer, string selfType) + { + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public "); + writer.Write(LuaApiNames.CEObject); + writer.WriteLine(" Handle => _handle;"); + writer.WriteLine(); + + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public static "); + writer.Write(selfType); + writer.Write(" FromHandle("); + writer.Write(LuaApiNames.CEObject); + writer.WriteLine(" handle)"); + writer.OpenBlock(); + writer.WriteLine("return new(handle);"); + writer.CloseBlock(); + writer.WriteLine(); + } + + private static void WriteEquality(SourceWriter writer, string selfType) + { + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public bool Equals("); + writer.Write(selfType); + writer.WriteLine(" other)"); + writer.OpenBlock(); + writer.WriteLine("return _handle == other._handle;"); + writer.CloseBlock(); + writer.WriteLine(); + + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.WriteLine("public override bool Equals(object? obj)"); + writer.OpenBlock(); + writer.Write("return obj is "); + writer.Write(selfType); + writer.WriteLine(" other && Equals(other);"); + writer.CloseBlock(); + writer.WriteLine(); + + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.WriteLine("public override int GetHashCode()"); + writer.OpenBlock(); + writer.WriteLine("return _handle.GetHashCode();"); + writer.CloseBlock(); + writer.WriteLine(); + + WriteOperators(writer, selfType); + } + + private static void WriteOperators(SourceWriter writer, string selfType) + { + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public static bool operator ==("); + writer.Write(selfType); + writer.Write(" left, "); + writer.Write(selfType); + writer.WriteLine(" right)"); + writer.OpenBlock(); + writer.WriteLine("return left.Equals(right);"); + writer.CloseBlock(); + writer.WriteLine(); + + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public static bool operator !=("); + writer.Write(selfType); + writer.Write(" left, "); + writer.Write(selfType); + writer.WriteLine(" right)"); + writer.OpenBlock(); + writer.WriteLine("return !left.Equals(right);"); + writer.CloseBlock(); + writer.WriteLine(); + } + + private static void WriteMarshaller(SourceWriter writer, string selfType) + { + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public static void Push("); + writer.Write(LuaApiNames.LuaState); + writer.Write(" state, "); + writer.Write(selfType); + writer.WriteLine(" value)"); + writer.OpenBlock(); + writer.WriteLine("value._handle.Push(state);"); + writer.CloseBlock(); + writer.WriteLine(); + + writer.WriteLine("/// "); + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("public static bool TryRead("); + writer.Write(LuaApiNames.LuaState); + writer.Write(" state, int index, out "); + writer.Write(selfType); + writer.WriteLine(" value)"); + writer.OpenBlock(); + writer.Write(LuaApiNames.CEObject); + writer.WriteLine(" __handle;"); + writer.Write("if ("); + writer.Write(LuaApiNames.CEObject); + writer.WriteLine(".TryRead(state, index, out __handle))"); + writer.OpenBlock(); + writer.WriteLine("value = new(__handle);"); + writer.WriteLine("return true;"); + writer.CloseBlock(); + writer.WriteLine(); + writer.WriteLine("value = default;"); + writer.WriteLine("return false;"); + writer.CloseBlock(); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaFunctionFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaFunctionFileEmitter.cs index e938ca2a..d3a9111f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaFunctionFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaFunctionFileEmitter.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; @@ -19,82 +21,94 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; /// internal static class LuaFunctionFileEmitter { - /// Suffix of the hint name: Demo.Math.LuaFunctions.g.cs. - public const string HintSuffix = LuaFunctionTableModel.HintSuffix; - - // A [LuaFunction] target may be marked [Obsolete] by its own author; calling it from a file that author cannot - // edit must not warn (and would fail a build that treats warnings as errors). Unconditional, like the generated - // entry point's construction of an [Obsolete] plugin class (EntryPoint's BootstrapEmitter). - private const string ObsoleteWarningsOff = - "#pragma warning disable CS0612, CS0618 // a [LuaFunction] target may be marked [Obsolete]"; - - // Computed once: reads the assembly name and version of this generator. - private static readonly string GeneratedCodeAttribute = - GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaFunctionFileEmitter)); - - private static readonly string[] IdSeparator = [", "]; - - /// - /// The hint name of the file for : resolved once, across the whole pass, by - /// (), so that two types whose - /// names differ only in ASCII case still get distinct files. - /// - public static string HintName(LuaFunctionTableModel table) - { - return table.HintName; - } - - /// Emits the file for . - public static SourceText Emit(LuaFunctionTableModel table) - { - SourceWriter writer = new(4096); - GeneratedCodeText.WriteFileHeader(writer); - writer.WriteLine(ObsoleteWarningsOff); - WriteDeclaredDiagnosticIdsPragma(writer, table.Thunks); - writer.WriteLine(); - TypeScaffoldEmitter.Open(writer, table.ContainingType); - - LuaRegistrationEmitter.Emit(writer, table.Thunks, GeneratedCodeAttribute); - foreach (var thunk in table.Thunks) - { - writer.WriteLine(); - writer.WriteLine(GeneratedCodeAttribute); - LuaThunkEmitter.Emit(writer, thunk); - } - - TypeScaffoldEmitter.Close(writer, table.ContainingType); - return writer.ToSourceText(); - } - - // Same reason as ObsoleteWarningsOff, for IDs only a target (or a type it is nested in) knows: [Experimental] - // is an error by default, and a custom [Obsolete(DiagnosticId = ...)] is not covered by CS0612/CS0618. - private static void WriteDeclaredDiagnosticIdsPragma(SourceWriter writer, EquatableArray thunks) - { - var ids = CollectDeclaredDiagnosticIds(thunks); - if (ids.Length == 0) return; - - writer.Write("#pragma warning disable "); - writer.Write(ids); - writer.WriteLine(" // declared by a [LuaFunction] target: [Experimental] or [Obsolete(DiagnosticId = ...)]"); - } - - // The union, without duplicates, of every thunk's DeclaredDiagnosticIds (itself already comma-joined). - private static string CollectDeclaredDiagnosticIds(EquatableArray thunks) - { - List? ids = null; - foreach (var thunk in thunks) - { - if (thunk.DeclaredDiagnosticIds.Length == 0) continue; - - foreach (var id in thunk.DeclaredDiagnosticIds.Split(IdSeparator, StringSplitOptions.None)) - { - if (id.Length == 0) continue; - - ids ??= []; - if (!ids.Contains(id)) ids.Add(id); - } - } - - return ids is null ? string.Empty : string.Join(", ", ids); - } + /// Suffix of the hint name: Demo.Math.LuaFunctions.g.cs. + public const string HintSuffix = LuaFunctionTableModel.HintSuffix; + + // A [LuaFunction] target may be marked [Obsolete] by its own author; calling it from a file that author cannot + // edit must not warn (and would fail a build that treats warnings as errors). Unconditional, like the generated + // entry point's construction of an [Obsolete] plugin class (EntryPoint's BootstrapEmitter). + private const string ObsoleteWarningsOff = + "#pragma warning disable CS0612, CS0618 // a [LuaFunction] target may be marked [Obsolete]"; + + // Computed once: reads the assembly name and version of this generator. + private static readonly string GeneratedCodeAttribute = + GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaFunctionFileEmitter)); + + private static readonly string[] IdSeparator = [", "]; + + /// + /// The hint name of the file for : resolved once, across the whole pass, by + /// (), so that two types whose + /// names differ only in ASCII case still get distinct files. + /// + public static string HintName(LuaFunctionTableModel table) + { + return table.HintName; + } + + /// Emits the file for . + public static SourceText Emit(LuaFunctionTableModel table) + { + SourceWriter writer = new(4096); + GeneratedCodeText.WriteFileHeader(writer); + writer.WriteLine(ObsoleteWarningsOff); + WriteDeclaredDiagnosticIdsPragma(writer, table.Thunks); + writer.WriteLine(); + TypeScaffoldEmitter.Open(writer, table.ContainingType); + + LuaRegistrationEmitter.Emit(writer, table.Thunks, GeneratedCodeAttribute); + foreach (LuaThunkModel thunk in table.Thunks) + { + writer.WriteLine(); + writer.WriteLine(GeneratedCodeAttribute); + LuaThunkEmitter.Emit(writer, thunk); + } + + TypeScaffoldEmitter.Close(writer, table.ContainingType); + return writer.ToSourceText(); + } + + // Same reason as ObsoleteWarningsOff, for IDs only a target (or a type it is nested in) knows: [Experimental] + // is an error by default, and a custom [Obsolete(DiagnosticId = ...)] is not covered by CS0612/CS0618. + private static void WriteDeclaredDiagnosticIdsPragma(SourceWriter writer, EquatableArray thunks) + { + string ids = CollectDeclaredDiagnosticIds(thunks); + if (ids.Length == 0) + { + return; + } + + writer.Write("#pragma warning disable "); + writer.Write(ids); + writer.WriteLine(" // declared by a [LuaFunction] target: [Experimental] or [Obsolete(DiagnosticId = ...)]"); + } + + // The union, without duplicates, of every thunk's DeclaredDiagnosticIds (itself already comma-joined). + private static string CollectDeclaredDiagnosticIds(EquatableArray thunks) + { + List? ids = null; + foreach (LuaThunkModel thunk in thunks) + { + if (thunk.DeclaredDiagnosticIds.Length == 0) + { + continue; + } + + foreach (string id in thunk.DeclaredDiagnosticIds.Split(IdSeparator, StringSplitOptions.None)) + { + if (id.Length == 0) + { + continue; + } + + ids ??= []; + if (!ids.Contains(id)) + { + ids.Add(id); + } + } + } + + return ids is null ? string.Empty : string.Join(", ", ids); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaGlobalFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaGlobalFileEmitter.cs index 339eff70..81e75755 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaGlobalFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaGlobalFileEmitter.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; @@ -19,48 +20,48 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; /// internal static class LuaGlobalFileEmitter { - /// Suffix of the hint name: Demo.Memory.LuaGlobals.g.cs. - public const string HintSuffix = LuaGlobalTableModel.HintSuffix; + /// Suffix of the hint name: Demo.Memory.LuaGlobals.g.cs. + public const string HintSuffix = LuaGlobalTableModel.HintSuffix; - // Computed once: reads the assembly name and version of this generator. - private static readonly string GeneratedCodeAttribute = - GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaGlobalFileEmitter)); + // Computed once: reads the assembly name and version of this generator. + private static readonly string GeneratedCodeAttribute = + GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaGlobalFileEmitter)); - /// - /// The hint name of the file for : resolved once, across the whole pass, by - /// (), so that two types whose - /// names differ only in ASCII case still get distinct files. - /// - public static string HintName(LuaGlobalTableModel table) - { - return table.HintName; - } + /// + /// The hint name of the file for : resolved once, across the whole pass, by + /// (), so that two types whose + /// names differ only in ASCII case still get distinct files. + /// + public static string HintName(LuaGlobalTableModel table) + { + return table.HintName; + } - /// Emits the file for . - public static SourceText Emit(LuaGlobalTableModel table) - { - SourceWriter writer = new(4096); - GeneratedCodeText.WriteFileHeader(writer); - TypeScaffoldEmitter.Open(writer, table.ContainingType); + /// Emits the file for . + public static SourceText Emit(LuaGlobalTableModel table) + { + SourceWriter writer = new(4096); + GeneratedCodeText.WriteFileHeader(writer); + TypeScaffoldEmitter.Open(writer, table.ContainingType); - foreach (var global in table.CachedGlobals) - { - writer.WriteLine(GeneratedCodeAttribute); - writer.Write("private static readonly "); - writer.Write(LuaApiNames.LuaRef); - writer.Write(' '); - writer.Write(LuaGlobalCallModel.CacheFieldFor(global)); - writer.WriteLine(" = new();"); - } + foreach (string global in table.CachedGlobals) + { + writer.WriteLine(GeneratedCodeAttribute); + writer.Write("private static readonly "); + writer.Write(LuaApiNames.LuaRef); + writer.Write(' '); + writer.Write(LuaGlobalCallModel.CacheFieldFor(global)); + writer.WriteLine(" = new();"); + } - foreach (var call in table.Calls) - { - writer.WriteLine(); - writer.WriteLine(GeneratedCodeAttribute); - LuaGlobalCallEmitter.Emit(writer, call); - } + foreach (LuaGlobalCallModel call in table.Calls) + { + writer.WriteLine(); + writer.WriteLine(GeneratedCodeAttribute); + LuaGlobalCallEmitter.Emit(writer, call); + } - TypeScaffoldEmitter.Close(writer, table.ContainingType); - return writer.ToSourceText(); - } + TypeScaffoldEmitter.Close(writer, table.ContainingType); + return writer.ToSourceText(); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs index 3e7529b8..7af12ef8 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs @@ -1,8 +1,10 @@ using System; using System.Globalization; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; @@ -13,419 +15,459 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; /// internal static class LuaObjectMembersFileEmitter { - private const string State = "__ceState"; - private const string Operation = "__ceOperation"; - private const string Top = "__ceTop"; - private const string Status = "__ceStatus"; - private const string Result = "__ceResult"; - private const int StackCheckThreshold = LuaGlobalCallEmitter.StackCheckThreshold; - - private static readonly string GeneratedCodeAttribute = - GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaObjectMembersFileEmitter)); - - /// Returns the table's deterministic source hint name. - public static string HintName(LuaObjectMembersTableModel table) - { - return table.HintName; - } - - /// Writes one generated partial type file. - public static SourceText Emit(LuaObjectMembersTableModel table) - { - SourceWriter writer = new(4096); - GeneratedCodeText.WriteFileHeader(writer); - TypeScaffoldEmitter.Open(writer, table.ContainingType); - - foreach (var method in table.Methods) - { - writer.WriteLine(GeneratedCodeAttribute); - EmitMethod(writer, method); - writer.WriteLine(); - } - - foreach (var property in table.Properties) - { - writer.WriteLine(GeneratedCodeAttribute); - EmitProperty(writer, property); - writer.WriteLine(); - } - - TypeScaffoldEmitter.Close(writer, table.ContainingType); - return writer.ToSourceText(); - } - - private static void EmitMethod(SourceWriter writer, LuaObjectMethodModel model) - { - WriteMethodDeclaration(writer, model); - writer.OpenBlock(); - WriteOperationSetup(writer); - writer.WriteLine("try"); - writer.OpenBlock(); - WriteMethodCall(writer, model); - writer.CloseBlock(); - WriteTryExceptionCatch(writer, model); - WriteStackRestore(writer); - writer.CloseBlock(); - } - - private static void WriteMethodDeclaration(SourceWriter writer, LuaObjectMethodModel model) - { - writer.Write(model.Modifiers); - writer.Write(' '); - writer.Write(ReturnTypeName(model)); - writer.Write(' '); - writer.Write(model.MethodName); - WriteMethodParameters(writer, model); - writer.WriteLine(); - } - - private static void WriteOperationSetup(SourceWriter writer) - { - writer.Write("using "); - writer.Write(LuaApiNames.LuaRuntimeOperation); - writer.Write(' '); - writer.Write(Operation); - writer.Write(" = "); - writer.Write(LuaApiNames.AcquireOperation); - writer.WriteLine(";"); - writer.Write(LuaApiNames.LuaState); - writer.Write(' '); - writer.Write(State); - writer.Write(" = "); - writer.Write(Operation); - writer.Write(".State"); - writer.WriteLine(";"); - writer.Write("int "); - writer.Write(Top); - writer.Write(" = "); - writer.Write(State); - writer.WriteLine(".Top;"); - } - - private static void WriteTryExceptionCatch(SourceWriter writer, LuaObjectMethodModel model) - { - if (model.Form != LuaCallForm.Try) return; - - writer.Write("catch ("); - writer.Write(LuaApiNames.LuaException); - writer.WriteLine(")"); - writer.OpenBlock(); - foreach (var result in model.Results) WriteDefaultResult(writer, result); - - writer.WriteLine("return false;"); - writer.CloseBlock(); - } - - private static void WriteStackRestore(SourceWriter writer) - { - writer.WriteLine("finally"); - writer.OpenBlock(); - writer.Write(State); - writer.Write(".SetTop("); - writer.Write(Top); - writer.WriteLine(");"); - writer.CloseBlock(); - } - - private static void WriteMethodCall(SourceWriter writer, LuaObjectMethodModel model) - { - var requiredSlots = Math.Max(2 + model.Arguments.Length, 1 + model.ResultCount); - if (requiredSlots > StackCheckThreshold) WriteStackCheck(writer, model, requiredSlots); - - writer.Write(LuaApiNames.LuaStatus); - writer.Write(' '); - writer.Write(Status); - writer.Write(" = this.Handle.TryPushMethodLeavingObject("); - writer.Write(State); - writer.Write(", "); - writer.Write(CSharpLiteral.ToUtf8Literal(model.LuaName)); - writer.WriteLine(");"); - WriteStatusExit(writer, model); - - foreach (var argument in model.Arguments) - { - writer.Write(LuaValueKinds.MarshallerTypeName(argument.Kind)); - writer.Write(".Push("); - writer.Write(State); - writer.Write(", "); - writer.Write(argument.Name); - writer.WriteLine(");"); - } - - writer.Write(Status); - writer.Write(" = "); - writer.Write(State); - writer.Write(".TryCall("); - writer.Write(model.Arguments.Length.ToString(CultureInfo.InvariantCulture)); - writer.Write(", "); - writer.Write(model.ResultCount.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(");"); - WriteStatusExit(writer, model); - - if (model.Form == LuaCallForm.Try) - WriteTryResults(writer, model); - else - WriteThrowingResult(writer, model); - } - - private static void WriteStackCheck(SourceWriter writer, LuaObjectMethodModel model, int requiredSlots) - { - writer.Write("if (!"); - writer.Write(State); - writer.Write(".TryEnsureStack("); - writer.Write(requiredSlots.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine("))"); - writer.OpenBlock(); - if (model.Form == LuaCallForm.Try) - { - for (var i = 1; i < model.Results.Length; i++) WriteDefaultResult(writer, model.Results[i]); - - writer.Write("return "); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", out "); - writer.Write(model.Results[0].Name); - writer.WriteLine(");"); - } - else - { - writer.Write("throw new "); - writer.Write(LuaApiNames.LuaException); - writer.Write('('); - writer.Write(CSharpLiteral.ToStringLiteral( - "The Lua stack could not grow by " + requiredSlots.ToString(CultureInfo.InvariantCulture) - + " slots to call '" + model.LuaName + "'.")); - writer.WriteLine(");"); - } - - writer.CloseBlock(); - writer.WriteLine(); - } - - private static void WriteStatusExit(SourceWriter writer, LuaObjectMethodModel model) - { - writer.Write("if (!"); - writer.Write(Status); - writer.WriteLine(".IsOk)"); - writer.OpenBlock(); - if (model.Form == LuaCallForm.Try) - { - for (var i = 1; i < model.Results.Length; i++) WriteDefaultResult(writer, model.Results[i]); - - writer.Write("return "); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", out "); - writer.Write(model.Results[0].Name); - writer.WriteLine(");"); - } - else - { - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Throw("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", "); - writer.Write(Status); - writer.WriteLine(");"); - } - - writer.CloseBlock(); - } - - private static void WriteTryResults(SourceWriter writer, LuaObjectMethodModel model) - { - for (var i = 0; i < model.Results.Length; i++) - { - var result = model.Results[i]; - writer.Write("if (!"); - writer.Write(LuaValueKinds.MarshallerTypeName(result.Kind)); - writer.Write(".TryRead("); - writer.Write(State); - writer.Write(", "); - writer.Write((i - model.Results.Length).ToString(CultureInfo.InvariantCulture)); - writer.Write(", out "); - writer.Write(result.Name); - writer.WriteLine("))"); - writer.OpenBlock(); - for (var other = 0; other < model.Results.Length; other++) - if (other != i) - WriteDefaultResult(writer, model.Results[other]); - - writer.Write("return "); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", out "); - writer.Write(result.Name); - writer.WriteLine(");"); - writer.CloseBlock(); - } - - writer.WriteLine("return true;"); - } - - private static void WriteThrowingResult(SourceWriter writer, LuaObjectMethodModel model) - { - if (model.ReturnKind is not LuaValueKind kind) - { - writer.WriteLine("return;"); - return; - } - - writer.Write("if (!"); - writer.Write(LuaValueKinds.MarshallerTypeName(kind)); - writer.Write(".TryRead("); - writer.Write(State); - writer.Write(", -1, out "); - writer.Write(LuaValueKinds.TypeName(kind, isNullable: true)); - writer.Write(' '); - writer.Write(Result); - writer.WriteLine("))"); - writer.OpenBlock(); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".ThrowUnexpectedResult("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", -1, "); - writer.Write(CSharpLiteral.ToStringLiteral(model.LuaName)); - writer.Write(", "); - writer.Write(CSharpLiteral.ToStringLiteral(LuaValueKinds.ExpectedResult(kind))); - writer.WriteLine(");"); - writer.CloseBlock(); - writer.Write("return "); - writer.Write(Result); - writer.WriteLine(";"); - } - - private static void WriteMethodParameters(SourceWriter writer, LuaObjectMethodModel model) - { - writer.Write('('); - var first = true; - foreach (var argument in model.Arguments) - { - if (!first) writer.Write(", "); - - first = false; - if (argument.IsScoped) writer.Write("scoped "); - - writer.Write(LuaValueKinds.TypeName(argument.Kind, argument.IsNullable)); - writer.Write(' '); - writer.Write(argument.Name); - } - - if (model.Form == LuaCallForm.Try) - foreach (var result in model.Results) - { - if (!first) writer.Write(", "); - - first = false; - writer.Write("out "); - writer.Write(LuaValueKinds.TypeName(result.Kind, result.IsNullable)); - writer.Write(' '); - writer.Write(result.Name); - } - - writer.Write(')'); - } - - private static string ReturnTypeName(LuaObjectMethodModel model) - { - return model.Form == LuaCallForm.Try - ? "bool" - : model.ReturnKind is LuaValueKind kind - ? LuaValueKinds.TypeName(kind, model.ReturnIsNullable) - : "void"; - } - - private static void WriteDefaultResult(SourceWriter writer, LuaResultModel result) - { - writer.Write(result.Name); - writer.WriteLine(LuaValueKinds.IsReferenceType(result.Kind) ? " = default!;" : " = default;"); - } - - private static void EmitProperty(SourceWriter writer, LuaObjectPropertyModel model) - { - writer.Write(model.Modifiers); - writer.Write(' '); - writer.Write(LuaValueKinds.TypeName(model.Kind, model.IsNullable)); - writer.Write(' '); - writer.Write(model.PropertyName); - writer.WriteLine(); - writer.OpenBlock(); - - if (model.HasGetter) EmitGetter(writer, model); - - if (model.HasSetter) EmitSetter(writer, model); - - writer.CloseBlock(); - } - - private static void EmitGetter(SourceWriter writer, LuaObjectPropertyModel model) - { - WriteAccessorHeader(writer, model.GetterModifiers, "get"); - writer.OpenBlock(); - writer.Write("if (!this.Handle.TryGetProperty<"); - writer.Write(LuaValueKinds.MarshallerTypeName(model.Kind)); - writer.Write(", "); - writer.Write(LuaValueKinds.TypeName(model.Kind, model.IsNullable)); - writer.Write(">("); - writer.Write(CSharpLiteral.ToUtf8Literal(model.LuaName)); - writer.Write(", out "); - writer.Write(LuaValueKinds.TypeName(model.Kind, isNullable: true)); - writer.WriteLine(" __ceValue))"); - writer.OpenBlock(); - WritePropertyFailure(writer, model, "read"); - writer.CloseBlock(); - writer.WriteLine("return __ceValue;"); - writer.CloseBlock(); - } - - private static void EmitSetter(SourceWriter writer, LuaObjectPropertyModel model) - { - WriteAccessorHeader(writer, model.SetterModifiers, "set"); - writer.OpenBlock(); - writer.Write("if (!this.Handle.TrySetProperty<"); - writer.Write(LuaValueKinds.MarshallerTypeName(model.Kind)); - writer.Write(", "); - writer.Write(LuaValueKinds.TypeName(model.Kind, model.IsNullable)); - writer.Write(">("); - writer.Write(CSharpLiteral.ToUtf8Literal(model.LuaName)); - writer.WriteLine(", value))"); - writer.OpenBlock(); - WritePropertyFailure(writer, model, "write"); - writer.CloseBlock(); - writer.CloseBlock(); - } - - private static void WriteAccessorHeader(SourceWriter writer, string modifiers, string keyword) - { - if (modifiers.Length > 0) - { - writer.Write(modifiers); - writer.Write(' '); - } - - writer.WriteLine(keyword); - } - - private static void WritePropertyFailure(SourceWriter writer, LuaObjectPropertyModel model, string operation) - { - writer.Write("throw new "); - writer.Write(LuaApiNames.LuaException); - writer.Write('('); - writer.Write(CSharpLiteral.ToStringLiteral( - "The Cheat Engine object property '" + model.LuaName + "' could not be " + operation + ".")); - writer.WriteLine(");"); - } + private const string State = "__ceState"; + private const string Operation = "__ceOperation"; + private const string Top = "__ceTop"; + private const string Status = "__ceStatus"; + private const string Result = "__ceResult"; + private const int StackCheckThreshold = LuaGlobalCallEmitter.StackCheckThreshold; + + private static readonly string GeneratedCodeAttribute = + GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(LuaObjectMembersFileEmitter)); + + /// Returns the table's deterministic source hint name. + public static string HintName(LuaObjectMembersTableModel table) + { + return table.HintName; + } + + /// Writes one generated partial type file. + public static SourceText Emit(LuaObjectMembersTableModel table) + { + SourceWriter writer = new(4096); + GeneratedCodeText.WriteFileHeader(writer); + TypeScaffoldEmitter.Open(writer, table.ContainingType); + + foreach (LuaObjectMethodModel method in table.Methods) + { + writer.WriteLine(GeneratedCodeAttribute); + EmitMethod(writer, method); + writer.WriteLine(); + } + + foreach (LuaObjectPropertyModel property in table.Properties) + { + writer.WriteLine(GeneratedCodeAttribute); + EmitProperty(writer, property); + writer.WriteLine(); + } + + TypeScaffoldEmitter.Close(writer, table.ContainingType); + return writer.ToSourceText(); + } + + private static void EmitMethod(SourceWriter writer, LuaObjectMethodModel model) + { + WriteMethodDeclaration(writer, model); + writer.OpenBlock(); + WriteOperationSetup(writer); + writer.WriteLine("try"); + writer.OpenBlock(); + WriteMethodCall(writer, model); + writer.CloseBlock(); + WriteTryExceptionCatch(writer, model); + WriteStackRestore(writer); + writer.CloseBlock(); + } + + private static void WriteMethodDeclaration(SourceWriter writer, LuaObjectMethodModel model) + { + writer.Write(model.Modifiers); + writer.Write(' '); + writer.Write(ReturnTypeName(model)); + writer.Write(' '); + writer.Write(model.MethodName); + WriteMethodParameters(writer, model); + writer.WriteLine(); + } + + private static void WriteOperationSetup(SourceWriter writer) + { + writer.Write("using "); + writer.Write(LuaApiNames.LuaRuntimeOperation); + writer.Write(' '); + writer.Write(Operation); + writer.Write(" = "); + writer.Write(LuaApiNames.AcquireOperation); + writer.WriteLine(";"); + writer.Write(LuaApiNames.LuaState); + writer.Write(' '); + writer.Write(State); + writer.Write(" = "); + writer.Write(Operation); + writer.Write(".State"); + writer.WriteLine(";"); + writer.Write("int "); + writer.Write(Top); + writer.Write(" = "); + writer.Write(State); + writer.WriteLine(".Top;"); + } + + private static void WriteTryExceptionCatch(SourceWriter writer, LuaObjectMethodModel model) + { + if (model.Form != LuaCallForm.Try) + { + return; + } + + writer.Write("catch ("); + writer.Write(LuaApiNames.LuaException); + writer.WriteLine(")"); + writer.OpenBlock(); + foreach (LuaResultModel result in model.Results) + { + WriteDefaultResult(writer, result); + } + + writer.WriteLine("return false;"); + writer.CloseBlock(); + } + + private static void WriteStackRestore(SourceWriter writer) + { + writer.WriteLine("finally"); + writer.OpenBlock(); + writer.Write(State); + writer.Write(".SetTop("); + writer.Write(Top); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + private static void WriteMethodCall(SourceWriter writer, LuaObjectMethodModel model) + { + int requiredSlots = Math.Max(2 + model.Arguments.Length, 1 + model.ResultCount); + if (requiredSlots > StackCheckThreshold) + { + WriteStackCheck(writer, model, requiredSlots); + } + + writer.Write(LuaApiNames.LuaStatus); + writer.Write(' '); + writer.Write(Status); + writer.Write(" = this.Handle.TryPushMethodLeavingObject("); + writer.Write(State); + writer.Write(", "); + writer.Write(CSharpLiteral.ToUtf8Literal(model.LuaName)); + writer.WriteLine(");"); + WriteStatusExit(writer, model); + + foreach (LuaArgumentModel argument in model.Arguments) + { + writer.Write(LuaValueKinds.MarshallerTypeName(argument.Kind)); + writer.Write(".Push("); + writer.Write(State); + writer.Write(", "); + writer.Write(argument.Name); + writer.WriteLine(");"); + } + + writer.Write(Status); + writer.Write(" = "); + writer.Write(State); + writer.Write(".TryCall("); + writer.Write(model.Arguments.Length.ToString(CultureInfo.InvariantCulture)); + writer.Write(", "); + writer.Write(model.ResultCount.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(");"); + WriteStatusExit(writer, model); + + if (model.Form == LuaCallForm.Try) + { + WriteTryResults(writer, model); + } + else + { + WriteThrowingResult(writer, model); + } + } + + private static void WriteStackCheck(SourceWriter writer, LuaObjectMethodModel model, int requiredSlots) + { + writer.Write("if (!"); + writer.Write(State); + writer.Write(".TryEnsureStack("); + writer.Write(requiredSlots.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine("))"); + writer.OpenBlock(); + if (model.Form == LuaCallForm.Try) + { + for (int i = 1; i < model.Results.Length; i++) + { + WriteDefaultResult(writer, model.Results[i]); + } + + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", out "); + writer.Write(model.Results[0].Name); + writer.WriteLine(");"); + } + else + { + writer.Write("throw new "); + writer.Write(LuaApiNames.LuaException); + writer.Write('('); + writer.Write(CSharpLiteral.ToStringLiteral( + "The Lua stack could not grow by " + requiredSlots.ToString(CultureInfo.InvariantCulture) + + " slots to call '" + model.LuaName + "'.")); + writer.WriteLine(");"); + } + + writer.CloseBlock(); + writer.WriteLine(); + } + + private static void WriteStatusExit(SourceWriter writer, LuaObjectMethodModel model) + { + writer.Write("if (!"); + writer.Write(Status); + writer.WriteLine(".IsOk)"); + writer.OpenBlock(); + if (model.Form == LuaCallForm.Try) + { + for (int i = 1; i < model.Results.Length; i++) + { + WriteDefaultResult(writer, model.Results[i]); + } + + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", out "); + writer.Write(model.Results[0].Name); + writer.WriteLine(");"); + } + else + { + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Throw("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", "); + writer.Write(Status); + writer.WriteLine(");"); + } + + writer.CloseBlock(); + } + + private static void WriteTryResults(SourceWriter writer, LuaObjectMethodModel model) + { + for (int i = 0; i < model.Results.Length; i++) + { + LuaResultModel result = model.Results[i]; + writer.Write("if (!"); + writer.Write(LuaValueKinds.MarshallerTypeName(result.Kind)); + writer.Write(".TryRead("); + writer.Write(State); + writer.Write(", "); + writer.Write((i - model.Results.Length).ToString(CultureInfo.InvariantCulture)); + writer.Write(", out "); + writer.Write(result.Name); + writer.WriteLine("))"); + writer.OpenBlock(); + for (int other = 0; other < model.Results.Length; other++) + { + if (other != i) + { + WriteDefaultResult(writer, model.Results[other]); + } + } + + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", out "); + writer.Write(result.Name); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + writer.WriteLine("return true;"); + } + + private static void WriteThrowingResult(SourceWriter writer, LuaObjectMethodModel model) + { + if (model.ReturnKind is not LuaValueKind kind) + { + writer.WriteLine("return;"); + return; + } + + writer.Write("if (!"); + writer.Write(LuaValueKinds.MarshallerTypeName(kind)); + writer.Write(".TryRead("); + writer.Write(State); + writer.Write(", -1, out "); + writer.Write(LuaValueKinds.TypeName(kind, true)); + writer.Write(' '); + writer.Write(Result); + writer.WriteLine("))"); + writer.OpenBlock(); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".ThrowUnexpectedResult("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", -1, "); + writer.Write(CSharpLiteral.ToStringLiteral(model.LuaName)); + writer.Write(", "); + writer.Write(CSharpLiteral.ToStringLiteral(LuaValueKinds.ExpectedResult(kind))); + writer.WriteLine(");"); + writer.CloseBlock(); + writer.Write("return "); + writer.Write(Result); + writer.WriteLine(";"); + } + + private static void WriteMethodParameters(SourceWriter writer, LuaObjectMethodModel model) + { + writer.Write('('); + bool first = true; + foreach (LuaArgumentModel argument in model.Arguments) + { + if (!first) + { + writer.Write(", "); + } + + first = false; + if (argument.IsScoped) + { + writer.Write("scoped "); + } + + writer.Write(LuaValueKinds.TypeName(argument.Kind, argument.IsNullable)); + writer.Write(' '); + writer.Write(argument.Name); + } + + if (model.Form == LuaCallForm.Try) + { + foreach (LuaResultModel result in model.Results) + { + if (!first) + { + writer.Write(", "); + } + + first = false; + writer.Write("out "); + writer.Write(LuaValueKinds.TypeName(result.Kind, result.IsNullable)); + writer.Write(' '); + writer.Write(result.Name); + } + } + + writer.Write(')'); + } + + private static string ReturnTypeName(LuaObjectMethodModel model) + { + return model.Form == LuaCallForm.Try + ? "bool" + : model.ReturnKind is LuaValueKind kind + ? LuaValueKinds.TypeName(kind, model.ReturnIsNullable) + : "void"; + } + + private static void WriteDefaultResult(SourceWriter writer, LuaResultModel result) + { + writer.Write(result.Name); + writer.WriteLine(LuaValueKinds.IsReferenceType(result.Kind) ? " = default!;" : " = default;"); + } + + private static void EmitProperty(SourceWriter writer, LuaObjectPropertyModel model) + { + writer.Write(model.Modifiers); + writer.Write(' '); + writer.Write(LuaValueKinds.TypeName(model.Kind, model.IsNullable)); + writer.Write(' '); + writer.Write(model.PropertyName); + writer.WriteLine(); + writer.OpenBlock(); + + if (model.HasGetter) + { + EmitGetter(writer, model); + } + + if (model.HasSetter) + { + EmitSetter(writer, model); + } + + writer.CloseBlock(); + } + + private static void EmitGetter(SourceWriter writer, LuaObjectPropertyModel model) + { + WriteAccessorHeader(writer, model.GetterModifiers, "get"); + writer.OpenBlock(); + writer.Write("if (!this.Handle.TryGetProperty<"); + writer.Write(LuaValueKinds.MarshallerTypeName(model.Kind)); + writer.Write(", "); + writer.Write(LuaValueKinds.TypeName(model.Kind, model.IsNullable)); + writer.Write(">("); + writer.Write(CSharpLiteral.ToUtf8Literal(model.LuaName)); + writer.Write(", out "); + writer.Write(LuaValueKinds.TypeName(model.Kind, true)); + writer.WriteLine(" __ceValue))"); + writer.OpenBlock(); + WritePropertyFailure(writer, model, "read"); + writer.CloseBlock(); + writer.WriteLine("return __ceValue;"); + writer.CloseBlock(); + } + + private static void EmitSetter(SourceWriter writer, LuaObjectPropertyModel model) + { + WriteAccessorHeader(writer, model.SetterModifiers, "set"); + writer.OpenBlock(); + writer.Write("if (!this.Handle.TrySetProperty<"); + writer.Write(LuaValueKinds.MarshallerTypeName(model.Kind)); + writer.Write(", "); + writer.Write(LuaValueKinds.TypeName(model.Kind, model.IsNullable)); + writer.Write(">("); + writer.Write(CSharpLiteral.ToUtf8Literal(model.LuaName)); + writer.WriteLine(", value))"); + writer.OpenBlock(); + WritePropertyFailure(writer, model, "write"); + writer.CloseBlock(); + writer.CloseBlock(); + } + + private static void WriteAccessorHeader(SourceWriter writer, string modifiers, string keyword) + { + if (modifiers.Length > 0) + { + writer.Write(modifiers); + writer.Write(' '); + } + + writer.WriteLine(keyword); + } + + private static void WritePropertyFailure(SourceWriter writer, LuaObjectPropertyModel model, string operation) + { + writer.Write("throw new "); + writer.Write(LuaApiNames.LuaException); + writer.Write('('); + writer.Write(CSharpLiteral.ToStringLiteral( + "The Cheat Engine object property '" + model.LuaName + "' could not be " + operation + ".")); + writer.WriteLine(");"); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/TypeScaffoldEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/TypeScaffoldEmitter.cs index 54e8034c..96b2af30 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/TypeScaffoldEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/TypeScaffoldEmitter.cs @@ -17,33 +17,42 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; /// internal static class TypeScaffoldEmitter { - /// Opens the namespace (when not global) and the partial parts. - public static void Open(SourceWriter writer, ContainingTypeModel type) - { - if (type.Namespace.Length > 0) - { - writer.Write("namespace "); - writer.WriteLine(type.Namespace); - writer.OpenBlock(); - } + /// Opens the namespace (when not global) and the partial parts. + public static void Open(SourceWriter writer, ContainingTypeModel type) + { + if (type.Namespace.Length > 0) + { + writer.Write("namespace "); + writer.WriteLine(type.Namespace); + writer.OpenBlock(); + } - foreach (var declaration in type.Declarations) - { - if (declaration.IsReadOnly) writer.Write("readonly "); + foreach (TypeDeclarationModel declaration in type.Declarations) + { + if (declaration.IsReadOnly) + { + writer.Write("readonly "); + } - writer.Write("partial "); - writer.Write(declaration.Keyword); - writer.Write(' '); - writer.WriteLine(declaration.Name); - writer.OpenBlock(); - } - } + writer.Write("partial "); + writer.Write(declaration.Keyword); + writer.Write(' '); + writer.WriteLine(declaration.Name); + writer.OpenBlock(); + } + } - /// Closes what opened. - public static void Close(SourceWriter writer, ContainingTypeModel type) - { - for (var i = 0; i < type.Declarations.Length; i++) writer.CloseBlock(); + /// Closes what opened. + public static void Close(SourceWriter writer, ContainingTypeModel type) + { + for (int i = 0; i < type.Declarations.Length; i++) + { + writer.CloseBlock(); + } - if (type.Namespace.Length > 0) writer.CloseBlock(); - } + if (type.Namespace.Length > 0) + { + writer.CloseBlock(); + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsGenerator.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsGenerator.cs index 2e9243f1..04bf4133 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsGenerator.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsGenerator.cs @@ -1,7 +1,10 @@ +using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -30,143 +33,144 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings; [Generator(LanguageNames.CSharp)] public sealed class LuaBindingsGenerator : IIncrementalGenerator { - /// Metadata name of the function-export attribute (declared by CheatEngine.SDK.Annotations). - internal const string LuaFunctionAttributeMetadataName = AnnotationsMetadataNames.LuaFunctionAttribute; - - /// Metadata name of the global-binding attribute (declared by CheatEngine.SDK.Annotations). - internal const string LuaGlobalAttributeMetadataName = AnnotationsMetadataNames.LuaGlobalAttribute; - - /// Metadata name of the generated borrowed-object handle marker. - internal const string LuaClassAttributeMetadataName = AnnotationsMetadataNames.LuaClassAttribute; - - /// Metadata name of the generated object-method marker. - internal const string LuaMethodAttributeMetadataName = AnnotationsMetadataNames.LuaMethodAttribute; - - /// Metadata name of the generated object-property marker. - internal const string LuaPropertyAttributeMetadataName = AnnotationsMetadataNames.LuaPropertyAttribute; - - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - ConfigureFunctions(context); - ConfigureGlobals(context); - ConfigureObjectHandles(context); - ConfigureObjectMembers(context); - } - - private static void ConfigureFunctions(IncrementalGeneratorInitializationContext context) - { - // Reduced to a value before it is combined: the Compilation itself never equals its predecessor. - var facts = context.CompilationProvider - .Select(static (compilation, _) => CompilationFacts.From(compilation)) - .WithTrackingName(LuaBindingsTrackingNames.Facts); - - // Discovery is attribute-driven only; the predicate is purely syntactic and excludes what can never be - // bound (accessors, local functions, lambdas, properties). The transform is the single place where - // symbols are read; the grouping is where the per-type rules live. - var functionTables = context.SyntaxProvider - .ForAttributeWithMetadataName( - LuaFunctionAttributeMetadataName, - static (node, _) => node is MethodDeclarationSyntax, - static (attributeContext, cancellationToken) => - LuaFunctionParser.Parse(attributeContext, cancellationToken)) - .WithTrackingName(LuaBindingsTrackingNames.LuaFunction) - .Collect() - .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaFunctions) - .Select(static (models, _) => LuaFunctionTables.Group(models)) - .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTables) - .SelectMany(static (tables, _) => tables.AsImmutableArray()) - .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTable); - - var functionOutputs = functionTables - .Combine(facts) - .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTableAndFacts) - .Where(static pair => pair.Right.AllowUnsafeBlocks) - .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTableAllowed) - .Select(static (pair, _) => pair.Left) - .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionOutput); - - context.RegisterSourceOutput(functionOutputs, static (productionContext, table) => - productionContext.AddSource(LuaFunctionFileEmitter.HintName(table), LuaFunctionFileEmitter.Emit(table))); - } - - private static void ConfigureGlobals(IncrementalGeneratorInitializationContext context) - { - var globalTables = context.SyntaxProvider - .ForAttributeWithMetadataName( - LuaGlobalAttributeMetadataName, - static (node, _) => node is MethodDeclarationSyntax, - static (attributeContext, cancellationToken) => - LuaGlobalParser.Parse(attributeContext, cancellationToken)) - .WithTrackingName(LuaBindingsTrackingNames.LuaGlobal) - .Collect() - .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaGlobals) - .Select(static (models, _) => LuaGlobalTables.Group(models)) - .WithTrackingName(LuaBindingsTrackingNames.LuaGlobalTables) - .SelectMany(static (tables, _) => tables.AsImmutableArray()) - .Select(static (table, _) => table) - .WithTrackingName(LuaBindingsTrackingNames.LuaGlobalTable); - - // A global wrapper uses no function pointer or other unsafe syntax. It must remain available to the common - // consumer that only calls Cheat Engine Lua globals with false. - var globalOutputs = globalTables.WithTrackingName(LuaBindingsTrackingNames.LuaGlobalOutput); - - context.RegisterSourceOutput(globalOutputs, static (productionContext, table) => - productionContext.AddSource(LuaGlobalFileEmitter.HintName(table), LuaGlobalFileEmitter.Emit(table))); - } - - private static void ConfigureObjectHandles(IncrementalGeneratorInitializationContext context) - { - var luaClasses = context.SyntaxProvider - .ForAttributeWithMetadataName( - LuaClassAttributeMetadataName, - static (node, _) => node is StructDeclarationSyntax or RecordDeclarationSyntax, - static (attributeContext, cancellationToken) => - LuaClassParser.Parse(attributeContext, cancellationToken)) - .WithTrackingName(LuaBindingsTrackingNames.LuaClass) - .Collect() - .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaClasses) - .Select(static (models, _) => LuaClassTables.Select(models)) - .WithTrackingName(LuaBindingsTrackingNames.LuaClasses) - .SelectMany(static (models, _) => models.AsImmutableArray()) - .WithTrackingName(LuaBindingsTrackingNames.LuaClassOutput); - - context.RegisterSourceOutput(luaClasses, static (productionContext, model) => - productionContext.AddSource(LuaClassFileEmitter.HintName(model), LuaClassFileEmitter.Emit(model))); - } - - private static void ConfigureObjectMembers(IncrementalGeneratorInitializationContext context) - { - var objectMethods = context.SyntaxProvider - .ForAttributeWithMetadataName( - LuaMethodAttributeMetadataName, - static (node, _) => node is MethodDeclarationSyntax, - static (attributeContext, cancellationToken) => - LuaObjectMethodParser.Parse(attributeContext, cancellationToken)) - .WithTrackingName(LuaBindingsTrackingNames.LuaMethod) - .Collect() - .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaMethods); - - var objectProperties = context.SyntaxProvider - .ForAttributeWithMetadataName( - LuaPropertyAttributeMetadataName, - static (node, _) => node is PropertyDeclarationSyntax, - static (attributeContext, cancellationToken) => - LuaObjectPropertyParser.Parse(attributeContext, cancellationToken)) - .WithTrackingName(LuaBindingsTrackingNames.LuaProperty) - .Collect() - .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaProperties); - - var objectMemberTables = objectMethods - .Combine(objectProperties) - .WithTrackingName(LuaBindingsTrackingNames.LuaObjectMembersAndProperties) - .Select(static (pair, _) => LuaObjectMembersTables.Group(pair.Left, pair.Right)) - .WithTrackingName(LuaBindingsTrackingNames.LuaObjectMembersTables) - .SelectMany(static (tables, _) => tables.AsImmutableArray()) - .WithTrackingName(LuaBindingsTrackingNames.LuaObjectMembersOutput); - - context.RegisterSourceOutput(objectMemberTables, static (productionContext, table) => - productionContext.AddSource(LuaObjectMembersFileEmitter.HintName(table), - LuaObjectMembersFileEmitter.Emit(table))); - } + /// Metadata name of the function-export attribute (declared by CheatEngine.SDK.Annotations). + internal const string LuaFunctionAttributeMetadataName = AnnotationsMetadataNames.LuaFunctionAttribute; + + /// Metadata name of the global-binding attribute (declared by CheatEngine.SDK.Annotations). + internal const string LuaGlobalAttributeMetadataName = AnnotationsMetadataNames.LuaGlobalAttribute; + + /// Metadata name of the generated borrowed-object handle marker. + internal const string LuaClassAttributeMetadataName = AnnotationsMetadataNames.LuaClassAttribute; + + /// Metadata name of the generated object-method marker. + internal const string LuaMethodAttributeMetadataName = AnnotationsMetadataNames.LuaMethodAttribute; + + /// Metadata name of the generated object-property marker. + internal const string LuaPropertyAttributeMetadataName = AnnotationsMetadataNames.LuaPropertyAttribute; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + ConfigureFunctions(context); + ConfigureGlobals(context); + ConfigureObjectHandles(context); + ConfigureObjectMembers(context); + } + + private static void ConfigureFunctions(IncrementalGeneratorInitializationContext context) + { + // Reduced to a value before it is combined: the Compilation itself never equals its predecessor. + IncrementalValueProvider facts = context.CompilationProvider + .Select(static (compilation, _) => CompilationFacts.From(compilation)) + .WithTrackingName(LuaBindingsTrackingNames.Facts); + + // Discovery is attribute-driven only; the predicate is purely syntactic and excludes what can never be + // bound (accessors, local functions, lambdas, properties). The transform is the single place where + // symbols are read; the grouping is where the per-type rules live. + IncrementalValuesProvider functionTables = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaFunctionAttributeMetadataName, + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, cancellationToken) => + LuaFunctionParser.Parse(attributeContext, cancellationToken)) + .WithTrackingName(LuaBindingsTrackingNames.LuaFunction) + .Collect() + .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaFunctions) + .Select(static (models, _) => LuaFunctionTables.Group(models)) + .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTables) + .SelectMany(static (tables, _) => tables.AsImmutableArray()) + .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTable); + + IncrementalValuesProvider functionOutputs = functionTables + .Combine(facts) + .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTableAndFacts) + .Where(static pair => pair.Right.AllowUnsafeBlocks) + .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionTableAllowed) + .Select(static (pair, _) => pair.Left) + .WithTrackingName(LuaBindingsTrackingNames.LuaFunctionOutput); + + context.RegisterSourceOutput(functionOutputs, static (productionContext, table) => + productionContext.AddSource(LuaFunctionFileEmitter.HintName(table), LuaFunctionFileEmitter.Emit(table))); + } + + private static void ConfigureGlobals(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider globalTables = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaGlobalAttributeMetadataName, + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, cancellationToken) => + LuaGlobalParser.Parse(attributeContext, cancellationToken)) + .WithTrackingName(LuaBindingsTrackingNames.LuaGlobal) + .Collect() + .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaGlobals) + .Select(static (models, _) => LuaGlobalTables.Group(models)) + .WithTrackingName(LuaBindingsTrackingNames.LuaGlobalTables) + .SelectMany(static (tables, _) => tables.AsImmutableArray()) + .Select(static (table, _) => table) + .WithTrackingName(LuaBindingsTrackingNames.LuaGlobalTable); + + // A global wrapper uses no function pointer or other unsafe syntax. It must remain available to the common + // consumer that only calls Cheat Engine Lua globals with false. + IncrementalValuesProvider globalOutputs = + globalTables.WithTrackingName(LuaBindingsTrackingNames.LuaGlobalOutput); + + context.RegisterSourceOutput(globalOutputs, static (productionContext, table) => + productionContext.AddSource(LuaGlobalFileEmitter.HintName(table), LuaGlobalFileEmitter.Emit(table))); + } + + private static void ConfigureObjectHandles(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider luaClasses = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaClassAttributeMetadataName, + static (node, _) => node is StructDeclarationSyntax or RecordDeclarationSyntax, + static (attributeContext, cancellationToken) => + LuaClassParser.Parse(attributeContext, cancellationToken)) + .WithTrackingName(LuaBindingsTrackingNames.LuaClass) + .Collect() + .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaClasses) + .Select(static (models, _) => LuaClassTables.Select(models)) + .WithTrackingName(LuaBindingsTrackingNames.LuaClasses) + .SelectMany(static (models, _) => models.AsImmutableArray()) + .WithTrackingName(LuaBindingsTrackingNames.LuaClassOutput); + + context.RegisterSourceOutput(luaClasses, static (productionContext, model) => + productionContext.AddSource(LuaClassFileEmitter.HintName(model), LuaClassFileEmitter.Emit(model))); + } + + private static void ConfigureObjectMembers(IncrementalGeneratorInitializationContext context) + { + IncrementalValueProvider> objectMethods = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaMethodAttributeMetadataName, + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, cancellationToken) => + LuaObjectMethodParser.Parse(attributeContext, cancellationToken)) + .WithTrackingName(LuaBindingsTrackingNames.LuaMethod) + .Collect() + .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaMethods); + + IncrementalValueProvider> objectProperties = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaPropertyAttributeMetadataName, + static (node, _) => node is PropertyDeclarationSyntax, + static (attributeContext, cancellationToken) => + LuaObjectPropertyParser.Parse(attributeContext, cancellationToken)) + .WithTrackingName(LuaBindingsTrackingNames.LuaProperty) + .Collect() + .WithTrackingName(LuaBindingsTrackingNames.CollectedLuaProperties); + + IncrementalValuesProvider objectMemberTables = objectMethods + .Combine(objectProperties) + .WithTrackingName(LuaBindingsTrackingNames.LuaObjectMembersAndProperties) + .Select(static (pair, _) => LuaObjectMembersTables.Group(pair.Left, pair.Right)) + .WithTrackingName(LuaBindingsTrackingNames.LuaObjectMembersTables) + .SelectMany(static (tables, _) => tables.AsImmutableArray()) + .WithTrackingName(LuaBindingsTrackingNames.LuaObjectMembersOutput); + + context.RegisterSourceOutput(objectMemberTables, static (productionContext, table) => + productionContext.AddSource(LuaObjectMembersFileEmitter.HintName(table), + LuaObjectMembersFileEmitter.Emit(table))); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsTrackingNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsTrackingNames.cs index 5ef735f6..5f883329 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsTrackingNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/LuaBindingsTrackingNames.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; namespace CheatEngine.SDK.SourceGenerators.LuaBindings; @@ -9,89 +10,89 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings; /// internal static class LuaBindingsTrackingNames { - /// CompilationProvider.Select: the compilation facts (unsafe allowed?), reduced to a value. - public const string Facts = Prefix + "Facts"; + /// CompilationProvider.Select: the compilation facts (unsafe allowed?), reduced to a value. + public const string Facts = Prefix + "Facts"; - /// ForAttributeWithMetadataName transform: one LuaFunctionModel per attributed method. - public const string LuaFunction = Prefix + "LuaFunction"; + /// ForAttributeWithMetadataName transform: one LuaFunctionModel per attributed method. + public const string LuaFunction = Prefix + "LuaFunction"; - /// Collect: all [LuaFunction] models as an ImmutableArray. - public const string CollectedLuaFunctions = Prefix + "CollectedLuaFunctions"; + /// Collect: all [LuaFunction] models as an ImmutableArray. + public const string CollectedLuaFunctions = Prefix + "CollectedLuaFunctions"; - /// The models grouped by containing type into an EquatableArray of tables. - public const string LuaFunctionTables = Prefix + "LuaFunctionTables"; + /// The models grouped by containing type into an EquatableArray of tables. + public const string LuaFunctionTables = Prefix + "LuaFunctionTables"; - /// SelectMany: one LuaFunctionTableModel per containing type. - public const string LuaFunctionTable = Prefix + "LuaFunctionTable"; + /// SelectMany: one LuaFunctionTableModel per containing type. + public const string LuaFunctionTable = Prefix + "LuaFunctionTable"; - /// Combine of and . - public const string LuaFunctionTableAndFacts = Prefix + "LuaFunctionTableAndFacts"; + /// Combine of and . + public const string LuaFunctionTableAndFacts = Prefix + "LuaFunctionTableAndFacts"; - /// Where: the pairs whose compilation allows unsafe code. - public const string LuaFunctionTableAllowed = Prefix + "LuaFunctionTableAllowed"; + /// Where: the pairs whose compilation allows unsafe code. + public const string LuaFunctionTableAllowed = Prefix + "LuaFunctionTableAllowed"; - /// The final LuaFunctionTableModel: the only input of the functions source output. - public const string LuaFunctionOutput = Prefix + "LuaFunctionOutput"; + /// The final LuaFunctionTableModel: the only input of the functions source output. + public const string LuaFunctionOutput = Prefix + "LuaFunctionOutput"; - /// ForAttributeWithMetadataName transform: one LuaGlobalModel per attributed method. - public const string LuaGlobal = Prefix + "LuaGlobal"; + /// ForAttributeWithMetadataName transform: one LuaGlobalModel per attributed method. + public const string LuaGlobal = Prefix + "LuaGlobal"; - /// Collect: all [LuaGlobal] models as an ImmutableArray. - public const string CollectedLuaGlobals = Prefix + "CollectedLuaGlobals"; + /// Collect: all [LuaGlobal] models as an ImmutableArray. + public const string CollectedLuaGlobals = Prefix + "CollectedLuaGlobals"; - /// The models grouped by containing type into an EquatableArray of tables. - public const string LuaGlobalTables = Prefix + "LuaGlobalTables"; + /// The models grouped by containing type into an EquatableArray of tables. + public const string LuaGlobalTables = Prefix + "LuaGlobalTables"; - /// SelectMany: one LuaGlobalTableModel per containing type. - public const string LuaGlobalTable = Prefix + "LuaGlobalTable"; + /// SelectMany: one LuaGlobalTableModel per containing type. + public const string LuaGlobalTable = Prefix + "LuaGlobalTable"; - /// The final LuaGlobalTableModel: the only input of the globals source output. - public const string LuaGlobalOutput = Prefix + "LuaGlobalOutput"; + /// The final LuaGlobalTableModel: the only input of the globals source output. + public const string LuaGlobalOutput = Prefix + "LuaGlobalOutput"; - /// ForAttributeWithMetadataName transform: one LuaClassModel per annotated struct. - public const string LuaClass = Prefix + "LuaClass"; + /// ForAttributeWithMetadataName transform: one LuaClassModel per annotated struct. + public const string LuaClass = Prefix + "LuaClass"; - /// Collect: all [LuaClass] models. - public const string CollectedLuaClasses = Prefix + "CollectedLuaClasses"; + /// Collect: all [LuaClass] models. + public const string CollectedLuaClasses = Prefix + "CollectedLuaClasses"; - /// Filtered and ordered borrowed-handle models. - public const string LuaClasses = Prefix + "LuaClasses"; + /// Filtered and ordered borrowed-handle models. + public const string LuaClasses = Prefix + "LuaClasses"; - /// The input of the class-handle source output. - public const string LuaClassOutput = Prefix + "LuaClassOutput"; + /// The input of the class-handle source output. + public const string LuaClassOutput = Prefix + "LuaClassOutput"; - /// ForAttributeWithMetadataName transform: one LuaObjectMethodModel per method. - public const string LuaMethod = Prefix + "LuaMethod"; + /// ForAttributeWithMetadataName transform: one LuaObjectMethodModel per method. + public const string LuaMethod = Prefix + "LuaMethod"; - /// Collect: all [LuaMethod] models. - public const string CollectedLuaMethods = Prefix + "CollectedLuaMethods"; + /// Collect: all [LuaMethod] models. + public const string CollectedLuaMethods = Prefix + "CollectedLuaMethods"; - /// ForAttributeWithMetadataName transform: one LuaObjectPropertyModel per property. - public const string LuaProperty = Prefix + "LuaProperty"; + /// ForAttributeWithMetadataName transform: one LuaObjectPropertyModel per property. + public const string LuaProperty = Prefix + "LuaProperty"; - /// Collect: all [LuaProperty] models. - public const string CollectedLuaProperties = Prefix + "CollectedLuaProperties"; + /// Collect: all [LuaProperty] models. + public const string CollectedLuaProperties = Prefix + "CollectedLuaProperties"; - /// The paired object-member collections. - public const string LuaObjectMembersAndProperties = Prefix + "LuaObjectMembersAndProperties"; + /// The paired object-member collections. + public const string LuaObjectMembersAndProperties = Prefix + "LuaObjectMembersAndProperties"; - /// Object members grouped by generated borrowed-handle type. - public const string LuaObjectMembersTables = Prefix + "LuaObjectMembersTables"; + /// Object members grouped by generated borrowed-handle type. + public const string LuaObjectMembersTables = Prefix + "LuaObjectMembersTables"; - /// The input of the object-member source output. - public const string LuaObjectMembersOutput = Prefix + "LuaObjectMembersOutput"; + /// The input of the object-member source output. + public const string LuaObjectMembersOutput = Prefix + "LuaObjectMembersOutput"; - private const string Prefix = TrackingNames.Prefix + "LuaBindings."; + private const string Prefix = TrackingNames.Prefix + "LuaBindings."; - /// All of the above, for tests that must not miss a step. - public static readonly ImmutableArray All = - [ - Facts, - LuaFunction, CollectedLuaFunctions, LuaFunctionTables, LuaFunctionTable, LuaFunctionTableAndFacts, - LuaFunctionTableAllowed, LuaFunctionOutput, - LuaGlobal, CollectedLuaGlobals, LuaGlobalTables, LuaGlobalTable, LuaGlobalOutput, - LuaClass, CollectedLuaClasses, LuaClasses, LuaClassOutput, - LuaMethod, CollectedLuaMethods, LuaProperty, CollectedLuaProperties, LuaObjectMembersAndProperties, - LuaObjectMembersTables, LuaObjectMembersOutput, - ]; + /// All of the above, for tests that must not miss a step. + public static readonly ImmutableArray All = + [ + Facts, + LuaFunction, CollectedLuaFunctions, LuaFunctionTables, LuaFunctionTable, LuaFunctionTableAndFacts, + LuaFunctionTableAllowed, LuaFunctionOutput, + LuaGlobal, CollectedLuaGlobals, LuaGlobalTables, LuaGlobalTable, LuaGlobalOutput, + LuaClass, CollectedLuaClasses, LuaClasses, LuaClassOutput, + LuaMethod, CollectedLuaMethods, LuaProperty, CollectedLuaProperties, LuaObjectMembersAndProperties, + LuaObjectMembersTables, LuaObjectMembersOutput + ]; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/CompilationFacts.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/CompilationFacts.cs index 5452f8cf..229ebdc4 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/CompilationFacts.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/CompilationFacts.cs @@ -14,9 +14,9 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// internal readonly record struct CompilationFacts(bool AllowUnsafeBlocks) { - /// Reads the facts from ; a non-C# compilation reads as "unsafe not allowed". - public static CompilationFacts From(Compilation compilation) - { - return new CompilationFacts(compilation?.Options is CSharpCompilationOptions { AllowUnsafe: true }); - } + /// Reads the facts from ; a non-C# compilation reads as "unsafe not allowed". + public static CompilationFacts From(Compilation compilation) + { + return new CompilationFacts(compilation?.Options is CSharpCompilationOptions { AllowUnsafe: true }); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/ContainingTypeModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/ContainingTypeModel.cs index 51f28d23..f714ee5f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/ContainingTypeModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/ContainingTypeModel.cs @@ -20,7 +20,7 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// escapes). /// internal sealed record ContainingTypeModel( - string Namespace, - EquatableArray Declarations, - string FullyQualifiedName, - string HintBaseName); + string Namespace, + EquatableArray Declarations, + string FullyQualifiedName, + string HintBaseName); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassModel.cs index ec107cf9..adec7b2c 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassModel.cs @@ -5,7 +5,7 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// an unchanged type remains unchanged to the incremental pipeline. /// internal sealed record LuaClassModel( - ContainingTypeModel ContainingType, - string LuaName, - bool IsValid, - string HintName = ""); + ContainingTypeModel ContainingType, + string LuaName, + bool IsValid, + string HintName = ""); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassTables.cs index f3ced531..35774586 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaClassTables.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Emit; using CheatEngine.SDK.SourceGenerators.Shared; @@ -8,35 +9,39 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// Filters and orders class handles while assigning deterministic, collision-free source hint names. internal static class LuaClassTables { - /// Produces every valid class model, ordered by fully-qualified type name. - public static EquatableArray Select(ImmutableArray models) - { - List selected = []; - foreach (var model in models) - if (model.IsValid) - selected.Add(model); + /// Produces every valid class model, ordered by fully-qualified type name. + public static EquatableArray Select(ImmutableArray models) + { + List selected = []; + foreach (LuaClassModel model in models) + { + if (model.IsValid) + { + selected.Add(model); + } + } - selected.Sort(static (left, right) => - string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); + selected.Sort(static (left, right) => + string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); - return new EquatableArray([.. AssignHintNames(selected)]); - } + return new EquatableArray([.. AssignHintNames(selected)]); + } - // Source hints are case-insensitive to Roslyn even when type names are not. Resolve them after the stable type - // order is known: the shared allocator reserves the readable candidate, then a deterministic hash and ordinal - // suffixes, so every valid class keeps its output across repeated runs. - private static List AssignHintNames(List models) - { - var used = HintNames.CreateUsedNames(); - for (var i = 0; i < models.Count; i++) - { - var baseName = models[i].ContainingType.HintBaseName; - models[i] = models[i] with - { - HintName = HintNames.AllocateUnique(baseName, LuaClassFileEmitter.HintSuffix, used), - }; - } + // Source hints are case-insensitive to Roslyn even when type names are not. Resolve them after the stable type + // order is known: the shared allocator reserves the readable candidate, then a deterministic hash and ordinal + // suffixes, so every valid class keeps its output across repeated runs. + private static List AssignHintNames(List models) + { + HashSet used = HintNames.CreateUsedNames(); + for (int i = 0; i < models.Count; i++) + { + string baseName = models[i].ContainingType.HintBaseName; + models[i] = models[i] with + { + HintName = HintNames.AllocateUnique(baseName, LuaClassFileEmitter.HintSuffix, used) + }; + } - return models; - } + return models; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs index dc56585d..41dc2aa4 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs @@ -23,17 +23,17 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// This is separate from a signature issue so CESDK2007 can explain the local collision precisely. /// internal sealed record LuaFunctionModel( - ContainingTypeModel ContainingType, - ContainingTypeIssues ContainingTypeIssues, - string LuaName, - LuaFunctionShapeIssues Issues, - LuaThunkModel? Thunk, - bool HasGeneratedIdentityCollision = false) + ContainingTypeModel ContainingType, + ContainingTypeIssues ContainingTypeIssues, + string LuaName, + LuaFunctionShapeIssues Issues, + LuaThunkModel? Thunk, + bool HasGeneratedIdentityCollision = false) { - /// - /// when a thunk can be emitted for this method (before the duplicate-name check of its - /// group). - /// - public bool IsValid => Issues == LuaFunctionShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && - Thunk is not null && !HasGeneratedIdentityCollision; + /// + /// when a thunk can be emitted for this method (before the duplicate-name check of its + /// group). + /// + public bool IsValid => Issues == LuaFunctionShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && + Thunk is not null && !HasGeneratedIdentityCollision; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTableModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTableModel.cs index fa44243a..5ef1e489 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTableModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTableModel.cs @@ -15,14 +15,14 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// alone cannot tell apart) still get distinct names. /// internal sealed record LuaFunctionTableModel( - ContainingTypeModel ContainingType, - EquatableArray Thunks, - string HintName) + ContainingTypeModel ContainingType, + EquatableArray Thunks, + string HintName) { - /// - /// Suffix of the hint name: Demo.Math.LuaFunctions.g.cs. Owned here because - /// needs it to resolve ; - /// Emit/LuaFunctionFileEmitter.cs reuses this constant. - /// - public const string HintSuffix = ".LuaFunctions.g.cs"; + /// + /// Suffix of the hint name: Demo.Math.LuaFunctions.g.cs. Owned here because + /// needs it to resolve ; + /// Emit/LuaFunctionFileEmitter.cs reuses this constant. + /// + public const string HintSuffix = ".LuaFunctions.g.cs"; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs index a54a438f..203502fc 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; @@ -12,74 +13,86 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// internal static class LuaFunctionTables { - /// - /// Groups the valid models by containing type, drops every member of a name that appears twice in a type, and - /// sorts tables by type name and thunks by Lua name (ordinal) so that the output is deterministic and a table - /// compares equal to its previous value when nothing in that type changed. - /// - public static EquatableArray Group(ImmutableArray models) - { - if (models.IsDefaultOrEmpty) return EquatableArray.Empty; + /// + /// Groups the valid models by containing type, drops every member of a name that appears twice in a type, and + /// sorts tables by type name and thunks by Lua name (ordinal) so that the output is deterministic and a table + /// compares equal to its previous value when nothing in that type changed. + /// + public static EquatableArray Group(ImmutableArray models) + { + if (models.IsDefaultOrEmpty) + { + return EquatableArray.Empty; + } - Dictionary> groups = new(StringComparer.Ordinal); - foreach (var model in models) - { - if (!model.IsValid) continue; + Dictionary> groups = new(StringComparer.Ordinal); + foreach (LuaFunctionModel model in models) + { + if (!model.IsValid) + { + continue; + } - var key = model.ContainingType.FullyQualifiedName; - if (!groups.TryGetValue(key, out var members)) - { - members = []; - groups.Add(key, members); - } + string key = model.ContainingType.FullyQualifiedName; + if (!groups.TryGetValue(key, out List? members)) + { + members = []; + groups.Add(key, members); + } - members.Add(model); - } + members.Add(model); + } - List tables = []; - foreach (var group in groups) - { - var thunks = SelectThunks(group.Value); - if (!thunks.IsEmpty) - tables.Add(new LuaFunctionTableModel(group.Value[0].ContainingType, thunks, string.Empty)); - } + List tables = []; + foreach (KeyValuePair> group in groups) + { + EquatableArray thunks = SelectThunks(group.Value); + if (!thunks.IsEmpty) + { + tables.Add(new LuaFunctionTableModel(group.Value[0].ContainingType, thunks, string.Empty)); + } + } - tables.Sort(static (left, right) => - string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); - return new EquatableArray([.. AssignHintNames(tables)]); - } + tables.Sort(static (left, right) => + string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); + return new EquatableArray([.. AssignHintNames(tables)]); + } - // Hint names are resolved across every table of the pass because Roslyn compares them case-insensitively. The - // shared allocator reserves the readable candidate, then a deterministic hash candidate, then ordinal suffixes. - private static List AssignHintNames(List tables) - { - var used = HintNames.CreateUsedNames(); - for (var i = 0; i < tables.Count; i++) - { - var baseName = tables[i].ContainingType.HintBaseName; - var hintName = HintNames.AllocateUnique(baseName, LuaFunctionTableModel.HintSuffix, used); - tables[i] = tables[i] with { HintName = hintName }; - } + // Hint names are resolved across every table of the pass because Roslyn compares them case-insensitively. The + // shared allocator reserves the readable candidate, then a deterministic hash candidate, then ordinal suffixes. + private static List AssignHintNames(List tables) + { + HashSet used = HintNames.CreateUsedNames(); + for (int i = 0; i < tables.Count; i++) + { + string baseName = tables[i].ContainingType.HintBaseName; + string hintName = HintNames.AllocateUnique(baseName, LuaFunctionTableModel.HintSuffix, used); + tables[i] = tables[i] with { HintName = hintName }; + } - return tables; - } + return tables; + } - // The thunks of one type without the duplicated names, sorted by Lua name. - private static EquatableArray SelectThunks(List members) - { - Dictionary occurrences = new(StringComparer.Ordinal); - foreach (var member in members) - { - occurrences.TryGetValue(member.LuaName, out var count); - occurrences[member.LuaName] = count + 1; - } + // The thunks of one type without the duplicated names, sorted by Lua name. + private static EquatableArray SelectThunks(List members) + { + Dictionary occurrences = new(StringComparer.Ordinal); + foreach (LuaFunctionModel member in members) + { + occurrences.TryGetValue(member.LuaName, out int count); + occurrences[member.LuaName] = count + 1; + } - List thunks = []; - foreach (var member in members) - if (occurrences[member.LuaName] == 1) - thunks.Add(member.Thunk!); + List thunks = []; + foreach (LuaFunctionModel member in members) + { + if (occurrences[member.LuaName] == 1) + { + thunks.Add(member.Thunk!); + } + } - thunks.Sort(static (left, right) => string.CompareOrdinal(left.LuaName, right.LuaName)); - return new EquatableArray([.. thunks]); - } + thunks.Sort(static (left, right) => string.CompareOrdinal(left.LuaName, right.LuaName)); + return new EquatableArray([.. thunks]); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalModel.cs index 4af1253a..4057e841 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalModel.cs @@ -24,14 +24,14 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// was unsupported. /// internal sealed record LuaGlobalModel( - ContainingTypeModel ContainingType, - ContainingTypeIssues ContainingTypeIssues, - LuaGlobalShapeIssues Issues, - LuaGlobalCallModel? Call, - string SortKey, - bool HasGeneratedIdentityCollision = false) + ContainingTypeModel ContainingType, + ContainingTypeIssues ContainingTypeIssues, + LuaGlobalShapeIssues Issues, + LuaGlobalCallModel? Call, + string SortKey, + bool HasGeneratedIdentityCollision = false) { - /// when a body can be emitted for this method. - public bool IsValid => Issues == LuaGlobalShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && - Call is not null && !HasGeneratedIdentityCollision; + /// when a body can be emitted for this method. + public bool IsValid => Issues == LuaGlobalShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && + Call is not null && !HasGeneratedIdentityCollision; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTableModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTableModel.cs index a5164fdd..94c0cf55 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTableModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTableModel.cs @@ -17,15 +17,15 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// alone cannot tell apart) still get distinct names. /// internal sealed record LuaGlobalTableModel( - ContainingTypeModel ContainingType, - EquatableArray CachedGlobals, - EquatableArray Calls, - string HintName) + ContainingTypeModel ContainingType, + EquatableArray CachedGlobals, + EquatableArray Calls, + string HintName) { - /// - /// Suffix of the hint name: Demo.Memory.LuaGlobals.g.cs. Owned here because - /// needs it to resolve ; - /// Emit/LuaGlobalFileEmitter.cs reuses this constant. - /// - public const string HintSuffix = ".LuaGlobals.g.cs"; + /// + /// Suffix of the hint name: Demo.Memory.LuaGlobals.g.cs. Owned here because + /// needs it to resolve ; + /// Emit/LuaGlobalFileEmitter.cs reuses this constant. + /// + public const string HintSuffix = ".LuaGlobals.g.cs"; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs index 12d7ea07..8b4cf4b5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; @@ -13,71 +14,80 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// internal static class LuaGlobalTables { - /// - /// Groups the valid models by containing type and sorts tables by type name, bodies by their sort key and cached - /// globals by name (all ordinal), so that the output is deterministic and a table compares equal to its previous - /// value when nothing in that type changed. - /// - public static EquatableArray Group(ImmutableArray models) - { - if (models.IsDefaultOrEmpty) return EquatableArray.Empty; + /// + /// Groups the valid models by containing type and sorts tables by type name, bodies by their sort key and cached + /// globals by name (all ordinal), so that the output is deterministic and a table compares equal to its previous + /// value when nothing in that type changed. + /// + public static EquatableArray Group(ImmutableArray models) + { + if (models.IsDefaultOrEmpty) + { + return EquatableArray.Empty; + } - Dictionary> groups = new(StringComparer.Ordinal); - foreach (var model in models) - { - if (!model.IsValid) continue; + Dictionary> groups = new(StringComparer.Ordinal); + foreach (LuaGlobalModel model in models) + { + if (!model.IsValid) + { + continue; + } - var key = model.ContainingType.FullyQualifiedName; - if (!groups.TryGetValue(key, out var members)) - { - members = []; - groups.Add(key, members); - } + string key = model.ContainingType.FullyQualifiedName; + if (!groups.TryGetValue(key, out List? members)) + { + members = []; + groups.Add(key, members); + } - members.Add(model); - } + members.Add(model); + } - List tables = []; - foreach (var group in groups) tables.Add(CreateTable(group.Value)); + List tables = []; + foreach (KeyValuePair> group in groups) + { + tables.Add(CreateTable(group.Value)); + } - tables.Sort(static (left, right) => - string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); - return new EquatableArray([.. AssignHintNames(tables)]); - } + tables.Sort(static (left, right) => + string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); + return new EquatableArray([.. AssignHintNames(tables)]); + } - private static LuaGlobalTableModel CreateTable(List members) - { - members.Sort(static (left, right) => string.CompareOrdinal(left.SortKey, right.SortKey)); + private static LuaGlobalTableModel CreateTable(List members) + { + members.Sort(static (left, right) => string.CompareOrdinal(left.SortKey, right.SortKey)); - // The sorted set is the list of distinct names in ordinal order, so nothing is sorted afterwards. - SortedSet globals = new(StringComparer.Ordinal); - List calls = new(members.Count); - foreach (var member in members) - { - var call = member.Call!; - calls.Add(call); - globals.Add(call.GlobalName); - } + // The sorted set is the list of distinct names in ordinal order, so nothing is sorted afterwards. + SortedSet globals = new(StringComparer.Ordinal); + List calls = new(members.Count); + foreach (LuaGlobalModel member in members) + { + LuaGlobalCallModel call = member.Call!; + calls.Add(call); + globals.Add(call.GlobalName); + } - return new LuaGlobalTableModel( - members[0].ContainingType, - new EquatableArray([.. globals]), - new EquatableArray([.. calls]), - string.Empty); - } + return new LuaGlobalTableModel( + members[0].ContainingType, + new EquatableArray([.. globals]), + new EquatableArray([.. calls]), + string.Empty); + } - // Hint names are resolved across every table of the pass because Roslyn compares them case-insensitively. The - // shared allocator reserves the readable candidate, then a deterministic hash candidate, then ordinal suffixes. - private static List AssignHintNames(List tables) - { - var used = HintNames.CreateUsedNames(); - for (var i = 0; i < tables.Count; i++) - { - var baseName = tables[i].ContainingType.HintBaseName; - var hintName = HintNames.AllocateUnique(baseName, LuaGlobalTableModel.HintSuffix, used); - tables[i] = tables[i] with { HintName = hintName }; - } + // Hint names are resolved across every table of the pass because Roslyn compares them case-insensitively. The + // shared allocator reserves the readable candidate, then a deterministic hash candidate, then ordinal suffixes. + private static List AssignHintNames(List tables) + { + HashSet used = HintNames.CreateUsedNames(); + for (int i = 0; i < tables.Count; i++) + { + string baseName = tables[i].ContainingType.HintBaseName; + string hintName = HintNames.AllocateUnique(baseName, LuaGlobalTableModel.HintSuffix, used); + tables[i] = tables[i] with { HintName = hintName }; + } - return tables; - } + return tables; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTableModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTableModel.cs index 0ea23091..27bf023f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTableModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTableModel.cs @@ -4,7 +4,7 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// All valid object methods and properties generated into one partial type file. internal sealed record LuaObjectMembersTableModel( - ContainingTypeModel ContainingType, - EquatableArray Methods, - EquatableArray Properties, - string HintName); + ContainingTypeModel ContainingType, + EquatableArray Methods, + EquatableArray Properties, + string HintName); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTables.cs index 4063b027..cd314048 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMembersTables.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; @@ -8,81 +9,99 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// Groups valid object methods and properties by their borrowed-handle type in deterministic emission order. internal static class LuaObjectMembersTables { - /// Stable suffix for one type's generated object-member file. - public const string HintSuffix = ".LuaObjectMembers.g.cs"; - - /// Creates tables without allowing an invalid member to suppress unrelated valid declarations. - public static EquatableArray Group(ImmutableArray methods, - ImmutableArray properties) - { - Dictionary groups = new(StringComparer.Ordinal); - AddMethods(groups, methods); - AddProperties(groups, properties); - - List tables = []; - foreach (var pair in groups) - { - pair.Value.Methods.Sort(static (left, right) => string.CompareOrdinal(left.SortKey, right.SortKey)); - pair.Value.Properties.Sort(static (left, right) => string.CompareOrdinal(left.SortKey, right.SortKey)); - tables.Add(new LuaObjectMembersTableModel( - pair.Value.ContainingType, - new EquatableArray([.. pair.Value.Methods]), - new EquatableArray([.. pair.Value.Properties]), - string.Empty)); - } - - tables.Sort(static (left, right) => - string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); - AssignHintNames(tables); - return new EquatableArray([.. tables]); - } - - private static void AddMethods(Dictionary groups, ImmutableArray methods) - { - foreach (var method in methods) - { - if (!method.IsValid) continue; - - GetOrCreate(groups, method.ContainingType).Methods.Add(method); - } - } - - private static void AddProperties(Dictionary groups, - ImmutableArray properties) - { - foreach (var property in properties) - { - if (!property.IsValid) continue; - - GetOrCreate(groups, property.ContainingType).Properties.Add(property); - } - } - - private static Members GetOrCreate(Dictionary groups, ContainingTypeModel type) - { - if (groups.TryGetValue(type.FullyQualifiedName, out var members)) return members; - - members = new Members(type); - groups.Add(type.FullyQualifiedName, members); - return members; - } - - private static void AssignHintNames(List tables) - { - var used = HintNames.CreateUsedNames(); - for (var i = 0; i < tables.Count; i++) - { - var baseName = tables[i].ContainingType.HintBaseName; - tables[i] = tables[i] with { HintName = HintNames.AllocateUnique(baseName, HintSuffix, used) }; - } - } - - private sealed class Members(ContainingTypeModel containingType) - { - public ContainingTypeModel ContainingType { get; } = containingType; - - public List Methods { get; } = []; - - public List Properties { get; } = []; - } + /// Stable suffix for one type's generated object-member file. + public const string HintSuffix = ".LuaObjectMembers.g.cs"; + + /// Creates tables without allowing an invalid member to suppress unrelated valid declarations. + public static EquatableArray Group(ImmutableArray methods, + ImmutableArray properties) + { + Dictionary groups = new(StringComparer.Ordinal); + AddMethods(groups, methods); + AddProperties(groups, properties); + + List tables = []; + foreach (KeyValuePair pair in groups) + { + pair.Value.Methods.Sort(static (left, right) => string.CompareOrdinal(left.SortKey, right.SortKey)); + pair.Value.Properties.Sort(static (left, right) => string.CompareOrdinal(left.SortKey, right.SortKey)); + tables.Add(new LuaObjectMembersTableModel( + pair.Value.ContainingType, + new EquatableArray([.. pair.Value.Methods]), + new EquatableArray([.. pair.Value.Properties]), + string.Empty)); + } + + tables.Sort(static (left, right) => + string.CompareOrdinal(left.ContainingType.FullyQualifiedName, right.ContainingType.FullyQualifiedName)); + AssignHintNames(tables); + return new EquatableArray([.. tables]); + } + + private static void AddMethods(Dictionary groups, ImmutableArray methods) + { + foreach (LuaObjectMethodModel method in methods) + { + if (!method.IsValid) + { + continue; + } + + GetOrCreate(groups, method.ContainingType).Methods.Add(method); + } + } + + private static void AddProperties(Dictionary groups, + ImmutableArray properties) + { + foreach (LuaObjectPropertyModel property in properties) + { + if (!property.IsValid) + { + continue; + } + + GetOrCreate(groups, property.ContainingType).Properties.Add(property); + } + } + + private static Members GetOrCreate(Dictionary groups, ContainingTypeModel type) + { + if (groups.TryGetValue(type.FullyQualifiedName, out Members? members)) + { + return members; + } + + members = new Members(type); + groups.Add(type.FullyQualifiedName, members); + return members; + } + + private static void AssignHintNames(List tables) + { + HashSet used = HintNames.CreateUsedNames(); + for (int i = 0; i < tables.Count; i++) + { + string baseName = tables[i].ContainingType.HintBaseName; + tables[i] = tables[i] with { HintName = HintNames.AllocateUnique(baseName, HintSuffix, used) }; + } + } + + private sealed class Members(ContainingTypeModel containingType) + { + public ContainingTypeModel ContainingType + { + get; + } = containingType; + + public List Methods + { + get; + } = []; + + public List Properties + { + get; + } = []; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMethodModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMethodModel.cs index ee391fce..3b5f5254 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMethodModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectMethodModel.cs @@ -5,18 +5,18 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// One generated instance-method body on a [LuaClass] borrowed handle. internal sealed record LuaObjectMethodModel( - ContainingTypeModel ContainingType, - string LuaName, - string Modifiers, - string MethodName, - EquatableArray Arguments, - LuaCallForm Form, - EquatableArray Results, - LuaValueKind? ReturnKind, - bool ReturnIsNullable, - string SortKey, - bool IsValid) + ContainingTypeModel ContainingType, + string LuaName, + string Modifiers, + string MethodName, + EquatableArray Arguments, + LuaCallForm Form, + EquatableArray Results, + LuaValueKind? ReturnKind, + bool ReturnIsNullable, + string SortKey, + bool IsValid) { - /// Number of values the protected call keeps. - public int ResultCount => Form == LuaCallForm.Try ? Results.Length : ReturnKind is null ? 0 : 1; + /// Number of values the protected call keeps. + public int ResultCount => Form == LuaCallForm.Try ? Results.Length : ReturnKind is null ? 0 : 1; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectPropertyModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectPropertyModel.cs index 26efe478..61f3e15a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectPropertyModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaObjectPropertyModel.cs @@ -4,15 +4,15 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Model; /// One generated partial property on a [LuaClass] borrowed handle. internal sealed record LuaObjectPropertyModel( - ContainingTypeModel ContainingType, - string LuaName, - string Modifiers, - string PropertyName, - LuaValueKind Kind, - bool IsNullable, - bool HasGetter, - string GetterModifiers, - bool HasSetter, - string SetterModifiers, - string SortKey, - bool IsValid); + ContainingTypeModel ContainingType, + string LuaName, + string Modifiers, + string PropertyName, + LuaValueKind Kind, + bool IsNullable, + bool HasGetter, + string GetterModifiers, + bool HasSetter, + string SetterModifiers, + string SortKey, + bool IsValid); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/AttributeArguments.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/AttributeArguments.cs index e2cf7ac3..06710415 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/AttributeArguments.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/AttributeArguments.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; @@ -6,18 +7,21 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// Reads the one constructor argument of [LuaFunction(name)] and [LuaGlobal(name)]. internal static class AttributeArguments { - /// - /// The name argument of the first attribute, or when it is missing, not a string or - /// (all of which happen while the author is typing). The attribute constructor's own - /// check never runs at compile time, so an empty string reaches here too and fails the name rule later. - /// - public static string? ReadName(ImmutableArray attributes) - { - if (attributes.IsDefaultOrEmpty) return null; + /// + /// The name argument of the first attribute, or when it is missing, not a string or + /// (all of which happen while the author is typing). The attribute constructor's own + /// check never runs at compile time, so an empty string reaches here too and fails the name rule later. + /// + public static string? ReadName(ImmutableArray attributes) + { + if (attributes.IsDefaultOrEmpty) + { + return null; + } - var arguments = attributes[0].ConstructorArguments; - return arguments.Length == 1 && arguments[0] is { Kind: TypedConstantKind.Primitive, Value: string name } - ? name - : null; - } + ImmutableArray arguments = attributes[0].ConstructorArguments; + return arguments.Length == 1 && arguments[0] is { Kind: TypedConstantKind.Primitive, Value: string name } + ? name + : null; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs index c592fa4c..94a58a4f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -15,64 +17,70 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// internal static class ContainingTypeParser { - // Namespace as written in the generated file: no 'global::' (a namespace declaration cannot carry it), keyword - // parts escaped. - private static readonly SymbolDisplayFormat NamespaceFormat = new( - SymbolDisplayGlobalNamespaceStyle.Omitted, - SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + // Namespace as written in the generated file: no 'global::' (a namespace declaration cannot carry it), keyword + // parts escaped. + private static readonly SymbolDisplayFormat NamespaceFormat = new( + SymbolDisplayGlobalNamespaceStyle.Omitted, + SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); - // The dotted name the hint name is derived from: plain identifiers, no escapes, no 'global::'. - private static readonly SymbolDisplayFormat HintFormat = new( - SymbolDisplayGlobalNamespaceStyle.Omitted, - SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces); + // The dotted name the hint name is derived from: plain identifiers, no escapes, no 'global::'. + private static readonly SymbolDisplayFormat HintFormat = new( + SymbolDisplayGlobalNamespaceStyle.Omitted, + SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces); - /// - /// Builds the model of . Works on any type, valid or not; validity is - /// 's business. - /// - public static ContainingTypeModel Parse(INamedTypeSymbol type) - { - List chain = []; - for (var current = type; current is not null; current = current.ContainingType) - chain.Add(new TypeDeclarationModel(Keyword(current), Identifiers.Escape(current.Name), - IsReadOnly(current))); + /// + /// Builds the model of . Works on any type, valid or not; validity is + /// 's business. + /// + public static ContainingTypeModel Parse(INamedTypeSymbol type) + { + List chain = []; + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + chain.Add(new TypeDeclarationModel(Keyword(current), Identifiers.Escape(current.Name), + IsReadOnly(current))); + } - chain.Reverse(); + chain.Reverse(); - var ns = type.ContainingNamespace is { IsGlobalNamespace: false } containingNamespace - ? containingNamespace.ToDisplayString(NamespaceFormat) - : string.Empty; + string ns = type.ContainingNamespace is { IsGlobalNamespace: false } containingNamespace + ? containingNamespace.ToDisplayString(NamespaceFormat) + : string.Empty; - return new ContainingTypeModel( - ns, - new EquatableArray(ImmutableArray.CreateRange(chain)), - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - type.ToDisplayString(HintFormat)); - } + return new ContainingTypeModel( + ns, + new EquatableArray(ImmutableArray.CreateRange(chain)), + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + type.ToDisplayString(HintFormat)); + } - // The keyword a further part must repeat. Interfaces and the like are rejected by the shape; 'class' is a - // harmless fallback that keeps the parser total. - private static string Keyword(INamedTypeSymbol type) - { - return (type.TypeKind, type.IsRecord) switch - { - (TypeKind.Struct, true) => "record struct", - (TypeKind.Struct, false) => "struct", - (_, true) => "record", - _ => "class" - }; - } + // The keyword a further part must repeat. Interfaces and the like are rejected by the shape; 'class' is a + // harmless fallback that keeps the parser total. + private static string Keyword(INamedTypeSymbol type) + { + return (type.TypeKind, type.IsRecord) switch + { + (TypeKind.Struct, true) => "record struct", + (TypeKind.Struct, false) => "struct", + (_, true) => "record", + _ => "class" + }; + } - // Do not use a symbol-name heuristic: a partial readonly struct has the modifier on every declaration, and the - // generated part must repeat it. The syntax walk keeps this code compatible with the netstandard Roslyn host. - private static bool IsReadOnly(INamedTypeSymbol type) - { - foreach (var reference in type.DeclaringSyntaxReferences) - if (reference.GetSyntax() is TypeDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) - return true; + // Do not use a symbol-name heuristic: a partial readonly struct has the modifier on every declaration, and the + // generated part must repeat it. The syntax walk keeps this code compatible with the netstandard Roslyn host. + private static bool IsReadOnly(INamedTypeSymbol type) + { + foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is TypeDeclarationSyntax declaration + && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) + { + return true; + } + } - return false; - } + return false; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs index 1bb4ecc2..576f7286 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; @@ -11,75 +12,88 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// internal static class LuaBindingSymbols { - private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; - private const string LuaAssemblyName = "CheatEngine.SDK.Lua"; - private const string LuaStateMetadataName = "CheatEngine.SDK.Lua.State.LuaState"; - private const string LuaMarshallerMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute"; - private const string LuaMarshallerContractMetadataName = "CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller`1"; + private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; + private const string LuaAssemblyName = "CheatEngine.SDK.Lua"; + private const string LuaStateMetadataName = "CheatEngine.SDK.Lua.State.LuaState"; + private const string LuaMarshallerMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute"; + private const string LuaMarshallerContractMetadataName = "CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller`1"; - /// - /// Returns whether one attribute in is exactly the SDK annotation named by - /// in . - /// - public static bool ContainsSdkAttribute(ImmutableArray attributes, Compilation compilation, - string metadataName) - { - var expected = ResolveSdkType(compilation, metadataName, AnnotationsAssemblyName); - if (expected is null) return false; + /// + /// Returns whether one attribute in is exactly the SDK annotation named by + /// in . + /// + public static bool ContainsSdkAttribute(ImmutableArray attributes, Compilation compilation, + string metadataName) + { + INamedTypeSymbol? expected = ResolveSdkType(compilation, metadataName, AnnotationsAssemblyName); + if (expected is null) + { + return false; + } - foreach (var attribute in attributes) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, expected)) - return true; + foreach (AttributeData attribute in attributes) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, expected)) + { + return true; + } + } - return false; - } + return false; + } - /// Gets the actual LuaState symbol, or when the SDK runtime is absent. - public static INamedTypeSymbol? ResolveLuaState(Compilation compilation) - { - return ResolveSdkType(compilation, LuaStateMetadataName, LuaAssemblyName); - } + /// Gets the actual LuaState symbol, or when the SDK runtime is absent. + public static INamedTypeSymbol? ResolveLuaState(Compilation compilation) + { + return ResolveSdkType(compilation, LuaStateMetadataName, LuaAssemblyName); + } - /// Gets the actual SDK custom-marshaller annotation, or when it is unavailable. - public static INamedTypeSymbol? ResolveLuaMarshallerAttribute(Compilation compilation) - { - return ResolveSdkType(compilation, LuaMarshallerMetadataName, AnnotationsAssemblyName); - } + /// Gets the actual SDK custom-marshaller annotation, or when it is unavailable. + public static INamedTypeSymbol? ResolveLuaMarshallerAttribute(Compilation compilation) + { + return ResolveSdkType(compilation, LuaMarshallerMetadataName, AnnotationsAssemblyName); + } - /// Gets the actual static marshaller contract, or when it is unavailable. - public static INamedTypeSymbol? ResolveLuaMarshallerContract(Compilation compilation) - { - return ResolveSdkType(compilation, LuaMarshallerContractMetadataName, LuaAssemblyName); - } + /// Gets the actual static marshaller contract, or when it is unavailable. + public static INamedTypeSymbol? ResolveLuaMarshallerContract(Compilation compilation) + { + return ResolveSdkType(compilation, LuaMarshallerContractMetadataName, LuaAssemblyName); + } - /// - /// Reads the name argument belonging to the resolved SDK attribute. An unrelated attribute with the same - /// metadata name is ignored even when Roslyn's discovery predicate delivered it. - /// - public static string? ReadSdkAttributeName(ImmutableArray attributes, Compilation compilation, - string metadataName) - { - var expected = ResolveSdkType(compilation, metadataName, AnnotationsAssemblyName); - if (expected is null) return null; + /// + /// Reads the name argument belonging to the resolved SDK attribute. An unrelated attribute with the same + /// metadata name is ignored even when Roslyn's discovery predicate delivered it. + /// + public static string? ReadSdkAttributeName(ImmutableArray attributes, Compilation compilation, + string metadataName) + { + INamedTypeSymbol? expected = ResolveSdkType(compilation, metadataName, AnnotationsAssemblyName); + if (expected is null) + { + return null; + } - foreach (var attribute in attributes) - { - if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, expected)) continue; + foreach (AttributeData attribute in attributes) + { + if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, expected)) + { + continue; + } - var arguments = attribute.ConstructorArguments; - return arguments.Length == 1 && arguments[0] is { Kind: TypedConstantKind.Primitive, Value: string name } - ? name - : null; - } + ImmutableArray arguments = attribute.ConstructorArguments; + return arguments.Length == 1 && arguments[0] is { Kind: TypedConstantKind.Primitive, Value: string name } + ? name + : null; + } - return null; - } + return null; + } - private static INamedTypeSymbol? ResolveSdkType(Compilation compilation, string metadataName, string assemblyName) - { - var type = compilation.GetTypeByMetadataName(metadataName); - return type is not null && string.Equals(type.ContainingAssembly.Name, assemblyName, StringComparison.Ordinal) - ? type - : null; - } + private static INamedTypeSymbol? ResolveSdkType(Compilation compilation, string metadataName, string assemblyName) + { + INamedTypeSymbol? type = compilation.GetTypeByMetadataName(metadataName); + return type is not null && string.Equals(type.ContainingAssembly.Name, assemblyName, StringComparison.Ordinal) + ? type + : null; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs index bb767178..ea7f0b3f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -29,83 +30,107 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// internal static class LuaBindingsDeclaredDiagnosticIds { - private const string Separator = ", "; - - /// - /// The IDs of and the types it is nested in, outermost first, without duplicates, - /// joined with ", ": the operand of a #pragma warning disable. Empty when none are declared. - /// Never throws on malformed attributes. - /// - public static string Collect(IMethodSymbol method) - { - var ids = CollectFrom(method.GetAttributes(), null); - if (method.ContainingType is { } containing) ids = CollectFromTypeAndContainers(containing, ids); - - return ids is null ? string.Empty : string.Join(Separator, ids); - } - - // The list is created on the first ID and threaded through the return value, so a member that declares none - // allocates nothing. - private static List? CollectFromTypeAndContainers(INamedTypeSymbol type, List? ids) - { - if (type.ContainingType is { } containing) ids = CollectFromTypeAndContainers(containing, ids); - - return CollectFrom(type.GetAttributes(), ids); - } - - private static List? CollectFrom(ImmutableArray attributes, List? ids) - { - foreach (var attribute in attributes) - if (ReadDeclaredId(attribute) is { } id && IsUsableInPragma(id) && (ids is null || !ids.Contains(id))) - (ids ??= []).Add(id); - - return ids; - } - - private static string? ReadDeclaredId(AttributeData attribute) - { - if (attribute.AttributeClass is not { Arity: 0, ContainingType: null } attributeClass) return null; - - if (string.Equals(attributeClass.Name, "ExperimentalAttribute", StringComparison.Ordinal)) - // [Experimental(string diagnosticId)] - return IsNamespace(attributeClass.ContainingNamespace, "System", "Diagnostics", "CodeAnalysis") - && attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0] is - { Kind: TypedConstantKind.Primitive, Value: string experimentalId } - ? experimentalId - : null; - - if (string.Equals(attributeClass.Name, "ObsoleteAttribute", StringComparison.Ordinal) - && IsNamespace(attributeClass.ContainingNamespace, "System")) - // [Obsolete(..., DiagnosticId = "ID")] - foreach (var argument in attribute.NamedArguments) - if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) - && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) - return obsoleteId; - - return null; - } - - // Innermost name last: IsNamespace(ns, "System", "Diagnostics") matches 'System.Diagnostics'. - private static bool IsNamespace(INamespaceSymbol? @namespace, params string[] names) - { - for (var i = names.Length - 1; i >= 0; i--) - { - if (@namespace is null || !string.Equals(@namespace.Name, names[i], StringComparison.Ordinal)) return false; - - @namespace = @namespace.ContainingNamespace; - } - - return @namespace is { IsGlobalNamespace: true }; - } - - // The ID goes into a '#pragma warning disable' line as it is, so it must be one identifier token there: no - // white space, line break or comment marker (text injection), no C# or preprocessor keyword. The compiler already - // rejects an [Experimental] ID that is not an identifier (CS9211); whatever is dropped here stays a loud error. - private static bool IsUsableInPragma(string id) - { - return SyntaxFacts.IsValidIdentifier(id) - && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None - && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; - } + private const string Separator = ", "; + + /// + /// The IDs of and the types it is nested in, outermost first, without duplicates, + /// joined with ", ": the operand of a #pragma warning disable. Empty when none are declared. + /// Never throws on malformed attributes. + /// + public static string Collect(IMethodSymbol method) + { + List? ids = CollectFrom(method.GetAttributes(), null); + if (method.ContainingType is { } containing) + { + ids = CollectFromTypeAndContainers(containing, ids); + } + + return ids is null ? string.Empty : string.Join(Separator, ids); + } + + // The list is created on the first ID and threaded through the return value, so a member that declares none + // allocates nothing. + private static List? CollectFromTypeAndContainers(INamedTypeSymbol type, List? ids) + { + if (type.ContainingType is { } containing) + { + ids = CollectFromTypeAndContainers(containing, ids); + } + + return CollectFrom(type.GetAttributes(), ids); + } + + private static List? CollectFrom(ImmutableArray attributes, List? ids) + { + foreach (AttributeData attribute in attributes) + { + if (ReadDeclaredId(attribute) is { } id && IsUsableInPragma(id) && (ids is null || !ids.Contains(id))) + { + (ids ??= []).Add(id); + } + } + + return ids; + } + + private static string? ReadDeclaredId(AttributeData attribute) + { + if (attribute.AttributeClass is not { Arity: 0, ContainingType: null } attributeClass) + { + return null; + } + + if (string.Equals(attributeClass.Name, "ExperimentalAttribute", StringComparison.Ordinal)) + // [Experimental(string diagnosticId)] + { + return IsNamespace(attributeClass.ContainingNamespace, "System", "Diagnostics", "CodeAnalysis") + && attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0] is + { Kind: TypedConstantKind.Primitive, Value: string experimentalId } + ? experimentalId + : null; + } + + if (string.Equals(attributeClass.Name, "ObsoleteAttribute", StringComparison.Ordinal) + && IsNamespace(attributeClass.ContainingNamespace, "System")) + // [Obsolete(..., DiagnosticId = "ID")] + { + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) + && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) + { + return obsoleteId; + } + } + } + + return null; + } + + // Innermost name last: IsNamespace(ns, "System", "Diagnostics") matches 'System.Diagnostics'. + private static bool IsNamespace(INamespaceSymbol? @namespace, params string[] names) + { + for (int i = names.Length - 1; i >= 0; i--) + { + if (@namespace is null || !string.Equals(@namespace.Name, names[i], StringComparison.Ordinal)) + { + return false; + } + + @namespace = @namespace.ContainingNamespace; + } + + return @namespace is { IsGlobalNamespace: true }; + } + + // The ID goes into a '#pragma warning disable' line as it is, so it must be one identifier token there: no + // white space, line break or comment marker (text injection), no C# or preprocessor keyword. The compiler already + // rejects an [Experimental] ID that is not an identifier (CS9211); whatever is dropped here stays a loud error. + private static bool IsUsableInPragma(string id) + { + return SyntaxFacts.IsValidIdentifier(id) + && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None + && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs index 98d9c3d5..bf4789c0 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs @@ -1,9 +1,11 @@ using System.Collections.Immutable; using System.Threading; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -13,113 +15,141 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// Reduces a valid [LuaClass] borrowed-handle declaration to its deterministic generator model. internal static class LuaClassParser { - /// Builds the model, leaving malformed declarations invalid so valid siblings can still generate. - public static LuaClassModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var type = (INamedTypeSymbol)context.TargetSymbol; - var compilation = context.SemanticModel.Compilation; - var isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, - LuaBindingsGenerator.LuaClassAttributeMetadataName); - var luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, - LuaBindingsGenerator.LuaClassAttributeMetadataName); - var isValid = isSdkAttribute && LuaNames.IsValidName(luaName) - && IsBorrowedHandleShape(type, compilation, cancellationToken); - - return new LuaClassModel(ContainingTypeParser.Parse(type), luaName ?? string.Empty, isValid); - } - - /// - /// Whether can receive the generated handle surface. Kept internal so the instance - /// member parsers use precisely the same ownership and collision rule. - /// - internal static bool IsBorrowedHandleShape(INamedTypeSymbol type, Compilation compilation, - CancellationToken cancellationToken) - { - if (type.TypeKind != TypeKind.Struct || type.IsGenericType || type.IsRecord || type.IsRefLikeType) return false; - - if (ContainingTypeShape.Inspect(type, cancellationToken) != ContainingTypeIssues.None) return false; - - if (!IsReadOnlyStruct(type, cancellationToken)) return false; - - return !HasGeneratedIdentityCollision(type, - compilation.GetTypeByMetadataName("CheatEngine.SDK.Engine.Objects.CEObject")); - } - - /// Whether the type carries the actual SDK [LuaClass] marker and can receive generated members. - internal static bool IsGeneratedHandle(INamedTypeSymbol type, Compilation compilation, - CancellationToken cancellationToken) - { - if (!IsBorrowedHandleShape(type, compilation, cancellationToken)) return false; - - foreach (var attribute in type.GetAttributes()) - { - var attributes = ImmutableArray.Create(attribute); - if (LuaBindingSymbols.ContainsSdkAttribute(attributes, compilation, - LuaBindingsGenerator.LuaClassAttributeMetadataName)) - return LuaNames.IsValidName(LuaBindingSymbols.ReadSdkAttributeName( - attributes, - compilation, - LuaBindingsGenerator.LuaClassAttributeMetadataName)); - } - - return false; - } - - private static bool IsReadOnlyStruct(INamedTypeSymbol type, CancellationToken cancellationToken) - { - foreach (var reference in type.DeclaringSyntaxReferences) - { - cancellationToken.ThrowIfCancellationRequested(); - if (reference.GetSyntax(cancellationToken) is StructDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) - return true; - } - - return false; - } - - // A generated member never silently replaces an author declaration. The analyzer explains the collision as - // CESDK2007; the generator just drops this type and leaves independent valid types alone. - private static bool HasGeneratedIdentityCollision(INamedTypeSymbol type, INamedTypeSymbol? ceObject) - { - return LuaClassGeneratedNames.IsGeneratedType(type.Name) - || HasGeneratedMember(type, ceObject) - || HasMember(type, "op_Equality") - || HasMember(type, "op_Inequality") - || HasCEObjectConstructor(type, ceObject); - } - - private static bool HasCEObjectConstructor(INamedTypeSymbol type, INamedTypeSymbol? ceObject) - { - if (ceObject is null) return false; - - foreach (var constructor in type.InstanceConstructors) - { - if (constructor.Parameters.Length != 1) continue; - - var parameter = constructor.Parameters[0]; - if (parameter.RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(parameter.Type, ceObject)) - return true; - } - - return false; - } - - private static bool HasGeneratedMember(INamedTypeSymbol type, INamedTypeSymbol? ceObject) - { - foreach (var member in type.GetMembers()) - if (LuaClassGeneratedNames.IsGeneratedMember(member.Name) - || LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) - return true; - - return false; - } - - private static bool HasMember(INamedTypeSymbol type, string name) - { - return type.GetMembers(name).Length != 0; - } + /// Builds the model, leaving malformed declarations invalid so valid siblings can still generate. + public static LuaClassModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + INamedTypeSymbol type = (INamedTypeSymbol) context.TargetSymbol; + Compilation compilation = context.SemanticModel.Compilation; + bool isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, + LuaBindingsGenerator.LuaClassAttributeMetadataName); + string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, + LuaBindingsGenerator.LuaClassAttributeMetadataName); + bool isValid = isSdkAttribute && LuaNames.IsValidName(luaName) + && IsBorrowedHandleShape(type, compilation, cancellationToken); + + return new LuaClassModel(ContainingTypeParser.Parse(type), luaName ?? string.Empty, isValid); + } + + /// + /// Whether can receive the generated handle surface. Kept internal so the instance + /// member parsers use precisely the same ownership and collision rule. + /// + internal static bool IsBorrowedHandleShape(INamedTypeSymbol type, Compilation compilation, + CancellationToken cancellationToken) + { + if (type.TypeKind != TypeKind.Struct || type.IsGenericType || type.IsRecord || type.IsRefLikeType) + { + return false; + } + + if (ContainingTypeShape.Inspect(type, cancellationToken) != ContainingTypeIssues.None) + { + return false; + } + + if (!IsReadOnlyStruct(type, cancellationToken)) + { + return false; + } + + return !HasGeneratedIdentityCollision(type, + compilation.GetTypeByMetadataName("CheatEngine.SDK.Engine.Objects.CEObject")); + } + + /// Whether the type carries the actual SDK [LuaClass] marker and can receive generated members. + internal static bool IsGeneratedHandle(INamedTypeSymbol type, Compilation compilation, + CancellationToken cancellationToken) + { + if (!IsBorrowedHandleShape(type, compilation, cancellationToken)) + { + return false; + } + + foreach (AttributeData attribute in type.GetAttributes()) + { + ImmutableArray attributes = ImmutableArray.Create(attribute); + if (LuaBindingSymbols.ContainsSdkAttribute(attributes, compilation, + LuaBindingsGenerator.LuaClassAttributeMetadataName)) + { + return LuaNames.IsValidName(LuaBindingSymbols.ReadSdkAttributeName( + attributes, + compilation, + LuaBindingsGenerator.LuaClassAttributeMetadataName)); + } + } + + return false; + } + + private static bool IsReadOnlyStruct(INamedTypeSymbol type, CancellationToken cancellationToken) + { + foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reference.GetSyntax(cancellationToken) is StructDeclarationSyntax declaration + && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) + { + return true; + } + } + + return false; + } + + // A generated member never silently replaces an author declaration. The analyzer explains the collision as + // CESDK2007; the generator just drops this type and leaves independent valid types alone. + private static bool HasGeneratedIdentityCollision(INamedTypeSymbol type, INamedTypeSymbol? ceObject) + { + return LuaClassGeneratedNames.IsGeneratedType(type.Name) + || HasGeneratedMember(type, ceObject) + || HasMember(type, "op_Equality") + || HasMember(type, "op_Inequality") + || HasCEObjectConstructor(type, ceObject); + } + + private static bool HasCEObjectConstructor(INamedTypeSymbol type, INamedTypeSymbol? ceObject) + { + if (ceObject is null) + { + return false; + } + + foreach (IMethodSymbol constructor in type.InstanceConstructors) + { + if (constructor.Parameters.Length != 1) + { + continue; + } + + IParameterSymbol parameter = constructor.Parameters[0]; + if (parameter.RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(parameter.Type, ceObject)) + { + return true; + } + } + + return false; + } + + private static bool HasGeneratedMember(INamedTypeSymbol type, INamedTypeSymbol? ceObject) + { + foreach (ISymbol member in type.GetMembers()) + { + if (LuaClassGeneratedNames.IsGeneratedMember(member.Name) + || LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) + { + return true; + } + } + + return false; + } + + private static bool HasMember(INamedTypeSymbol type, string name) + { + return type.GetMembers(name).Length != 0; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs index a4ae6553..ad538b28 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs @@ -1,8 +1,10 @@ using System.Threading; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; @@ -14,54 +16,62 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// internal static class LuaFunctionParser { - /// Builds the model of one attributed method. - public static LuaFunctionModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); + /// Builds the model of one attributed method. + public static LuaFunctionModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); - var method = (IMethodSymbol)context.TargetSymbol; - var compilation = context.SemanticModel.Compilation; - var isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, - LuaBindingsGenerator.LuaFunctionAttributeMetadataName); - var luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, - LuaBindingsGenerator.LuaFunctionAttributeMetadataName); + IMethodSymbol method = (IMethodSymbol) context.TargetSymbol; + Compilation compilation = context.SemanticModel.Compilation; + bool isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, + LuaBindingsGenerator.LuaFunctionAttributeMetadataName); + string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, + LuaBindingsGenerator.LuaFunctionAttributeMetadataName); - var issues = LuaFunctionShape.Inspect(compilation, method, LuaBindingSymbols.ResolveLuaState(compilation), - LuaBindingSymbols.ResolveLuaMarshallerAttribute(compilation), - LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), out var signature); - if (!isSdkAttribute || !LuaNames.IsValidName(luaName)) issues |= LuaFunctionShapeIssues.InvalidName; + LuaFunctionShapeIssues issues = LuaFunctionShape.Inspect(compilation, method, + LuaBindingSymbols.ResolveLuaState(compilation), + LuaBindingSymbols.ResolveLuaMarshallerAttribute(compilation), + LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), out LuaFunctionSignature signature); + if (!isSdkAttribute || !LuaNames.IsValidName(luaName)) + { + issues |= LuaFunctionShapeIssues.InvalidName; + } - var typeIssues = ContainingTypeShape.Inspect(method.ContainingType, cancellationToken); - var containingType = ContainingTypeParser.Parse(method.ContainingType); + ContainingTypeIssues typeIssues = ContainingTypeShape.Inspect(method.ContainingType, cancellationToken); + ContainingTypeModel containingType = ContainingTypeParser.Parse(method.ContainingType); - LuaThunkModel? thunk = null; - if (issues == LuaFunctionShapeIssues.None && typeIssues == ContainingTypeIssues.None) - thunk = new LuaThunkModel( - luaName!, - LuaThunkModel.ThunkNameFor(luaName!), - containingType.FullyQualifiedName + "." + Identifiers.Escape(method.Name), - signature.PassesState, - signature.Arguments, - signature.ReturnKind, - LuaBindingsDeclaredDiagnosticIds.Collect(method), - signature.ReturnMarshaller); + LuaThunkModel? thunk = null; + if (issues == LuaFunctionShapeIssues.None && typeIssues == ContainingTypeIssues.None) + { + thunk = new LuaThunkModel( + luaName!, + LuaThunkModel.ThunkNameFor(luaName!), + containingType.FullyQualifiedName + "." + Identifiers.Escape(method.Name), + signature.PassesState, + signature.Arguments, + signature.ReturnKind, + LuaBindingsDeclaredDiagnosticIds.Collect(method), + signature.ReturnMarshaller); + } - return new LuaFunctionModel(containingType, typeIssues, luaName ?? string.Empty, issues, thunk, - HasGeneratedIdentityCollision(method, luaName)); - } + return new LuaFunctionModel(containingType, typeIssues, luaName ?? string.Empty, issues, thunk, + HasGeneratedIdentityCollision(method, luaName)); + } - // A source generator must not rely on a later CS0111/CS0102 failure to protect user code. The thunk's name is - // per entry; the registration pair is shared by every valid function on this containing type. When either exists - // already, this entry is intentionally dropped and CESDK2007 identifies the colliding source member. - private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? luaName) - { - var type = method.ContainingType; - if (type.GetMembers(LuaRegistrationEmitter.RegisterMethodName).Length != 0 - || type.GetMembers(LuaRegistrationEmitter.RegisterLeaseMethodName).Length != 0 - || type.GetMembers(LuaRegistrationEmitter.UnregisterMethodName).Length != 0) - return true; + // A source generator must not rely on a later CS0111/CS0102 failure to protect user code. The thunk's name is + // per entry; the registration pair is shared by every valid function on this containing type. When either exists + // already, this entry is intentionally dropped and CESDK2007 identifies the colliding source member. + private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? luaName) + { + INamedTypeSymbol type = method.ContainingType; + if (type.GetMembers(LuaRegistrationEmitter.RegisterMethodName).Length != 0 + || type.GetMembers(LuaRegistrationEmitter.RegisterLeaseMethodName).Length != 0 + || type.GetMembers(LuaRegistrationEmitter.UnregisterMethodName).Length != 0) + { + return true; + } - return LuaNames.IsValidName(luaName) - && type.GetMembers(LuaThunkModel.ThunkNameFor(luaName!)).Length != 0; - } + return LuaNames.IsValidName(luaName) + && type.GetMembers(LuaThunkModel.ThunkNameFor(luaName!)).Length != 0; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs index a392370a..4faf5d75 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs @@ -1,9 +1,11 @@ using System.Text; using System.Threading; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -16,117 +18,146 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// internal static class LuaGlobalParser { - /// Builds the model of one attributed method declaration. - public static LuaGlobalModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var method = (IMethodSymbol)context.TargetSymbol; - var compilation = context.SemanticModel.Compilation; - var isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, - LuaBindingsGenerator.LuaGlobalAttributeMetadataName); - var luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, - LuaBindingsGenerator.LuaGlobalAttributeMetadataName); - - var issues = LuaGlobalShape.Inspect(compilation, method, LuaBindingSymbols.ResolveLuaState(compilation), - LuaBindingSymbols.ResolveLuaMarshallerAttribute(compilation), - LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), out var signature); - if (!isSdkAttribute || !LuaNames.IsValidName(luaName)) issues |= LuaGlobalShapeIssues.InvalidName; - - var typeIssues = ContainingTypeShape.Inspect(method.ContainingType, cancellationToken); - var containingType = ContainingTypeParser.Parse(method.ContainingType); - var hasGeneratedIdentityCollision = HasGeneratedIdentityCollision(method, luaName); - - LuaGlobalCallModel? call = null; - if (issues == LuaGlobalShapeIssues.None && typeIssues == ContainingTypeIssues.None) - call = new LuaGlobalCallModel( - luaName!, - LuaGlobalCallModel.CacheFieldFor(luaName!), - Modifiers(context.TargetNode as MethodDeclarationSyntax), - Identifiers.Escape(method.Name), - signature.StateParameterName, - signature.Arguments, - signature.Form, - signature.Results, - signature.ReturnKind, - signature.ReturnIsNullable, - method.IsExtensionMethod, - containingType.FullyQualifiedName + "." + LuaGlobalCallModel.CacheFieldFor(luaName!), - signature.ReturnMarshaller); - - return new LuaGlobalModel(containingType, typeIssues, issues, call, SortKey(method), - hasGeneratedIdentityCollision); - } - - // The implementing declaration must repeat the defining declaration's accessibility, 'new', 'static' and - // 'unsafe' exactly (CS8799, CS0763, CS0764), and must not add an accessibility to an old-style partial method - // that has none. Read from the syntax, in a canonical order, 'partial' last. - private static string Modifiers(MethodDeclarationSyntax? declaration) - { - StringBuilder modifiers = new(); - var isNew = false; - var isUnsafe = false; - if (declaration is not null) - foreach (var token in declaration.Modifiers) - switch (token.Kind()) - { - case SyntaxKind.PublicKeyword: - case SyntaxKind.InternalKeyword: - case SyntaxKind.ProtectedKeyword: - case SyntaxKind.PrivateKeyword: - Append(modifiers, token.ValueText); - break; - case SyntaxKind.NewKeyword: - isNew = true; - break; - case SyntaxKind.UnsafeKeyword: - isUnsafe = true; - break; - } - - if (isNew) Append(modifiers, "new"); - - Append(modifiers, "static"); - if (isUnsafe) Append(modifiers, "unsafe"); - - Append(modifiers, "partial"); - return modifiers.ToString(); - } - - private static void Append(StringBuilder modifiers, string modifier) - { - if (modifiers.Length > 0) modifiers.Append(' '); - - modifiers.Append(modifier); - } - - // Name and parameter types: overloads of one name (a copy-out and a string form of the same global) must - // still sort deterministically. - private static string SortKey(IMethodSymbol method) - { - StringBuilder key = new(method.Name); - key.Append('('); - for (var i = 0; i < method.Parameters.Length; i++) - { - if (i > 0) key.Append(", "); - - var parameter = method.Parameters[i]; - if (parameter.RefKind != RefKind.None) key.Append(parameter.RefKind.ToString()).Append(' '); - - key.Append(parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); - } - - return key.Append(')').ToString(); - } - - private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? luaName) - { - foreach (var parameter in method.Parameters) - if (parameter.Name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result" or - "__resolution" or "__exception") - return true; - - return LuaNames.IsValidName(luaName) - && method.ContainingType.GetMembers(LuaGlobalCallModel.CacheFieldFor(luaName!)).Length != 0; - } + /// Builds the model of one attributed method declaration. + public static LuaGlobalModel Parse(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IMethodSymbol method = (IMethodSymbol) context.TargetSymbol; + Compilation compilation = context.SemanticModel.Compilation; + bool isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, + LuaBindingsGenerator.LuaGlobalAttributeMetadataName); + string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, + LuaBindingsGenerator.LuaGlobalAttributeMetadataName); + + LuaGlobalShapeIssues issues = LuaGlobalShape.Inspect(compilation, method, + LuaBindingSymbols.ResolveLuaState(compilation), + LuaBindingSymbols.ResolveLuaMarshallerAttribute(compilation), + LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), out LuaGlobalSignature signature); + if (!isSdkAttribute || !LuaNames.IsValidName(luaName)) + { + issues |= LuaGlobalShapeIssues.InvalidName; + } + + ContainingTypeIssues typeIssues = ContainingTypeShape.Inspect(method.ContainingType, cancellationToken); + ContainingTypeModel containingType = ContainingTypeParser.Parse(method.ContainingType); + bool hasGeneratedIdentityCollision = HasGeneratedIdentityCollision(method, luaName); + + LuaGlobalCallModel? call = null; + if (issues == LuaGlobalShapeIssues.None && typeIssues == ContainingTypeIssues.None) + { + call = new LuaGlobalCallModel( + luaName!, + LuaGlobalCallModel.CacheFieldFor(luaName!), + Modifiers(context.TargetNode as MethodDeclarationSyntax), + Identifiers.Escape(method.Name), + signature.StateParameterName, + signature.Arguments, + signature.Form, + signature.Results, + signature.ReturnKind, + signature.ReturnIsNullable, + method.IsExtensionMethod, + containingType.FullyQualifiedName + "." + LuaGlobalCallModel.CacheFieldFor(luaName!), + signature.ReturnMarshaller); + } + + return new LuaGlobalModel(containingType, typeIssues, issues, call, SortKey(method), + hasGeneratedIdentityCollision); + } + + // The implementing declaration must repeat the defining declaration's accessibility, 'new', 'static' and + // 'unsafe' exactly (CS8799, CS0763, CS0764), and must not add an accessibility to an old-style partial method + // that has none. Read from the syntax, in a canonical order, 'partial' last. + private static string Modifiers(MethodDeclarationSyntax? declaration) + { + StringBuilder modifiers = new(); + bool isNew = false; + bool isUnsafe = false; + if (declaration is not null) + { + foreach (SyntaxToken token in declaration.Modifiers) + { + switch (token.Kind()) + { + case SyntaxKind.PublicKeyword: + case SyntaxKind.InternalKeyword: + case SyntaxKind.ProtectedKeyword: + case SyntaxKind.PrivateKeyword: + Append(modifiers, token.ValueText); + break; + case SyntaxKind.NewKeyword: + isNew = true; + break; + case SyntaxKind.UnsafeKeyword: + isUnsafe = true; + break; + } + } + } + + if (isNew) + { + Append(modifiers, "new"); + } + + Append(modifiers, "static"); + if (isUnsafe) + { + Append(modifiers, "unsafe"); + } + + Append(modifiers, "partial"); + return modifiers.ToString(); + } + + private static void Append(StringBuilder modifiers, string modifier) + { + if (modifiers.Length > 0) + { + modifiers.Append(' '); + } + + modifiers.Append(modifier); + } + + // Name and parameter types: overloads of one name (a copy-out and a string form of the same global) must + // still sort deterministically. + private static string SortKey(IMethodSymbol method) + { + StringBuilder key = new(method.Name); + key.Append('('); + for (int i = 0; i < method.Parameters.Length; i++) + { + if (i > 0) + { + key.Append(", "); + } + + IParameterSymbol parameter = method.Parameters[i]; + if (parameter.RefKind != RefKind.None) + { + key.Append(parameter.RefKind.ToString()).Append(' '); + } + + key.Append(parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + return key.Append(')').ToString(); + } + + private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? luaName) + { + foreach (IParameterSymbol parameter in method.Parameters) + { + if (parameter.Name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result" or + "__resolution" or "__exception") + { + return true; + } + } + + return LuaNames.IsValidName(luaName) + && method.ContainingType.GetMembers(LuaGlobalCallModel.CacheFieldFor(luaName!)).Length != 0; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs index 36948081..7f6a79af 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs @@ -2,10 +2,12 @@ using System.Collections.Immutable; using System.Text; using System.Threading; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -15,198 +17,241 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// Parses the supported partial instance-method form of [LuaMethod]. internal static class LuaObjectMethodParser { - private const string StateLocal = "__ceState"; - private const string OperationLocal = "__ceOperation"; - private const string TopLocal = "__ceTop"; - private const string StatusLocal = "__ceStatus"; - private const string ResultLocal = "__ceResult"; - - /// Builds one value-only method model; an invalid input receives no generated implementation. - public static LuaObjectMethodModel Parse(GeneratorAttributeSyntaxContext context, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var method = (IMethodSymbol)context.TargetSymbol; - var compilation = context.SemanticModel.Compilation; - var isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, - LuaBindingsGenerator.LuaMethodAttributeMetadataName); - var luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, - LuaBindingsGenerator.LuaMethodAttributeMetadataName); - var described = TryDescribe(method, context.TargetNode as MethodDeclarationSyntax, out var model); - var valid = isSdkAttribute && LuaNames.IsValidName(luaName) - && LuaClassParser.IsGeneratedHandle(method.ContainingType, compilation, - cancellationToken) - && described; - - if (valid) - return model with { LuaName = luaName!, IsValid = true }; - - return new LuaObjectMethodModel( - ContainingTypeParser.Parse(method.ContainingType), - luaName ?? string.Empty, - string.Empty, - Identifiers.Escape(method.Name), - EquatableArray.Empty, - LuaCallForm.Throwing, - EquatableArray.Empty, - ReturnKind: null, - ReturnIsNullable: false, - SortKey(method), - IsValid: false); - } - - private static bool TryDescribe(IMethodSymbol method, MethodDeclarationSyntax? declaration, - out LuaObjectMethodModel model) - { - var arguments = ImmutableArray.CreateBuilder(method.Parameters.Length); - var results = ImmutableArray.CreateBuilder(); - var valid = method.MethodKind == MethodKind.Ordinary - && !method.IsStatic - && method.IsPartialDefinition - && method.PartialImplementationPart is null - && !method.IsGenericMethod - && !method.IsAsync; - valid &= DescribeParameters(method.Parameters, arguments, results); - - var form = results.Count == 0 ? LuaCallForm.Throwing : LuaCallForm.Try; - LuaValueKind? returnKind = null; - var returnNullable = false; - valid &= TryDescribeReturn(method, form, out returnKind, out returnNullable); - - model = new LuaObjectMethodModel( - ContainingTypeParser.Parse(method.ContainingType), - string.Empty, - Modifiers(declaration), - Identifiers.Escape(method.Name), - new EquatableArray(arguments.ToImmutable()), - form, - new EquatableArray(results.ToImmutable()), - returnKind, - returnNullable, - SortKey(method), - valid); - return valid; - } - - private static bool DescribeParameters(ImmutableArray parameters, - ImmutableArray.Builder arguments, ImmutableArray.Builder results) - { - var valid = true; - var seenResult = false; - foreach (var parameter in parameters) - { - if (IsReserved(parameter.Name) || parameter.IsParams || parameter.IsOptional - || parameter.HasExplicitDefaultValue) valid = false; - - if (parameter.RefKind == RefKind.Out) - { - seenResult = true; - if (!LuaValueKindMapper.TryMap(parameter.Type, out var kind, out var nullable) - || !LuaValueKinds.CanBeResult(kind)) - valid = false; - else - results.Add(LuaResultModel.Value(kind, Identifiers.Escape(parameter.Name), nullable)); - - continue; - } - - if (parameter.RefKind != RefKind.None || seenResult - || !LuaValueKindMapper.TryMap(parameter.Type, out var argumentKind, - out var argumentNullable)) - valid = false; - else - arguments.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), argumentKind, argumentNullable, - parameter.ScopedKind != ScopedKind.None)); - } - - return valid; - } - - private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, out LuaValueKind? returnKind, - out bool returnNullable) - { - returnKind = null; - returnNullable = false; - if (form == LuaCallForm.Try) - return method.ReturnType.SpecialType == SpecialType.System_Boolean && !method.ReturnsByRef - && !method.ReturnsByRefReadonly; - - if (method.ReturnsVoid) return true; - - if (method.ReturnsByRef || method.ReturnsByRefReadonly - || !LuaValueKindMapper.TryMap(method.ReturnType, out var kind, out returnNullable) - || !LuaValueKinds.CanBeResult(kind)) - return false; - - returnKind = kind; - return true; - } - - private static bool IsReserved(string name) - { - return string.Equals(name, StateLocal, StringComparison.Ordinal) - || string.Equals(name, OperationLocal, StringComparison.Ordinal) - || string.Equals(name, TopLocal, StringComparison.Ordinal) - || string.Equals(name, StatusLocal, StringComparison.Ordinal) - || string.Equals(name, ResultLocal, StringComparison.Ordinal); - } - - private static string Modifiers(MethodDeclarationSyntax? declaration) - { - StringBuilder modifiers = new(); - var isNew = false; - var isUnsafe = false; - if (declaration is not null) - foreach (var token in declaration.Modifiers) - switch (token.Kind()) - { - case SyntaxKind.PublicKeyword: - case SyntaxKind.InternalKeyword: - case SyntaxKind.ProtectedKeyword: - case SyntaxKind.PrivateKeyword: - Append(modifiers, token.ValueText); - break; - case SyntaxKind.NewKeyword: - isNew = true; - break; - case SyntaxKind.ReadOnlyKeyword: - Append(modifiers, token.ValueText); - break; - case SyntaxKind.UnsafeKeyword: - isUnsafe = true; - break; - } - - if (isNew) Append(modifiers, "new"); - - if (isUnsafe) Append(modifiers, "unsafe"); - - Append(modifiers, "partial"); - return modifiers.ToString(); - } - - private static void Append(StringBuilder builder, string value) - { - if (builder.Length > 0) builder.Append(' '); - - builder.Append(value); - } - - private static string SortKey(IMethodSymbol method) - { - StringBuilder key = new(method.Name); - key.Append('('); - for (var i = 0; i < method.Parameters.Length; i++) - { - if (i > 0) key.Append(", "); - - var parameter = method.Parameters[i]; - if (parameter.RefKind != RefKind.None) key.Append(parameter.RefKind).Append(' '); - - key.Append(parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); - } - - return key.Append(')').ToString(); - } + private const string StateLocal = "__ceState"; + private const string OperationLocal = "__ceOperation"; + private const string TopLocal = "__ceTop"; + private const string StatusLocal = "__ceStatus"; + private const string ResultLocal = "__ceResult"; + + /// Builds one value-only method model; an invalid input receives no generated implementation. + public static LuaObjectMethodModel Parse(GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IMethodSymbol method = (IMethodSymbol) context.TargetSymbol; + Compilation compilation = context.SemanticModel.Compilation; + bool isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, + LuaBindingsGenerator.LuaMethodAttributeMetadataName); + string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, + LuaBindingsGenerator.LuaMethodAttributeMetadataName); + bool described = TryDescribe(method, context.TargetNode as MethodDeclarationSyntax, + out LuaObjectMethodModel model); + bool valid = isSdkAttribute && LuaNames.IsValidName(luaName) + && LuaClassParser.IsGeneratedHandle(method.ContainingType, compilation, + cancellationToken) + && described; + + if (valid) + { + return model with { LuaName = luaName!, IsValid = true }; + } + + return new LuaObjectMethodModel( + ContainingTypeParser.Parse(method.ContainingType), + luaName ?? string.Empty, + string.Empty, + Identifiers.Escape(method.Name), + EquatableArray.Empty, + LuaCallForm.Throwing, + EquatableArray.Empty, + null, + false, + SortKey(method), + false); + } + + private static bool TryDescribe(IMethodSymbol method, MethodDeclarationSyntax? declaration, + out LuaObjectMethodModel model) + { + ImmutableArray.Builder arguments = + ImmutableArray.CreateBuilder(method.Parameters.Length); + ImmutableArray.Builder results = ImmutableArray.CreateBuilder(); + bool valid = method.MethodKind == MethodKind.Ordinary + && !method.IsStatic + && method.IsPartialDefinition + && method.PartialImplementationPart is null + && !method.IsGenericMethod + && !method.IsAsync; + valid &= DescribeParameters(method.Parameters, arguments, results); + + LuaCallForm form = results.Count == 0 ? LuaCallForm.Throwing : LuaCallForm.Try; + LuaValueKind? returnKind = null; + bool returnNullable = false; + valid &= TryDescribeReturn(method, form, out returnKind, out returnNullable); + + model = new LuaObjectMethodModel( + ContainingTypeParser.Parse(method.ContainingType), + string.Empty, + Modifiers(declaration), + Identifiers.Escape(method.Name), + new EquatableArray(arguments.ToImmutable()), + form, + new EquatableArray(results.ToImmutable()), + returnKind, + returnNullable, + SortKey(method), + valid); + return valid; + } + + private static bool DescribeParameters(ImmutableArray parameters, + ImmutableArray.Builder arguments, ImmutableArray.Builder results) + { + bool valid = true; + bool seenResult = false; + foreach (IParameterSymbol parameter in parameters) + { + if (IsReserved(parameter.Name) || parameter.IsParams || parameter.IsOptional + || parameter.HasExplicitDefaultValue) + { + valid = false; + } + + if (parameter.RefKind == RefKind.Out) + { + seenResult = true; + if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool nullable) + || !LuaValueKinds.CanBeResult(kind)) + { + valid = false; + } + else + { + results.Add(LuaResultModel.Value(kind, Identifiers.Escape(parameter.Name), nullable)); + } + + continue; + } + + if (parameter.RefKind != RefKind.None || seenResult + || !LuaValueKindMapper.TryMap(parameter.Type, + out LuaValueKind argumentKind, + out bool argumentNullable)) + { + valid = false; + } + else + { + arguments.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), argumentKind, argumentNullable, + parameter.ScopedKind != ScopedKind.None)); + } + } + + return valid; + } + + private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, out LuaValueKind? returnKind, + out bool returnNullable) + { + returnKind = null; + returnNullable = false; + if (form == LuaCallForm.Try) + { + return method.ReturnType.SpecialType == SpecialType.System_Boolean && !method.ReturnsByRef + && !method.ReturnsByRefReadonly; + } + + if (method.ReturnsVoid) + { + return true; + } + + if (method.ReturnsByRef || method.ReturnsByRefReadonly + || !LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, + out returnNullable) + || !LuaValueKinds.CanBeResult(kind)) + { + return false; + } + + returnKind = kind; + return true; + } + + private static bool IsReserved(string name) + { + return string.Equals(name, StateLocal, StringComparison.Ordinal) + || string.Equals(name, OperationLocal, StringComparison.Ordinal) + || string.Equals(name, TopLocal, StringComparison.Ordinal) + || string.Equals(name, StatusLocal, StringComparison.Ordinal) + || string.Equals(name, ResultLocal, StringComparison.Ordinal); + } + + private static string Modifiers(MethodDeclarationSyntax? declaration) + { + StringBuilder modifiers = new(); + bool isNew = false; + bool isUnsafe = false; + if (declaration is not null) + { + foreach (SyntaxToken token in declaration.Modifiers) + { + switch (token.Kind()) + { + case SyntaxKind.PublicKeyword: + case SyntaxKind.InternalKeyword: + case SyntaxKind.ProtectedKeyword: + case SyntaxKind.PrivateKeyword: + Append(modifiers, token.ValueText); + break; + case SyntaxKind.NewKeyword: + isNew = true; + break; + case SyntaxKind.ReadOnlyKeyword: + Append(modifiers, token.ValueText); + break; + case SyntaxKind.UnsafeKeyword: + isUnsafe = true; + break; + } + } + } + + if (isNew) + { + Append(modifiers, "new"); + } + + if (isUnsafe) + { + Append(modifiers, "unsafe"); + } + + Append(modifiers, "partial"); + return modifiers.ToString(); + } + + private static void Append(StringBuilder builder, string value) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(value); + } + + private static string SortKey(IMethodSymbol method) + { + StringBuilder key = new(method.Name); + key.Append('('); + for (int i = 0; i < method.Parameters.Length; i++) + { + if (i > 0) + { + key.Append(", "); + } + + IParameterSymbol parameter = method.Parameters[i]; + if (parameter.RefKind != RefKind.None) + { + key.Append(parameter.RefKind).Append(' '); + } + + key.Append(parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + return key.Append(')').ToString(); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs index 4987ee64..1efea72f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs @@ -1,8 +1,10 @@ using System.Text; using System.Threading; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -12,187 +14,203 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// Parses the supported bodyless partial-property form of [LuaProperty]. internal static class LuaObjectPropertyParser { - /// Builds one value-only property model; malformed properties are not emitted. - public static LuaObjectPropertyModel Parse(GeneratorAttributeSyntaxContext context, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var property = (IPropertySymbol)context.TargetSymbol; - var declaration = context.TargetNode as PropertyDeclarationSyntax; - var compilation = context.SemanticModel.Compilation; - var isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, - LuaBindingsGenerator.LuaPropertyAttributeMetadataName); - var luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, - LuaBindingsGenerator.LuaPropertyAttributeMetadataName); - var described = TryDescribe(property, declaration, out var model); - var valid = isSdkAttribute && LuaNames.IsValidName(luaName) - && LuaClassParser.IsGeneratedHandle(property.ContainingType, compilation, - cancellationToken) - && described; - - if (valid) - return model with { LuaName = luaName!, IsValid = true }; - - return new LuaObjectPropertyModel( - ContainingTypeParser.Parse(property.ContainingType), - luaName ?? string.Empty, - string.Empty, - Identifiers.Escape(property.Name), - default, - IsNullable: false, - HasGetter: false, - string.Empty, - HasSetter: false, - string.Empty, - property.Name, - IsValid: false); - } - - private static bool TryDescribe(IPropertySymbol property, PropertyDeclarationSyntax? declaration, - out LuaObjectPropertyModel model) - { - var kind = default(LuaValueKind); - var isNullable = false; - var modifiers = string.Empty; - var typeIsSupported = LuaValueKindMapper.TryMap(property.Type, out kind, out isNullable) - && LuaValueKinds.CanBeResult(kind); - var definitionIsSupported = IsSupportedDefinition(property, declaration, typeIsSupported, - out modifiers); - var accessorsAreSupported = TryDescribeAccessors(declaration, out var hasGetter, out var getterModifiers, - out var hasSetter, out var setterModifiers); - var valid = definitionIsSupported && accessorsAreSupported; - - model = new LuaObjectPropertyModel( - ContainingTypeParser.Parse(property.ContainingType), - string.Empty, - modifiers, - Identifiers.Escape(property.Name), - kind, - isNullable, - hasGetter, - getterModifiers, - hasSetter, - setterModifiers, - property.Name, - valid); - return valid; - } - - private static bool IsSupportedDefinition(IPropertySymbol property, PropertyDeclarationSyntax? declaration, - bool typeIsSupported, out string modifiers) - { - modifiers = string.Empty; - return declaration is not null - && !property.IsStatic - && !property.IsIndexer - && property.RefKind == RefKind.None - && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) - && declaration.AccessorList is not null - && declaration.ExplicitInterfaceSpecifier is null - && property.PartialImplementationPart is null - && typeIsSupported - && TryModifiers(declaration, out modifiers); - } - - private static bool TryDescribeAccessors(PropertyDeclarationSyntax? declaration, out bool hasGetter, - out string getterModifiers, out bool hasSetter, out string setterModifiers) - { - hasGetter = false; - getterModifiers = string.Empty; - hasSetter = false; - setterModifiers = string.Empty; - if (declaration?.AccessorList is not { } accessorList) return false; - - foreach (var accessor in accessorList.Accessors) - if (!TryDescribeAccessor(accessor, ref hasGetter, ref getterModifiers, ref hasSetter, - ref setterModifiers)) - return false; - - return hasGetter || hasSetter; - } - - private static bool TryDescribeAccessor(AccessorDeclarationSyntax accessor, ref bool hasGetter, - ref string getterModifiers, ref bool hasSetter, ref string setterModifiers) - { - if (accessor.Body is not null || accessor.ExpressionBody is not null - || !TryAccessorModifiers(accessor, out var modifiers)) - return false; - - switch (accessor.Kind()) - { - case SyntaxKind.GetAccessorDeclaration when !hasGetter: - hasGetter = true; - getterModifiers = modifiers; - return true; - case SyntaxKind.SetAccessorDeclaration when !hasSetter: - hasSetter = true; - setterModifiers = modifiers; - return true; - default: - return false; - } - } - - private static bool TryModifiers(PropertyDeclarationSyntax declaration, out string modifiers) - { - StringBuilder builder = new(); - foreach (var token in declaration.Modifiers) - { - if (!IsSupportedPropertyModifier(token.Kind())) - { - modifiers = string.Empty; - return false; - } - - Append(builder, token.ValueText); - } - - modifiers = builder.ToString(); - return true; - } - - private static bool IsSupportedPropertyModifier(SyntaxKind kind) - { - return kind is SyntaxKind.PublicKeyword - or SyntaxKind.InternalKeyword - or SyntaxKind.ProtectedKeyword - or SyntaxKind.PrivateKeyword - or SyntaxKind.NewKeyword - or SyntaxKind.ReadOnlyKeyword - or SyntaxKind.UnsafeKeyword - or SyntaxKind.VirtualKeyword - or SyntaxKind.OverrideKeyword - or SyntaxKind.SealedKeyword - or SyntaxKind.RequiredKeyword - or SyntaxKind.PartialKeyword; - } - - private static bool TryAccessorModifiers(AccessorDeclarationSyntax accessor, out string modifiers) - { - StringBuilder builder = new(); - foreach (var token in accessor.Modifiers) - switch (token.Kind()) - { - case SyntaxKind.PublicKeyword: - case SyntaxKind.InternalKeyword: - case SyntaxKind.ProtectedKeyword: - case SyntaxKind.PrivateKeyword: - Append(builder, token.ValueText); - break; - default: - modifiers = string.Empty; - return false; - } - - modifiers = builder.ToString(); - return true; - } - - private static void Append(StringBuilder builder, string value) - { - if (builder.Length > 0) builder.Append(' '); - - builder.Append(value); - } + /// Builds one value-only property model; malformed properties are not emitted. + public static LuaObjectPropertyModel Parse(GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IPropertySymbol property = (IPropertySymbol) context.TargetSymbol; + PropertyDeclarationSyntax? declaration = context.TargetNode as PropertyDeclarationSyntax; + Compilation compilation = context.SemanticModel.Compilation; + bool isSdkAttribute = LuaBindingSymbols.ContainsSdkAttribute(context.Attributes, compilation, + LuaBindingsGenerator.LuaPropertyAttributeMetadataName); + string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, + LuaBindingsGenerator.LuaPropertyAttributeMetadataName); + bool described = TryDescribe(property, declaration, out LuaObjectPropertyModel model); + bool valid = isSdkAttribute && LuaNames.IsValidName(luaName) + && LuaClassParser.IsGeneratedHandle(property.ContainingType, compilation, + cancellationToken) + && described; + + if (valid) + { + return model with { LuaName = luaName!, IsValid = true }; + } + + return new LuaObjectPropertyModel( + ContainingTypeParser.Parse(property.ContainingType), + luaName ?? string.Empty, + string.Empty, + Identifiers.Escape(property.Name), + default, + false, + false, + string.Empty, + false, + string.Empty, + property.Name, + false); + } + + private static bool TryDescribe(IPropertySymbol property, PropertyDeclarationSyntax? declaration, + out LuaObjectPropertyModel model) + { + LuaValueKind kind = default; + bool isNullable = false; + string modifiers = string.Empty; + bool typeIsSupported = LuaValueKindMapper.TryMap(property.Type, out kind, out isNullable) + && LuaValueKinds.CanBeResult(kind); + bool definitionIsSupported = IsSupportedDefinition(property, declaration, typeIsSupported, + out modifiers); + bool accessorsAreSupported = TryDescribeAccessors(declaration, out bool hasGetter, out string getterModifiers, + out bool hasSetter, out string setterModifiers); + bool valid = definitionIsSupported && accessorsAreSupported; + + model = new LuaObjectPropertyModel( + ContainingTypeParser.Parse(property.ContainingType), + string.Empty, + modifiers, + Identifiers.Escape(property.Name), + kind, + isNullable, + hasGetter, + getterModifiers, + hasSetter, + setterModifiers, + property.Name, + valid); + return valid; + } + + private static bool IsSupportedDefinition(IPropertySymbol property, PropertyDeclarationSyntax? declaration, + bool typeIsSupported, out string modifiers) + { + modifiers = string.Empty; + return declaration is not null + && !property.IsStatic + && !property.IsIndexer + && property.RefKind == RefKind.None + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) + && declaration.AccessorList is not null + && declaration.ExplicitInterfaceSpecifier is null + && property.PartialImplementationPart is null + && typeIsSupported + && TryModifiers(declaration, out modifiers); + } + + private static bool TryDescribeAccessors(PropertyDeclarationSyntax? declaration, out bool hasGetter, + out string getterModifiers, out bool hasSetter, out string setterModifiers) + { + hasGetter = false; + getterModifiers = string.Empty; + hasSetter = false; + setterModifiers = string.Empty; + if (declaration?.AccessorList is not { } accessorList) + { + return false; + } + + foreach (AccessorDeclarationSyntax accessor in accessorList.Accessors) + { + if (!TryDescribeAccessor(accessor, ref hasGetter, ref getterModifiers, ref hasSetter, + ref setterModifiers)) + { + return false; + } + } + + return hasGetter || hasSetter; + } + + private static bool TryDescribeAccessor(AccessorDeclarationSyntax accessor, ref bool hasGetter, + ref string getterModifiers, ref bool hasSetter, ref string setterModifiers) + { + if (accessor.Body is not null || accessor.ExpressionBody is not null + || !TryAccessorModifiers(accessor, out string modifiers)) + { + return false; + } + + switch (accessor.Kind()) + { + case SyntaxKind.GetAccessorDeclaration when !hasGetter: + hasGetter = true; + getterModifiers = modifiers; + return true; + case SyntaxKind.SetAccessorDeclaration when !hasSetter: + hasSetter = true; + setterModifiers = modifiers; + return true; + default: + return false; + } + } + + private static bool TryModifiers(PropertyDeclarationSyntax declaration, out string modifiers) + { + StringBuilder builder = new(); + foreach (SyntaxToken token in declaration.Modifiers) + { + if (!IsSupportedPropertyModifier(token.Kind())) + { + modifiers = string.Empty; + return false; + } + + Append(builder, token.ValueText); + } + + modifiers = builder.ToString(); + return true; + } + + private static bool IsSupportedPropertyModifier(SyntaxKind kind) + { + return kind is SyntaxKind.PublicKeyword + or SyntaxKind.InternalKeyword + or SyntaxKind.ProtectedKeyword + or SyntaxKind.PrivateKeyword + or SyntaxKind.NewKeyword + or SyntaxKind.ReadOnlyKeyword + or SyntaxKind.UnsafeKeyword + or SyntaxKind.VirtualKeyword + or SyntaxKind.OverrideKeyword + or SyntaxKind.SealedKeyword + or SyntaxKind.RequiredKeyword + or SyntaxKind.PartialKeyword; + } + + private static bool TryAccessorModifiers(AccessorDeclarationSyntax accessor, out string modifiers) + { + StringBuilder builder = new(); + foreach (SyntaxToken token in accessor.Modifiers) + { + switch (token.Kind()) + { + case SyntaxKind.PublicKeyword: + case SyntaxKind.InternalKeyword: + case SyntaxKind.ProtectedKeyword: + case SyntaxKind.PrivateKeyword: + Append(builder, token.ValueText); + break; + default: + modifiers = string.Empty; + return false; + } + } + + modifiers = builder.ToString(); + return true; + } + + private static void Append(StringBuilder builder, string value) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(value); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogDiagnostic.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogDiagnostic.cs index b2427b1a..e2062292 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogDiagnostic.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogDiagnostic.cs @@ -4,8 +4,8 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; /// A diagnostic whose source span is stored as plain values rather than a Roslyn syntax object. internal sealed record CatalogDiagnostic( - string Path, - TextSpan Span, - LinePositionSpan LineSpan, - string Message, - bool IsConflict); + string Path, + TextSpan Span, + LinePositionSpan LineSpan, + string Message, + bool IsConflict); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogModel.cs index 2483c653..2657bb36 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogModel.cs @@ -4,6 +4,6 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; /// The generator-relevant, value-only projection of the protected operation catalogue. internal sealed record CatalogModel( - string SourcePath, - ImmutableArray Operations, - ulong RequiredBitmap); + string SourcePath, + ImmutableArray Operations, + ulong RequiredBitmap); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogParseResult.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogParseResult.cs index 0dd3cbe8..da9a4612 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogParseResult.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/CatalogParseResult.cs @@ -4,6 +4,6 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; /// The validated catalogue, if any, and every diagnostic recovered without throwing. internal sealed record CatalogParseResult( - string SourcePath, - CatalogModel? Catalog, - ImmutableArray Diagnostics); + string SourcePath, + CatalogModel? Catalog, + ImmutableArray Diagnostics); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractDiagnostics.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractDiagnostics.cs index c3d9a92f..c8f85081 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractDiagnostics.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractDiagnostics.cs @@ -6,40 +6,40 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; /// Descriptors and external-file locations for the repository-only bridge catalogue generator. internal static class LuaBridgeContractDiagnostics { - private const string Category = "CheatEngine.SDK.LuaBridge"; + private const string Category = "CheatEngine.SDK.LuaBridge"; - private static readonly DiagnosticDescriptor InvalidCatalog = new( - "CESDK4001", - "Protected Lua operation catalogue is invalid", - "Protected Lua operation catalogue: {0}", - Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "Correct the invalid protected-operations.json entry; the managed bridge contract is not generated from an invalid catalogue."); + private static readonly DiagnosticDescriptor InvalidCatalog = new( + "CESDK4001", + "Protected Lua operation catalogue is invalid", + "Protected Lua operation catalogue: {0}", + Category, + DiagnosticSeverity.Error, + true, + "Correct the invalid protected-operations.json entry; the managed bridge contract is not generated from an invalid catalogue."); - private static readonly DiagnosticDescriptor ConflictingCatalog = new( - "CESDK4002", - "Protected Lua operation catalogue is ambiguous", - "Protected Lua operation catalogue: {0}", - Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "CheatEngine.SDK.Lua.Interop must supply exactly one protected-operations.json AdditionalFile."); + private static readonly DiagnosticDescriptor ConflictingCatalog = new( + "CESDK4002", + "Protected Lua operation catalogue is ambiguous", + "Protected Lua operation catalogue: {0}", + Category, + DiagnosticSeverity.Error, + true, + "CheatEngine.SDK.Lua.Interop must supply exactly one protected-operations.json AdditionalFile."); - public static CatalogDiagnostic Ambiguous(string path) - { - return new CatalogDiagnostic( - path, - new TextSpan(0, 0), - new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0)), - "More than one protected-operations.json AdditionalFile was supplied; only one catalogue may own the generated managed operation contract.", - IsConflict: true); - } + public static CatalogDiagnostic Ambiguous(string path) + { + return new CatalogDiagnostic( + path, + new TextSpan(0, 0), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0)), + "More than one protected-operations.json AdditionalFile was supplied; only one catalogue may own the generated managed operation contract.", + true); + } - public static Diagnostic Create(CatalogDiagnostic diagnostic) - { - var descriptor = diagnostic.IsConflict ? ConflictingCatalog : InvalidCatalog; - var location = Location.Create(diagnostic.Path, diagnostic.Span, diagnostic.LineSpan); - return Diagnostic.Create(descriptor, location, diagnostic.Message); - } + public static Diagnostic Create(CatalogDiagnostic diagnostic) + { + DiagnosticDescriptor descriptor = diagnostic.IsConflict ? ConflictingCatalog : InvalidCatalog; + Location location = Location.Create(diagnostic.Path, diagnostic.Span, diagnostic.LineSpan); + return Diagnostic.Create(descriptor, location, diagnostic.Message); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractGenerationPlan.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractGenerationPlan.cs index a8972354..90f99898 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractGenerationPlan.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/LuaBridgeContractGenerationPlan.cs @@ -4,25 +4,29 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; /// Combines the compiler inputs into the one deterministic generation decision. internal sealed record LuaBridgeContractGenerationPlan( - CatalogModel? Catalog, - ImmutableArray Diagnostics) + CatalogModel? Catalog, + ImmutableArray Diagnostics) { - public static LuaBridgeContractGenerationPlan Create(ImmutableArray results) - { - if (results.IsDefaultOrEmpty) - return new LuaBridgeContractGenerationPlan(Catalog: null, ImmutableArray.Empty); + public static LuaBridgeContractGenerationPlan Create(ImmutableArray results) + { + if (results.IsDefaultOrEmpty) + { + return new LuaBridgeContractGenerationPlan(null, ImmutableArray.Empty); + } - if (results.Length == 1) - return new LuaBridgeContractGenerationPlan(results[0].Catalog, results[0].Diagnostics); + if (results.Length == 1) + { + return new LuaBridgeContractGenerationPlan(results[0].Catalog, results[0].Diagnostics); + } - var diagnostics = ImmutableArray.CreateBuilder(); - for (var i = 0; i < results.Length; i++) - { - var result = results[i]; - diagnostics.AddRange(result.Diagnostics); - diagnostics.Add(LuaBridgeContractDiagnostics.Ambiguous(result.SourcePath)); - } + ImmutableArray.Builder diagnostics = ImmutableArray.CreateBuilder(); + for (int i = 0; i < results.Length; i++) + { + CatalogParseResult result = results[i]; + diagnostics.AddRange(result.Diagnostics); + diagnostics.Add(LuaBridgeContractDiagnostics.Ambiguous(result.SourcePath)); + } - return new LuaBridgeContractGenerationPlan(Catalog: null, diagnostics.ToImmutable()); - } + return new LuaBridgeContractGenerationPlan(null, diagnostics.ToImmutable()); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs index d25c7bd2..dcf133dc 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Globalization; using System.Text; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; @@ -10,777 +11,982 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; /// Strict, Roslyn-free JSON parser and contract validator for the protected operation catalogue. internal static class ProtectedOperationCatalogParser { - private const string FileName = "protected-operations.json"; - private const string ExpectedCatalogId = "cheatengine-sdk-lua-protected-operations"; - - public static bool IsCatalogFile(string path) - { - if (path is null) return false; - - var slash = path.LastIndexOf('/'); - var backslash = path.LastIndexOf('\\'); - var separator = slash > backslash ? slash : backslash; - return string.Equals(path[(separator + 1)..], FileName, StringComparison.OrdinalIgnoreCase); - } - - public static CatalogParseResult Parse(CatalogInput input) - { - try - { - return ParseCore(input); - } - catch (Exception exception) - { - var reader = new JsonReader(input.Path, input.Text); - return Failure(input.Path, reader.CreateDiagnostic(new TextSpan(0, 0), - "The catalogue parser recovered an unexpected " + exception.GetType().Name + ".")); - } - } - - private static CatalogParseResult ParseCore(CatalogInput input) - { - var reader = new JsonReader(input.Path, input.Text); - if (!reader.TryParse(out var root, out var syntaxDiagnostic)) - return Failure(input.Path, syntaxDiagnostic!); - - if (root is not JsonObject rootObject) - return Failure(input.Path, reader.CreateDiagnostic(root.Span, "The catalogue root must be a JSON object.")); - - var diagnostics = ImmutableArray.CreateBuilder(); - ValidateExactNumber(rootObject, "schemaVersion", 1, reader, diagnostics); - ValidateExactString(rootObject, "catalogId", ExpectedCatalogId, reader, diagnostics); - - var bridgeContract = RequireObject(rootObject, "bridgeContract", reader, diagnostics); - ValidateBridgeContract(bridgeContract, reader, diagnostics); - - var operationsValue = Require(rootObject, "operations", reader, diagnostics); - if (operationsValue is not JsonArray operationsArray) - { - if (operationsValue is not null) - diagnostics.Add(reader.CreateDiagnostic(operationsValue.Span, - "Property 'operations' must be a JSON array.")); - return new CatalogParseResult(input.Path, Catalog: null, diagnostics.ToImmutable()); - } - - return ParseOperations(input.Path, bridgeContract, operationsArray, reader, diagnostics); - } - - private static void ValidateBridgeContract( - JsonObject? bridgeContract, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - if (bridgeContract is null) return; - ValidateExactNumber(bridgeContract, "abiMajor", 1, reader, diagnostics); - ValidateExactNumber(bridgeContract, "minimumAbiMinor", 1, reader, diagnostics); - ValidateExactNumber(bridgeContract, "operationBitmapWidth", 64, reader, diagnostics); - } - - private static CatalogParseResult ParseOperations( - string sourcePath, - JsonObject? bridgeContract, - JsonArray operationsArray, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - if (operationsArray.Items.Count == 0) - diagnostics.Add(reader.CreateDiagnostic(operationsArray.Span, - "Property 'operations' must contain at least one protected operation.")); - - var operations = new List(); - var ids = new Dictionary(StringComparer.Ordinal); - var opcodes = new Dictionary(); - ulong bitmap = 0; - for (var i = 0; i < operationsArray.Items.Count; i++) - ParseOperation(operationsArray.Items[i], reader, diagnostics, operations, ids, opcodes, ref bitmap); - - if (bridgeContract is not null) ValidateBitmap(bridgeContract, bitmap, reader, diagnostics); - if (diagnostics.Count > 0) return new CatalogParseResult(sourcePath, Catalog: null, diagnostics.ToImmutable()); - - operations.Sort(static (left, right) => - { - var opcode = left.Opcode.CompareTo(right.Opcode); - return opcode != 0 ? opcode : string.CompareOrdinal(left.Id, right.Id); - }); - return new CatalogParseResult( - sourcePath, - new CatalogModel(sourcePath, ImmutableArray.CreateRange(operations), bitmap), - ImmutableArray.Empty); - } - - private static CatalogParseResult Failure(string sourcePath, CatalogDiagnostic diagnostic) - { - return new CatalogParseResult(sourcePath, Catalog: null, ImmutableArray.Create(diagnostic)); - } - - private static void ParseOperation( - JsonValue value, - JsonReader reader, - ImmutableArray.Builder diagnostics, - List operations, - Dictionary ids, - Dictionary opcodes, - ref ulong bitmap) - { - if (!TryReadOperationFields(value, reader, diagnostics, out var idValue, out var opcodeValue, - out var managedConstant)) return; - - if (!IsPascalIdentifier(idValue.Text)) - { - diagnostics.Add(reader.CreateDiagnostic(idValue.Span, - "Operation 'id' must be a PascalCase ASCII identifier.")); - return; - } - - if (managedConstant is not null && - !string.Equals(managedConstant.Text, idValue.Text + "Operation", StringComparison.Ordinal)) - diagnostics.Add(reader.CreateDiagnostic(managedConstant.Span, - "Property 'managed.constant' must be '" + idValue.Text + "Operation' for operation '" + idValue.Text + - "'.")); - - if (!int.TryParse(opcodeValue.Text, NumberStyles.None, CultureInfo.InvariantCulture, out var opcode) - || opcode < 0 - || opcode > 63) - { - diagnostics.Add(reader.CreateDiagnostic(opcodeValue.Span, - "Operation 'opcode' must be an integer between 0 and 63.")); - return; - } - - if (ids.TryGetValue(idValue.Text, out var firstId)) - { - diagnostics.Add(reader.CreateDiagnostic(idValue.Span, - "Operation id '" + idValue.Text + "' duplicates an earlier operation.")); - diagnostics.Add(reader.CreateDiagnostic(firstId.Span, - "Operation id '" + idValue.Text + "' is duplicated.")); - return; - } - - if (opcodes.TryGetValue(opcode, out var firstOpcode)) - { - diagnostics.Add(reader.CreateDiagnostic(opcodeValue.Span, - "Operation opcode '" + opcode.ToString(CultureInfo.InvariantCulture) + - "' duplicates an earlier operation.")); - diagnostics.Add(reader.CreateDiagnostic(firstOpcode.Span, - "Operation opcode '" + opcode.ToString(CultureInfo.InvariantCulture) + "' is duplicated.")); - return; - } - - ids.Add(idValue.Text, idValue); - opcodes.Add(opcode, opcodeValue); - bitmap |= 1UL << opcode; - operations.Add(new CatalogOperation(idValue.Text, opcode)); - } - - private static bool TryReadOperationFields( - JsonValue value, - JsonReader reader, - ImmutableArray.Builder diagnostics, - out JsonString idValue, - out JsonNumber opcodeValue, - out JsonString? managedConstant) - { - idValue = null!; - opcodeValue = null!; - managedConstant = null; - if (value is not JsonObject operation) - { - diagnostics.Add(reader.CreateDiagnostic(value.Span, "Every item in 'operations' must be a JSON object.")); - return false; - } - - var requiredId = RequireString(operation, "id", reader, diagnostics); - var requiredOpcode = RequireNumber(operation, "opcode", reader, diagnostics); - ValidateExactTrue(operation, "protected", reader, diagnostics); - ValidateExactTrue(operation, "requiresNativeProtection", reader, diagnostics); - var managed = RequireObject(operation, "managed", reader, diagnostics); - managedConstant = managed is null ? null : RequireString(managed, "constant", reader, diagnostics); - if (managed is not null) _ = RequireString(managed, "wrapper", reader, diagnostics); - if (requiredId is null || requiredOpcode is null) return false; - - idValue = requiredId; - opcodeValue = requiredOpcode; - return true; - } - - private static void ValidateBitmap( - JsonObject bridgeContract, - ulong calculatedBitmap, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var value = RequireString(bridgeContract, "operationBitmap", reader, diagnostics); - if (value is null) return; - - if (!TryParseBitmap(value.Text, out var expected)) - { - diagnostics.Add(reader.CreateDiagnostic(value.Span, - "Property 'bridgeContract.operationBitmap' must have the form 0x followed by exactly 16 uppercase hexadecimal digits.")); - return; - } - - if (expected != calculatedBitmap) - diagnostics.Add(reader.CreateDiagnostic(value.Span, - "Property 'bridgeContract.operationBitmap' is " + value.Text + - " but the declared operation opcodes require " + - "0x" + calculatedBitmap.ToString("X16", CultureInfo.InvariantCulture) + ".")); - } - - private static bool TryParseBitmap(string value, out ulong bitmap) - { - bitmap = 0; - if (value.Length != 18 || value[0] != '0' || value[1] != 'x') return false; - - for (var i = 2; i < value.Length; i++) - { - var character = value[i]; - int digit; - if (character is >= '0' and <= '9') digit = character - '0'; - else if (character is >= 'A' and <= 'F') digit = character - 'A' + 10; - else return false; - bitmap = (bitmap << 4) | (uint)digit; - } - - return true; - } - - private static void ValidateExactNumber( - JsonObject value, - string name, - int expected, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var number = RequireNumber(value, name, reader, diagnostics); - if (number is null) return; - if (!string.Equals(number.Text, expected.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) - diagnostics.Add(reader.CreateDiagnostic(number.Span, - "Property '" + name + "' must be " + expected.ToString(CultureInfo.InvariantCulture) + ".")); - } - - private static void ValidateExactString( - JsonObject value, - string name, - string expected, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var text = RequireString(value, name, reader, diagnostics); - if (text is null) return; - if (!string.Equals(text.Text, expected, StringComparison.Ordinal)) - diagnostics.Add(reader.CreateDiagnostic(text.Span, - "Property '" + name + "' must be '" + expected + "'.")); - } - - private static void ValidateExactTrue( - JsonObject value, - string name, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var item = Require(value, name, reader, diagnostics); - if (item is null) return; - if (item is not JsonBoolean { Value: true }) - diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be true.")); - } - - private static JsonObject? RequireObject( - JsonObject value, - string name, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var item = Require(value, name, reader, diagnostics); - if (item is null) return null; - if (item is JsonObject result) return result; - diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be a JSON object.")); - return null; - } - - private static JsonString? RequireString( - JsonObject value, - string name, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var item = Require(value, name, reader, diagnostics); - if (item is null) return null; - if (item is JsonString result) return result; - diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be a JSON string.")); - return null; - } - - private static JsonNumber? RequireNumber( - JsonObject value, - string name, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - var item = Require(value, name, reader, diagnostics); - if (item is null) return null; - if (item is JsonNumber result) return result; - diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be a JSON number.")); - return null; - } - - private static JsonValue? Require( - JsonObject value, - string name, - JsonReader reader, - ImmutableArray.Builder diagnostics) - { - if (value.TryGet(name, out var item)) return item; - diagnostics.Add(reader.CreateDiagnostic(value.Span, "Property '" + name + "' is required.")); - return null; - } - - private static bool IsPascalIdentifier(string value) - { - if (value.Length == 0 || value[0] is < 'A' or > 'Z') return false; - for (var i = 1; i < value.Length; i++) - { - var character = value[i]; - if (character is not (>= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9')) - return false; - } - - return true; - } - - private abstract class JsonValue(TextSpan span) - { - public TextSpan Span { get; } = span; - } - - private sealed class JsonObject : JsonValue - { - private readonly List _properties; - - public JsonObject(TextSpan span, List properties) - : base(span) - { - _properties = properties; - } - - public bool TryGet(string name, out JsonValue value) - { - for (var i = 0; i < _properties.Count; i++) - if (string.Equals(_properties[i].Name, name, StringComparison.Ordinal)) - { - value = _properties[i].Value; - return true; - } - - value = null!; - return false; - } - } - - private sealed class JsonArray(TextSpan span, List items) : JsonValue(span) - { - public List Items { get; } = items; - } - - private sealed class JsonString(TextSpan span, string text) : JsonValue(span) - { - public string Text { get; } = text; - } - - private sealed class JsonNumber(TextSpan span, string text) : JsonValue(span) - { - public string Text { get; } = text; - } - - private sealed class JsonBoolean(TextSpan span, bool value) : JsonValue(span) - { - public bool Value { get; } = value; - } - - private sealed class JsonNull(TextSpan span) : JsonValue(span); - - private sealed record JsonProperty(string Name, JsonValue Value); - - private sealed class JsonReader - { - private readonly string _path; - private readonly string _text; - private int _position; - - public JsonReader(string path, string text) - { - _path = path; - _text = text; - } - - public CatalogDiagnostic CreateDiagnostic(TextSpan span, string message) - { - var start = GetLinePosition(span.Start); - var end = GetLinePosition(span.End); - return new CatalogDiagnostic(_path, span, new LinePositionSpan(start, end), message, IsConflict: false); - } - - public bool TryParse(out JsonValue root, out CatalogDiagnostic? diagnostic) - { - root = null!; - diagnostic = null; - SkipWhitespace(); - if (!TryParseValue(out root, out diagnostic)) return false; - SkipWhitespace(); - if (_position == _text.Length) return true; - - diagnostic = Error("Unexpected content after the JSON root value."); - return false; - } - - private bool TryParseValue(out JsonValue value, out CatalogDiagnostic? diagnostic) - { - value = null!; - diagnostic = null; - if (_position >= _text.Length) - { - diagnostic = Error("Expected a JSON value."); - return false; - } - - return _text[_position] switch - { - '{' => TryParseObject(out value, out diagnostic), - '[' => TryParseArray(out value, out diagnostic), - '"' => TryParseStringValue(out value, out diagnostic), - 't' => TryParseLiteral("true", boolean: true, out value, out diagnostic), - 'f' => TryParseLiteral("false", boolean: false, out value, out diagnostic), - 'n' => TryParseNull(out value, out diagnostic), - '-' => TryParseNumber(out value, out diagnostic), - >= '0' and <= '9' => TryParseNumber(out value, out diagnostic), - _ => Fail(out value, out diagnostic, "Expected a JSON value."), - }; - } - - private bool TryParseObject(out JsonValue value, out CatalogDiagnostic? diagnostic) - { - var start = _position++; - var properties = new List(); - var names = new Dictionary(StringComparer.Ordinal); - diagnostic = null; - SkipWhitespace(); - if (TryRead('}')) - { - value = new JsonObject(new TextSpan(start, _position - start), properties); - return true; - } - - while (true) - { - if (!TryParseString(out var name, out var nameSpan, out diagnostic)) - return Fail(out value, out diagnostic, "Expected an object property name."); - if (names.ContainsKey(name)) - { - value = null!; - diagnostic = CreateDiagnostic(nameSpan, "JSON object property '" + name + "' is duplicated."); - return false; - } - - names.Add(name, nameSpan); - SkipWhitespace(); - if (!TryRead(':')) - return Fail(out value, out diagnostic, "Expected ':' after an object property name."); - SkipWhitespace(); - if (!TryParseValue(out var propertyValue, out diagnostic)) - { - value = null!; - return false; - } - - properties.Add(new JsonProperty(name, propertyValue)); - SkipWhitespace(); - if (TryRead('}')) - { - value = new JsonObject(new TextSpan(start, _position - start), properties); - return true; - } - - if (!TryRead(',')) - return Fail(out value, out diagnostic, "Expected ',' or '}' after an object property."); - SkipWhitespace(); - } - } - - private bool TryParseArray(out JsonValue value, out CatalogDiagnostic? diagnostic) - { - var start = _position++; - var items = new List(); - diagnostic = null; - SkipWhitespace(); - if (TryRead(']')) - { - value = new JsonArray(new TextSpan(start, _position - start), items); - return true; - } - - while (true) - { - if (!TryParseValue(out var item, out diagnostic)) - { - value = null!; - return false; - } - - items.Add(item); - SkipWhitespace(); - if (TryRead(']')) - { - value = new JsonArray(new TextSpan(start, _position - start), items); - return true; - } - - if (!TryRead(',')) return Fail(out value, out diagnostic, "Expected ',' or ']' after an array item."); - SkipWhitespace(); - } - } - - private bool TryParseStringValue(out JsonValue value, out CatalogDiagnostic? diagnostic) - { - if (!TryParseString(out var text, out var span, out diagnostic)) - { - value = null!; - return false; - } - - value = new JsonString(span, text); - return true; - } - - private bool TryParseString(out string value, out TextSpan span, out CatalogDiagnostic? diagnostic) - { - var start = _position; - value = string.Empty; - span = default; - diagnostic = null; - if (!TryRead('"')) - { - diagnostic = Error("Expected a JSON string."); - return false; - } - - var builder = new StringBuilder(); - while (_position < _text.Length) - { - var character = _text[_position++]; - if (character == '"') - { - value = builder.ToString(); - span = new TextSpan(start, _position - start); - return true; - } - - if (character < ' ') - { - diagnostic = Error("A JSON string cannot contain an unescaped control character."); - return false; - } - - if (character != '\\') - { - builder.Append(character); - continue; - } - - if (!TryAppendEscape(builder, out diagnostic)) return false; - } - - diagnostic = Error("A JSON string is not terminated."); - return false; - } - - private bool TryAppendEscape(StringBuilder builder, out CatalogDiagnostic? diagnostic) - { - diagnostic = null; - if (_position >= _text.Length) - { - diagnostic = Error("A JSON string escape is incomplete."); - return false; - } - - var escape = _text[_position++]; - switch (escape) - { - case '"': - builder.Append('"'); - return true; - case '\\': - builder.Append('\\'); - return true; - case '/': - builder.Append('/'); - return true; - case 'b': - builder.Append('\b'); - return true; - case 'f': - builder.Append('\f'); - return true; - case 'n': - builder.Append('\n'); - return true; - case 'r': - builder.Append('\r'); - return true; - case 't': - builder.Append('\t'); - return true; - case 'u': - if (TryReadUnicodeEscape(out var unicode)) - { - builder.Append((char)unicode); - return true; - } - - diagnostic = Error("A JSON Unicode escape must contain four hexadecimal digits."); - return false; - default: - diagnostic = Error("A JSON string contains an invalid escape sequence."); - return false; - } - } - - private bool TryReadUnicodeEscape(out int value) - { - value = 0; - if (_position > _text.Length - 4) return false; - for (var i = 0; i < 4; i++) - { - var character = _text[_position++]; - if (character is >= '0' and <= '9') value = (value << 4) | (character - '0'); - else if (character is >= 'a' and <= 'f') value = (value << 4) | (character - 'a' + 10); - else if (character is >= 'A' and <= 'F') value = (value << 4) | (character - 'A' + 10); - else return false; - } - - return true; - } - - private bool TryParseLiteral(string literal, bool boolean, out JsonValue value, - out CatalogDiagnostic? diagnostic) - { - var start = _position; - if (!TryReadLiteral(literal)) - return Fail(out value, out diagnostic, "Invalid JSON literal."); - - value = new JsonBoolean(new TextSpan(start, literal.Length), boolean); - diagnostic = null; - return true; - } - - private bool TryParseNull(out JsonValue value, out CatalogDiagnostic? diagnostic) - { - var start = _position; - if (!TryReadLiteral("null")) - return Fail(out value, out diagnostic, "Invalid JSON literal."); - - value = new JsonNull(new TextSpan(start, 4)); - diagnostic = null; - return true; - } - - private bool TryParseNumber(out JsonValue value, out CatalogDiagnostic? diagnostic) - { - var start = _position; - if (TryRead('-') && _position == _text.Length) - return Fail(out value, out diagnostic, "A JSON number cannot end after '-'."); - - if (TryRead('0')) - { - if (_position < _text.Length && IsDigit(_text[_position])) - return Fail(out value, out diagnostic, "A JSON number cannot have a leading zero."); - } - else if (!TryReadDigits()) - { - return Fail(out value, out diagnostic, "A JSON number must contain digits."); - } - - if (TryRead('.')) - if (!TryReadDigits()) - return Fail(out value, out diagnostic, "A JSON fractional part requires digits."); - - if (_position < _text.Length && (_text[_position] == 'e' || _text[_position] == 'E')) - { - _position++; - if (_position < _text.Length && (_text[_position] == '+' || _text[_position] == '-')) _position++; - if (!TryReadDigits()) return Fail(out value, out diagnostic, "A JSON exponent requires digits."); - } - - value = new JsonNumber(new TextSpan(start, _position - start), _text.Substring(start, _position - start)); - diagnostic = null; - return true; - } - - private bool TryReadDigits() - { - var start = _position; - while (_position < _text.Length && IsDigit(_text[_position])) _position++; - return _position > start; - } - - private bool TryReadLiteral(string literal) - { - if (_position > _text.Length - literal.Length) return false; - for (var i = 0; i < literal.Length; i++) - if (_text[_position + i] != literal[i]) - return false; - _position += literal.Length; - return true; - } - - private void SkipWhitespace() - { - while (_position < _text.Length) - { - var character = _text[_position]; - if (character != ' ' && character != '\t' && character != '\r' && character != '\n') return; - _position++; - } - } - - private bool TryRead(char expected) - { - if (_position >= _text.Length || _text[_position] != expected) return false; - _position++; - return true; - } - - private static bool IsDigit(char value) - { - return value is >= '0' and <= '9'; - } - - private bool Fail(out JsonValue value, out CatalogDiagnostic? diagnostic, string message) - { - value = null!; - diagnostic = Error(message); - return false; - } - - private CatalogDiagnostic Error(string message) - { - return CreateDiagnostic(new TextSpan(_position, 0), message); - } - - private LinePosition GetLinePosition(int position) - { - var line = 0; - var character = 0; - for (var i = 0; i < position; i++) - if (_text[i] == '\n') - { - line++; - character = 0; - } - else if (_text[i] != '\r') - { - character++; - } - - return new LinePosition(line, character); - } - } + private const string FileName = "protected-operations.json"; + private const string ExpectedCatalogId = "cheatengine-sdk-lua-protected-operations"; + + public static bool IsCatalogFile(string path) + { + if (path is null) + { + return false; + } + + int slash = path.LastIndexOf('/'); + int backslash = path.LastIndexOf('\\'); + int separator = slash > backslash ? slash : backslash; + return string.Equals(path[(separator + 1)..], FileName, StringComparison.OrdinalIgnoreCase); + } + + public static CatalogParseResult Parse(CatalogInput input) + { + try + { + return ParseCore(input); + } + catch (Exception exception) + { + JsonReader reader = new(input.Path, input.Text); + return Failure(input.Path, reader.CreateDiagnostic(new TextSpan(0, 0), + "The catalogue parser recovered an unexpected " + exception.GetType().Name + ".")); + } + } + + private static CatalogParseResult ParseCore(CatalogInput input) + { + JsonReader reader = new(input.Path, input.Text); + if (!reader.TryParse(out JsonValue root, out CatalogDiagnostic? syntaxDiagnostic)) + { + return Failure(input.Path, syntaxDiagnostic!); + } + + if (root is not JsonObject rootObject) + { + return Failure(input.Path, reader.CreateDiagnostic(root.Span, "The catalogue root must be a JSON object.")); + } + + ImmutableArray.Builder diagnostics = ImmutableArray.CreateBuilder(); + ValidateExactNumber(rootObject, "schemaVersion", 1, reader, diagnostics); + ValidateExactString(rootObject, "catalogId", ExpectedCatalogId, reader, diagnostics); + + JsonObject? bridgeContract = RequireObject(rootObject, "bridgeContract", reader, diagnostics); + ValidateBridgeContract(bridgeContract, reader, diagnostics); + + JsonValue? operationsValue = Require(rootObject, "operations", reader, diagnostics); + if (operationsValue is not JsonArray operationsArray) + { + if (operationsValue is not null) + { + diagnostics.Add(reader.CreateDiagnostic(operationsValue.Span, + "Property 'operations' must be a JSON array.")); + } + + return new CatalogParseResult(input.Path, null, diagnostics.ToImmutable()); + } + + return ParseOperations(input.Path, bridgeContract, operationsArray, reader, diagnostics); + } + + private static void ValidateBridgeContract( + JsonObject? bridgeContract, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + if (bridgeContract is null) + { + return; + } + + ValidateExactNumber(bridgeContract, "abiMajor", 1, reader, diagnostics); + ValidateExactNumber(bridgeContract, "minimumAbiMinor", 1, reader, diagnostics); + ValidateExactNumber(bridgeContract, "operationBitmapWidth", 64, reader, diagnostics); + } + + private static CatalogParseResult ParseOperations( + string sourcePath, + JsonObject? bridgeContract, + JsonArray operationsArray, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + if (operationsArray.Items.Count == 0) + { + diagnostics.Add(reader.CreateDiagnostic(operationsArray.Span, + "Property 'operations' must contain at least one protected operation.")); + } + + List operations = new(); + Dictionary ids = new(StringComparer.Ordinal); + Dictionary opcodes = new(); + ulong bitmap = 0; + for (int i = 0; i < operationsArray.Items.Count; i++) + { + ParseOperation(operationsArray.Items[i], reader, diagnostics, operations, ids, opcodes, ref bitmap); + } + + if (bridgeContract is not null) + { + ValidateBitmap(bridgeContract, bitmap, reader, diagnostics); + } + + if (diagnostics.Count > 0) + { + return new CatalogParseResult(sourcePath, null, diagnostics.ToImmutable()); + } + + operations.Sort(static (left, right) => + { + int opcode = left.Opcode.CompareTo(right.Opcode); + return opcode != 0 ? opcode : string.CompareOrdinal(left.Id, right.Id); + }); + return new CatalogParseResult( + sourcePath, + new CatalogModel(sourcePath, ImmutableArray.CreateRange(operations), bitmap), + ImmutableArray.Empty); + } + + private static CatalogParseResult Failure(string sourcePath, CatalogDiagnostic diagnostic) + { + return new CatalogParseResult(sourcePath, null, ImmutableArray.Create(diagnostic)); + } + + private static void ParseOperation( + JsonValue value, + JsonReader reader, + ImmutableArray.Builder diagnostics, + List operations, + Dictionary ids, + Dictionary opcodes, + ref ulong bitmap) + { + if (!TryReadOperationFields(value, reader, diagnostics, out JsonString idValue, out JsonNumber opcodeValue, + out JsonString? managedConstant)) + { + return; + } + + if (!IsPascalIdentifier(idValue.Text)) + { + diagnostics.Add(reader.CreateDiagnostic(idValue.Span, + "Operation 'id' must be a PascalCase ASCII identifier.")); + return; + } + + if (managedConstant is not null && + !string.Equals(managedConstant.Text, idValue.Text + "Operation", StringComparison.Ordinal)) + { + diagnostics.Add(reader.CreateDiagnostic(managedConstant.Span, + "Property 'managed.constant' must be '" + idValue.Text + "Operation' for operation '" + idValue.Text + + "'.")); + } + + if (!int.TryParse(opcodeValue.Text, NumberStyles.None, CultureInfo.InvariantCulture, out int opcode) + || opcode < 0 + || opcode > 63) + { + diagnostics.Add(reader.CreateDiagnostic(opcodeValue.Span, + "Operation 'opcode' must be an integer between 0 and 63.")); + return; + } + + if (ids.TryGetValue(idValue.Text, out JsonValue? firstId)) + { + diagnostics.Add(reader.CreateDiagnostic(idValue.Span, + "Operation id '" + idValue.Text + "' duplicates an earlier operation.")); + diagnostics.Add(reader.CreateDiagnostic(firstId.Span, + "Operation id '" + idValue.Text + "' is duplicated.")); + return; + } + + if (opcodes.TryGetValue(opcode, out JsonValue? firstOpcode)) + { + diagnostics.Add(reader.CreateDiagnostic(opcodeValue.Span, + "Operation opcode '" + opcode.ToString(CultureInfo.InvariantCulture) + + "' duplicates an earlier operation.")); + diagnostics.Add(reader.CreateDiagnostic(firstOpcode.Span, + "Operation opcode '" + opcode.ToString(CultureInfo.InvariantCulture) + "' is duplicated.")); + return; + } + + ids.Add(idValue.Text, idValue); + opcodes.Add(opcode, opcodeValue); + bitmap |= 1UL << opcode; + operations.Add(new CatalogOperation(idValue.Text, opcode)); + } + + private static bool TryReadOperationFields( + JsonValue value, + JsonReader reader, + ImmutableArray.Builder diagnostics, + out JsonString idValue, + out JsonNumber opcodeValue, + out JsonString? managedConstant) + { + idValue = null!; + opcodeValue = null!; + managedConstant = null; + if (value is not JsonObject operation) + { + diagnostics.Add(reader.CreateDiagnostic(value.Span, "Every item in 'operations' must be a JSON object.")); + return false; + } + + JsonString? requiredId = RequireString(operation, "id", reader, diagnostics); + JsonNumber? requiredOpcode = RequireNumber(operation, "opcode", reader, diagnostics); + ValidateExactTrue(operation, "protected", reader, diagnostics); + ValidateExactTrue(operation, "requiresNativeProtection", reader, diagnostics); + JsonObject? managed = RequireObject(operation, "managed", reader, diagnostics); + managedConstant = managed is null ? null : RequireString(managed, "constant", reader, diagnostics); + if (managed is not null) + { + _ = RequireString(managed, "wrapper", reader, diagnostics); + } + + if (requiredId is null || requiredOpcode is null) + { + return false; + } + + idValue = requiredId; + opcodeValue = requiredOpcode; + return true; + } + + private static void ValidateBitmap( + JsonObject bridgeContract, + ulong calculatedBitmap, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonString? value = RequireString(bridgeContract, "operationBitmap", reader, diagnostics); + if (value is null) + { + return; + } + + if (!TryParseBitmap(value.Text, out ulong expected)) + { + diagnostics.Add(reader.CreateDiagnostic(value.Span, + "Property 'bridgeContract.operationBitmap' must have the form 0x followed by exactly 16 uppercase hexadecimal digits.")); + return; + } + + if (expected != calculatedBitmap) + { + diagnostics.Add(reader.CreateDiagnostic(value.Span, + "Property 'bridgeContract.operationBitmap' is " + value.Text + + " but the declared operation opcodes require " + + "0x" + calculatedBitmap.ToString("X16", CultureInfo.InvariantCulture) + ".")); + } + } + + private static bool TryParseBitmap(string value, out ulong bitmap) + { + bitmap = 0; + if (value.Length != 18 || value[0] != '0' || value[1] != 'x') + { + return false; + } + + for (int i = 2; i < value.Length; i++) + { + char character = value[i]; + int digit; + if (character is >= '0' and <= '9') + { + digit = character - '0'; + } + else if (character is >= 'A' and <= 'F') + { + digit = character - 'A' + 10; + } + else + { + return false; + } + + bitmap = (bitmap << 4) | (uint) digit; + } + + return true; + } + + private static void ValidateExactNumber( + JsonObject value, + string name, + int expected, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonNumber? number = RequireNumber(value, name, reader, diagnostics); + if (number is null) + { + return; + } + + if (!string.Equals(number.Text, expected.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) + { + diagnostics.Add(reader.CreateDiagnostic(number.Span, + "Property '" + name + "' must be " + expected.ToString(CultureInfo.InvariantCulture) + ".")); + } + } + + private static void ValidateExactString( + JsonObject value, + string name, + string expected, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonString? text = RequireString(value, name, reader, diagnostics); + if (text is null) + { + return; + } + + if (!string.Equals(text.Text, expected, StringComparison.Ordinal)) + { + diagnostics.Add(reader.CreateDiagnostic(text.Span, + "Property '" + name + "' must be '" + expected + "'.")); + } + } + + private static void ValidateExactTrue( + JsonObject value, + string name, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonValue? item = Require(value, name, reader, diagnostics); + if (item is null) + { + return; + } + + if (item is not JsonBoolean { Value: true }) + { + diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be true.")); + } + } + + private static JsonObject? RequireObject( + JsonObject value, + string name, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonValue? item = Require(value, name, reader, diagnostics); + if (item is null) + { + return null; + } + + if (item is JsonObject result) + { + return result; + } + + diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be a JSON object.")); + return null; + } + + private static JsonString? RequireString( + JsonObject value, + string name, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonValue? item = Require(value, name, reader, diagnostics); + if (item is null) + { + return null; + } + + if (item is JsonString result) + { + return result; + } + + diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be a JSON string.")); + return null; + } + + private static JsonNumber? RequireNumber( + JsonObject value, + string name, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + JsonValue? item = Require(value, name, reader, diagnostics); + if (item is null) + { + return null; + } + + if (item is JsonNumber result) + { + return result; + } + + diagnostics.Add(reader.CreateDiagnostic(item.Span, "Property '" + name + "' must be a JSON number.")); + return null; + } + + private static JsonValue? Require( + JsonObject value, + string name, + JsonReader reader, + ImmutableArray.Builder diagnostics) + { + if (value.TryGet(name, out JsonValue item)) + { + return item; + } + + diagnostics.Add(reader.CreateDiagnostic(value.Span, "Property '" + name + "' is required.")); + return null; + } + + private static bool IsPascalIdentifier(string value) + { + if (value.Length == 0 || value[0] is < 'A' or > 'Z') + { + return false; + } + + for (int i = 1; i < value.Length; i++) + { + char character = value[i]; + if (character is not (>= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9')) + { + return false; + } + } + + return true; + } + + private abstract class JsonValue(TextSpan span) + { + public TextSpan Span + { + get; + } = span; + } + + private sealed class JsonObject : JsonValue + { + private readonly List _properties; + + public JsonObject(TextSpan span, List properties) + : base(span) + { + _properties = properties; + } + + public bool TryGet(string name, out JsonValue value) + { + for (int i = 0; i < _properties.Count; i++) + { + if (string.Equals(_properties[i].Name, name, StringComparison.Ordinal)) + { + value = _properties[i].Value; + return true; + } + } + + value = null!; + return false; + } + } + + private sealed class JsonArray(TextSpan span, List items) : JsonValue(span) + { + public List Items + { + get; + } = items; + } + + private sealed class JsonString(TextSpan span, string text) : JsonValue(span) + { + public string Text + { + get; + } = text; + } + + private sealed class JsonNumber(TextSpan span, string text) : JsonValue(span) + { + public string Text + { + get; + } = text; + } + + private sealed class JsonBoolean(TextSpan span, bool value) : JsonValue(span) + { + public bool Value + { + get; + } = value; + } + + private sealed class JsonNull(TextSpan span) : JsonValue(span); + + private sealed record JsonProperty(string Name, JsonValue Value); + + private sealed class JsonReader + { + private readonly string _path; + private readonly string _text; + private int _position; + + public JsonReader(string path, string text) + { + _path = path; + _text = text; + } + + public CatalogDiagnostic CreateDiagnostic(TextSpan span, string message) + { + LinePosition start = GetLinePosition(span.Start); + LinePosition end = GetLinePosition(span.End); + return new CatalogDiagnostic(_path, span, new LinePositionSpan(start, end), message, false); + } + + public bool TryParse(out JsonValue root, out CatalogDiagnostic? diagnostic) + { + root = null!; + diagnostic = null; + SkipWhitespace(); + if (!TryParseValue(out root, out diagnostic)) + { + return false; + } + + SkipWhitespace(); + if (_position == _text.Length) + { + return true; + } + + diagnostic = Error("Unexpected content after the JSON root value."); + return false; + } + + private bool TryParseValue(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + value = null!; + diagnostic = null; + if (_position >= _text.Length) + { + diagnostic = Error("Expected a JSON value."); + return false; + } + + return _text[_position] switch + { + '{' => TryParseObject(out value, out diagnostic), + '[' => TryParseArray(out value, out diagnostic), + '"' => TryParseStringValue(out value, out diagnostic), + 't' => TryParseLiteral("true", true, out value, out diagnostic), + 'f' => TryParseLiteral("false", false, out value, out diagnostic), + 'n' => TryParseNull(out value, out diagnostic), + '-' => TryParseNumber(out value, out diagnostic), + >= '0' and <= '9' => TryParseNumber(out value, out diagnostic), + _ => Fail(out value, out diagnostic, "Expected a JSON value.") + }; + } + + private bool TryParseObject(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + int start = _position++; + List properties = new(); + Dictionary names = new(StringComparer.Ordinal); + diagnostic = null; + SkipWhitespace(); + if (TryRead('}')) + { + value = new JsonObject(new TextSpan(start, _position - start), properties); + return true; + } + + while (true) + { + if (!TryParseString(out string name, out TextSpan nameSpan, out diagnostic)) + { + return Fail(out value, out diagnostic, "Expected an object property name."); + } + + if (names.ContainsKey(name)) + { + value = null!; + diagnostic = CreateDiagnostic(nameSpan, "JSON object property '" + name + "' is duplicated."); + return false; + } + + names.Add(name, nameSpan); + SkipWhitespace(); + if (!TryRead(':')) + { + return Fail(out value, out diagnostic, "Expected ':' after an object property name."); + } + + SkipWhitespace(); + if (!TryParseValue(out JsonValue propertyValue, out diagnostic)) + { + value = null!; + return false; + } + + properties.Add(new JsonProperty(name, propertyValue)); + SkipWhitespace(); + if (TryRead('}')) + { + value = new JsonObject(new TextSpan(start, _position - start), properties); + return true; + } + + if (!TryRead(',')) + { + return Fail(out value, out diagnostic, "Expected ',' or '}' after an object property."); + } + + SkipWhitespace(); + } + } + + private bool TryParseArray(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + int start = _position++; + List items = new(); + diagnostic = null; + SkipWhitespace(); + if (TryRead(']')) + { + value = new JsonArray(new TextSpan(start, _position - start), items); + return true; + } + + while (true) + { + if (!TryParseValue(out JsonValue item, out diagnostic)) + { + value = null!; + return false; + } + + items.Add(item); + SkipWhitespace(); + if (TryRead(']')) + { + value = new JsonArray(new TextSpan(start, _position - start), items); + return true; + } + + if (!TryRead(',')) + { + return Fail(out value, out diagnostic, "Expected ',' or ']' after an array item."); + } + + SkipWhitespace(); + } + } + + private bool TryParseStringValue(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + if (!TryParseString(out string text, out TextSpan span, out diagnostic)) + { + value = null!; + return false; + } + + value = new JsonString(span, text); + return true; + } + + private bool TryParseString(out string value, out TextSpan span, out CatalogDiagnostic? diagnostic) + { + int start = _position; + value = string.Empty; + span = default; + diagnostic = null; + if (!TryRead('"')) + { + diagnostic = Error("Expected a JSON string."); + return false; + } + + StringBuilder builder = new(); + while (_position < _text.Length) + { + char character = _text[_position++]; + if (character == '"') + { + value = builder.ToString(); + span = new TextSpan(start, _position - start); + return true; + } + + if (character < ' ') + { + diagnostic = Error("A JSON string cannot contain an unescaped control character."); + return false; + } + + if (character != '\\') + { + builder.Append(character); + continue; + } + + if (!TryAppendEscape(builder, out diagnostic)) + { + return false; + } + } + + diagnostic = Error("A JSON string is not terminated."); + return false; + } + + private bool TryAppendEscape(StringBuilder builder, out CatalogDiagnostic? diagnostic) + { + diagnostic = null; + if (_position >= _text.Length) + { + diagnostic = Error("A JSON string escape is incomplete."); + return false; + } + + char escape = _text[_position++]; + switch (escape) + { + case '"': + builder.Append('"'); + return true; + case '\\': + builder.Append('\\'); + return true; + case '/': + builder.Append('/'); + return true; + case 'b': + builder.Append('\b'); + return true; + case 'f': + builder.Append('\f'); + return true; + case 'n': + builder.Append('\n'); + return true; + case 'r': + builder.Append('\r'); + return true; + case 't': + builder.Append('\t'); + return true; + case 'u': + if (TryReadUnicodeEscape(out int unicode)) + { + builder.Append((char) unicode); + return true; + } + + diagnostic = Error("A JSON Unicode escape must contain four hexadecimal digits."); + return false; + default: + diagnostic = Error("A JSON string contains an invalid escape sequence."); + return false; + } + } + + private bool TryReadUnicodeEscape(out int value) + { + value = 0; + if (_position > _text.Length - 4) + { + return false; + } + + for (int i = 0; i < 4; i++) + { + char character = _text[_position++]; + if (character is >= '0' and <= '9') + { + value = (value << 4) | (character - '0'); + } + else if (character is >= 'a' and <= 'f') + { + value = (value << 4) | (character - 'a' + 10); + } + else if (character is >= 'A' and <= 'F') + { + value = (value << 4) | (character - 'A' + 10); + } + else + { + return false; + } + } + + return true; + } + + private bool TryParseLiteral(string literal, bool boolean, out JsonValue value, + out CatalogDiagnostic? diagnostic) + { + int start = _position; + if (!TryReadLiteral(literal)) + { + return Fail(out value, out diagnostic, "Invalid JSON literal."); + } + + value = new JsonBoolean(new TextSpan(start, literal.Length), boolean); + diagnostic = null; + return true; + } + + private bool TryParseNull(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + int start = _position; + if (!TryReadLiteral("null")) + { + return Fail(out value, out diagnostic, "Invalid JSON literal."); + } + + value = new JsonNull(new TextSpan(start, 4)); + diagnostic = null; + return true; + } + + private bool TryParseNumber(out JsonValue value, out CatalogDiagnostic? diagnostic) + { + int start = _position; + if (TryRead('-') && _position == _text.Length) + { + return Fail(out value, out diagnostic, "A JSON number cannot end after '-'."); + } + + if (TryRead('0')) + { + if (_position < _text.Length && IsDigit(_text[_position])) + { + return Fail(out value, out diagnostic, "A JSON number cannot have a leading zero."); + } + } + else if (!TryReadDigits()) + { + return Fail(out value, out diagnostic, "A JSON number must contain digits."); + } + + if (TryRead('.')) + { + if (!TryReadDigits()) + { + return Fail(out value, out diagnostic, "A JSON fractional part requires digits."); + } + } + + if (_position < _text.Length && (_text[_position] == 'e' || _text[_position] == 'E')) + { + _position++; + if (_position < _text.Length && (_text[_position] == '+' || _text[_position] == '-')) + { + _position++; + } + + if (!TryReadDigits()) + { + return Fail(out value, out diagnostic, "A JSON exponent requires digits."); + } + } + + value = new JsonNumber(new TextSpan(start, _position - start), _text.Substring(start, _position - start)); + diagnostic = null; + return true; + } + + private bool TryReadDigits() + { + int start = _position; + while (_position < _text.Length && IsDigit(_text[_position])) + { + _position++; + } + + return _position > start; + } + + private bool TryReadLiteral(string literal) + { + if (_position > _text.Length - literal.Length) + { + return false; + } + + for (int i = 0; i < literal.Length; i++) + { + if (_text[_position + i] != literal[i]) + { + return false; + } + } + + _position += literal.Length; + return true; + } + + private void SkipWhitespace() + { + while (_position < _text.Length) + { + char character = _text[_position]; + if (character != ' ' && character != '\t' && character != '\r' && character != '\n') + { + return; + } + + _position++; + } + } + + private bool TryRead(char expected) + { + if (_position >= _text.Length || _text[_position] != expected) + { + return false; + } + + _position++; + return true; + } + + private static bool IsDigit(char value) + { + return value is >= '0' and <= '9'; + } + + private bool Fail(out JsonValue value, out CatalogDiagnostic? diagnostic, string message) + { + value = null!; + diagnostic = Error(message); + return false; + } + + private CatalogDiagnostic Error(string message) + { + return CreateDiagnostic(new TextSpan(_position, 0), message); + } + + private LinePosition GetLinePosition(int position) + { + int line = 0; + int character = 0; + for (int i = 0; i < position; i++) + { + if (_text[i] == '\n') + { + line++; + character = 0; + } + else if (_text[i] != '\r') + { + character++; + } + } + + return new LinePosition(line, character); + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Emit/LuaProtectedOperationEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Emit/LuaProtectedOperationEmitter.cs index 79b352b2..d781f64d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Emit/LuaProtectedOperationEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Emit/LuaProtectedOperationEmitter.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Text; + using CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Emit; @@ -8,46 +10,46 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Emit; /// Emits the allocation-free managed operation selector consumed by the protected bridge P/Invoke wrapper. internal static class LuaProtectedOperationEmitter { - public const string HintName = "CheatEngine.SDK.Lua.Interop.Protected.LuaProtectedOperation.g.cs"; + public const string HintName = "CheatEngine.SDK.Lua.Interop.Protected.LuaProtectedOperation.g.cs"; - public static SourceText Emit(CatalogModel catalog) - { - var text = new StringBuilder(); - text.AppendLine("// "); - text.AppendLine("#nullable enable"); - text.AppendLine(); - text.AppendLine("namespace CheatEngine.SDK.Lua.Interop.Protected;"); - text.AppendLine(); - text.AppendLine( - "// Values are the C11 operation numbers. They are generated from protected-operations.json; changing an opcode changes the ABI."); - text.AppendLine("internal enum LuaProtectedOperation"); - text.AppendLine("{"); - for (var i = 0; i < catalog.Operations.Length; i++) - { - var operation = catalog.Operations[i]; - text.Append(" ").Append(operation.Id).Append(" = ") - .Append(operation.Opcode.ToString(CultureInfo.InvariantCulture)); - text.AppendLine(i == catalog.Operations.Length - 1 ? string.Empty : ","); - } + public static SourceText Emit(CatalogModel catalog) + { + StringBuilder text = new(); + text.AppendLine("// "); + text.AppendLine("#nullable enable"); + text.AppendLine(); + text.AppendLine("namespace CheatEngine.SDK.Lua.Interop.Protected;"); + text.AppendLine(); + text.AppendLine( + "// Values are the C11 operation numbers. They are generated from protected-operations.json; changing an opcode changes the ABI."); + text.AppendLine("internal enum LuaProtectedOperation"); + text.AppendLine("{"); + for (int i = 0; i < catalog.Operations.Length; i++) + { + CatalogOperation operation = catalog.Operations[i]; + text.Append(" ").Append(operation.Id).Append(" = ") + .Append(operation.Opcode.ToString(CultureInfo.InvariantCulture)); + text.AppendLine(i == catalog.Operations.Length - 1 ? string.Empty : ","); + } - text.AppendLine("}"); - text.AppendLine(); - text.AppendLine( - "// The bitmap mirrors the native C11 contract and supports sparse opcode assignments without an enum-order assumption."); - text.AppendLine("internal static class LuaProtectedOperationContract"); - text.AppendLine("{"); - text.Append(" internal const int Count = ") - .Append(catalog.Operations.Length.ToString(CultureInfo.InvariantCulture)).AppendLine(";"); - text.Append(" internal const ulong RequiredBitmap = 0x") - .Append(catalog.RequiredBitmap.ToString("X16", CultureInfo.InvariantCulture)).AppendLine("UL;"); - text.AppendLine(); - text.AppendLine(" internal static bool IsDefined(LuaProtectedOperation operation)"); - text.AppendLine(" {"); - text.AppendLine(" var opcode = (uint)operation;"); - text.AppendLine(" return opcode < 64 && (RequiredBitmap & (1UL << (int)opcode)) != 0;"); - text.AppendLine(" }"); - text.AppendLine("}"); + text.AppendLine("}"); + text.AppendLine(); + text.AppendLine( + "// The bitmap mirrors the native C11 contract and supports sparse opcode assignments without an enum-order assumption."); + text.AppendLine("internal static class LuaProtectedOperationContract"); + text.AppendLine("{"); + text.Append(" internal const int Count = ") + .Append(catalog.Operations.Length.ToString(CultureInfo.InvariantCulture)).AppendLine(";"); + text.Append(" internal const ulong RequiredBitmap = 0x") + .Append(catalog.RequiredBitmap.ToString("X16", CultureInfo.InvariantCulture)).AppendLine("UL;"); + text.AppendLine(); + text.AppendLine(" internal static bool IsDefined(LuaProtectedOperation operation)"); + text.AppendLine(" {"); + text.AppendLine(" var opcode = (uint)operation;"); + text.AppendLine(" return opcode < 64 && (RequiredBitmap & (1UL << (int)opcode)) != 0;"); + text.AppendLine(" }"); + text.AppendLine("}"); - return SourceText.From(text.ToString(), Encoding.UTF8); - } + return SourceText.From(text.ToString(), Encoding.UTF8); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/LuaBridgeContractGenerator.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/LuaBridgeContractGenerator.cs index ce91ab3d..6bd00f10 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/LuaBridgeContractGenerator.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/LuaBridgeContractGenerator.cs @@ -1,5 +1,6 @@ using CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Catalog; using CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Emit; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract; @@ -21,25 +22,31 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract; [Generator(LanguageNames.CSharp)] public sealed class LuaBridgeContractGenerator : IIncrementalGenerator { - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var inputs = context.AdditionalTextsProvider - .Where(static text => ProtectedOperationCatalogParser.IsCatalogFile(text.Path)) - .Select(static (text, cancellationToken) => - new CatalogInput(text.Path, text.GetText(cancellationToken)?.ToString() ?? string.Empty)); + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider inputs = context.AdditionalTextsProvider + .Where(static text => ProtectedOperationCatalogParser.IsCatalogFile(text.Path)) + .Select(static (text, cancellationToken) => + new CatalogInput(text.Path, text.GetText(cancellationToken)?.ToString() ?? string.Empty)); - var parsed = inputs.Select(static (input, _) => ProtectedOperationCatalogParser.Parse(input)); - var plans = parsed.Collect().Select(static (catalogs, _) => LuaBridgeContractGenerationPlan.Create(catalogs)); + IncrementalValuesProvider parsed = inputs.Select(static (input, _) => + ProtectedOperationCatalogParser.Parse(input)); + IncrementalValueProvider plans = parsed.Collect() + .Select(static (catalogs, _) => LuaBridgeContractGenerationPlan.Create(catalogs)); - context.RegisterSourceOutput(plans, static (productionContext, plan) => - { - for (var i = 0; i < plan.Diagnostics.Length; i++) - productionContext.ReportDiagnostic(LuaBridgeContractDiagnostics.Create(plan.Diagnostics[i])); + context.RegisterSourceOutput(plans, static (productionContext, plan) => + { + for (int i = 0; i < plan.Diagnostics.Length; i++) + { + productionContext.ReportDiagnostic(LuaBridgeContractDiagnostics.Create(plan.Diagnostics[i])); + } - if (plan.Catalog is not null) - productionContext.AddSource(LuaProtectedOperationEmitter.HintName, - LuaProtectedOperationEmitter.Emit(plan.Catalog)); - }); - } + if (plan.Catalog is not null) + { + productionContext.AddSource(LuaProtectedOperationEmitter.HintName, + LuaProtectedOperationEmitter.Emit(plan.Catalog)); + } + }); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/AnnotationsMetadataNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/AnnotationsMetadataNames.cs index 2e762d4e..2c5a1265 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/AnnotationsMetadataNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/AnnotationsMetadataNames.cs @@ -24,60 +24,60 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// this dotted constant; that is a different, valid representation of the same fact, not a duplicate of this one. /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class AnnotationsMetadataNames { - /// - /// Metadata name of CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute, the marker attribute of a - /// plugin - /// class. - /// - public const string CheatEnginePluginAttribute = "CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute, the marker attribute of a + /// plugin + /// class. + /// + public const string CheatEnginePluginAttribute = "CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute"; - /// - /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute, which exports a static method to Lua - /// as - /// a global C function. - /// - public const string LuaFunctionAttribute = "CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute, which exports a static method to Lua + /// as + /// a global C function. + /// + public const string LuaFunctionAttribute = "CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute"; - /// - /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute, which binds a partial member to a Lua - /// global. - /// - public const string LuaGlobalAttribute = "CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute, which binds a partial member to a Lua + /// global. + /// + public const string LuaGlobalAttribute = "CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute"; - /// - /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute, which selects a concrete - /// static marshaller for one generated binding value. - /// - public const string LuaMarshallerAttribute = "CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute, which selects a concrete + /// static marshaller for one generated binding value. + /// + public const string LuaMarshallerAttribute = "CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute"; - /// - /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaClassAttribute, which identifies a borrowed - /// Cheat Engine object-handle wrapper. - /// - public const string LuaClassAttribute = "CheatEngine.SDK.Annotations.Lua.LuaClassAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaClassAttribute, which identifies a borrowed + /// Cheat Engine object-handle wrapper. + /// + public const string LuaClassAttribute = "CheatEngine.SDK.Annotations.Lua.LuaClassAttribute"; - /// - /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute, which binds an instance partial - /// method on a generated object-handle wrapper. - /// - public const string LuaMethodAttribute = "CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute, which binds an instance partial + /// method on a generated object-handle wrapper. + /// + public const string LuaMethodAttribute = "CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute"; - /// - /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute, which binds a partial property - /// on a generated object-handle wrapper. - /// - public const string LuaPropertyAttribute = "CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute"; + /// + /// Metadata name of CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute, which binds a partial property + /// on a generated object-handle wrapper. + /// + public const string LuaPropertyAttribute = "CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute"; - /// - /// Dotted metadata name of CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin, the base class of every plugin, - /// for - /// use with GetTypeByMetadataName. - /// - public const string CheatEnginePluginBase = "CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin"; + /// + /// Dotted metadata name of CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin, the base class of every plugin, + /// for + /// use with GetTypeByMetadataName. + /// + public const string CheatEnginePluginBase = "CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin"; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/BuildProperty.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/BuildProperty.cs index 9b567b79..f51239aa 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/BuildProperty.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/BuildProperty.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; namespace CheatEngine.SDK.SourceGenerators.Shared; @@ -15,31 +16,34 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// itself is not value-equatable. /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class BuildProperty { - /// Prefix of every MSBuild property key; concatenate with the property name into a constant. - public const string KeyPrefix = "build_property."; + /// Prefix of every MSBuild property key; concatenate with the property name into a constant. + public const string KeyPrefix = "build_property."; - /// - /// Reads a boolean property. Returns when the key is absent (the property is not - /// compiler-visible, for example in a project that references the generator without the package's props), empty, - /// or not a boolean. Parsing follows MSBuild usage: case-insensitive /, - /// surrounding white - /// space ignored. - /// - /// AnalyzerConfigOptionsProvider.GlobalOptions. - /// Full key, included. - /// Value used when the property carries no usable boolean. - public static bool ReadBoolean(AnalyzerConfigOptions globalOptions, string key, bool defaultValue) - { - if (globalOptions is null) throw new ArgumentNullException(nameof(globalOptions)); + /// + /// Reads a boolean property. Returns when the key is absent (the property is not + /// compiler-visible, for example in a project that references the generator without the package's props), empty, + /// or not a boolean. Parsing follows MSBuild usage: case-insensitive /, + /// surrounding white + /// space ignored. + /// + /// AnalyzerConfigOptionsProvider.GlobalOptions. + /// Full key, included. + /// Value used when the property carries no usable boolean. + public static bool ReadBoolean(AnalyzerConfigOptions globalOptions, string key, bool defaultValue) + { + if (globalOptions is null) + { + throw new ArgumentNullException(nameof(globalOptions)); + } - return globalOptions.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) - ? value - : defaultValue; - } + return globalOptions.TryGetValue(key, out string? raw) && bool.TryParse(raw, out bool value) + ? value + : defaultValue; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/CSharpLiteral.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/CSharpLiteral.cs index 7c5e4b6b..79f01df5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/CSharpLiteral.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/CSharpLiteral.cs @@ -26,119 +26,131 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// internal static class CSharpLiteral { - private const string HexDigits = "0123456789ABCDEF"; - private const char ReplacementCharacter = '\uFFFD'; + private const string HexDigits = "0123456789ABCDEF"; + private const char ReplacementCharacter = '\uFFFD'; - /// Returns as a regular C# string literal, quotes included. - public static string ToStringLiteral(string value) - { - StringBuilder builder = new(GuessCapacity(value)); - AppendStringLiteral(builder, value); - return builder.ToString(); - } + /// Returns as a regular C# string literal, quotes included. + public static string ToStringLiteral(string value) + { + StringBuilder builder = new(GuessCapacity(value)); + AppendStringLiteral(builder, value); + return builder.ToString(); + } - /// Returns as a C# UTF-8 string literal ("..."u8). - public static string ToUtf8Literal(string value) - { - StringBuilder builder = new(GuessCapacity(value) + 2); - AppendUtf8Literal(builder, value); - return builder.ToString(); - } + /// Returns as a C# UTF-8 string literal ("..."u8). + public static string ToUtf8Literal(string value) + { + StringBuilder builder = new(GuessCapacity(value) + 2); + AppendUtf8Literal(builder, value); + return builder.ToString(); + } - /// Appends as a regular C# string literal, quotes included. - public static void AppendStringLiteral(StringBuilder builder, string value) - { - AppendQuoted(builder, value, false); - } + /// Appends as a regular C# string literal, quotes included. + public static void AppendStringLiteral(StringBuilder builder, string value) + { + AppendQuoted(builder, value, false); + } - /// Appends as a C# UTF-8 string literal ("..."u8). - public static void AppendUtf8Literal(StringBuilder builder, string value) - { - AppendQuoted(builder, value, true); - builder.Append("u8"); - } + /// Appends as a C# UTF-8 string literal ("..."u8). + public static void AppendUtf8Literal(StringBuilder builder, string value) + { + AppendQuoted(builder, value, true); + builder.Append("u8"); + } - private static int GuessCapacity(string value) - { - return value is null ? 2 : value.Length + 8; - } + private static int GuessCapacity(string value) + { + return value is null ? 2 : value.Length + 8; + } - private static void AppendQuoted(StringBuilder builder, string value, bool replaceUnpairedSurrogates) - { - if (builder is null) throw new ArgumentNullException(nameof(builder)); + private static void AppendQuoted(StringBuilder builder, string value, bool replaceUnpairedSurrogates) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } - if (value is null) throw new ArgumentNullException(nameof(value)); + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } - builder.Append('"'); - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - AppendHexEscape(builder, 'U', char.ConvertToUtf32(c, value[i + 1]), 8); - i++; - } - else if (char.IsSurrogate(c)) - { - AppendHexEscape(builder, 'u', replaceUnpairedSurrogates ? ReplacementCharacter : c, 4); - } - else - { - AppendCharacter(builder, c); - } - } + builder.Append('"'); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + AppendHexEscape(builder, 'U', char.ConvertToUtf32(c, value[i + 1]), 8); + i++; + } + else if (char.IsSurrogate(c)) + { + AppendHexEscape(builder, 'u', replaceUnpairedSurrogates ? ReplacementCharacter : c, 4); + } + else + { + AppendCharacter(builder, c); + } + } - builder.Append('"'); - } + builder.Append('"'); + } - private static void AppendCharacter(StringBuilder builder, char c) - { - switch (c) - { - case '"': - builder.Append("\\\""); - break; - case '\\': - builder.Append("\\\\"); - break; - case '\0': - builder.Append("\\0"); - break; - case '\a': - builder.Append("\\a"); - break; - case '\b': - builder.Append("\\b"); - break; - case '\f': - builder.Append("\\f"); - break; - case '\n': - builder.Append("\\n"); - break; - case '\r': - builder.Append("\\r"); - break; - case '\t': - builder.Append("\\t"); - break; - case '\v': - builder.Append("\\v"); - break; - default: - if (c is >= ' ' and <= '~') - builder.Append(c); - else - AppendHexEscape(builder, 'u', c, 4); + private static void AppendCharacter(StringBuilder builder, char c) + { + switch (c) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + builder.Append("\\\\"); + break; + case '\0': + builder.Append("\\0"); + break; + case '\a': + builder.Append("\\a"); + break; + case '\b': + builder.Append("\\b"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + case '\v': + builder.Append("\\v"); + break; + default: + if (c is >= ' ' and <= '~') + { + builder.Append(c); + } + else + { + AppendHexEscape(builder, 'u', c, 4); + } - break; - } - } + break; + } + } - private static void AppendHexEscape(StringBuilder builder, char kind, int codePoint, int digits) - { - builder.Append('\\').Append(kind); - for (var shift = (digits - 1) * 4; shift >= 0; shift -= 4) - builder.Append(HexDigits[(codePoint >> shift) & 0xF]); - } + private static void AppendHexEscape(StringBuilder builder, char kind, int codePoint, int digits) + { + builder.Append('\\').Append(kind); + for (int shift = (digits - 1) * 4; shift >= 0; shift -= 4) + { + builder.Append(HexDigits[(codePoint >> shift) & 0xF]); + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/EquatableArray.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/EquatableArray.cs index 8851eb76..0aedcf79 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/EquatableArray.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/EquatableArray.cs @@ -24,114 +24,124 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// /// Element type, compared with . internal readonly struct EquatableArray : IEquatable>, IEnumerable - where T : IEquatable + where T : IEquatable { - /// The empty array. - public static readonly EquatableArray Empty = new(ImmutableArray.Empty); - - // May be 'default' (null underlying array) when the struct itself is default-initialised: every member - // goes through Items, which normalises that case to the empty array. - private readonly ImmutableArray _items; - - /// Wraps without copying. A default array is treated as empty. - public EquatableArray(ImmutableArray items) - { - _items = items; - } - - /// Number of elements. - public int Length => Items.Length; - - /// when the array has no element. - public bool IsEmpty => Items.IsEmpty; - - /// Element at . - public T this[int index] => Items[index]; - - private ImmutableArray Items => _items.IsDefault ? ImmutableArray.Empty : _items; - - public static bool operator ==(EquatableArray left, EquatableArray right) - { - return left.Equals(right); - } - - public static bool operator !=(EquatableArray left, EquatableArray right) - { - return !left.Equals(right); - } - - public static implicit operator EquatableArray(ImmutableArray items) - { - return new EquatableArray(items); - } - - /// The underlying array (never default). No copy. - public ImmutableArray AsImmutableArray() - { - return Items; - } - - /// A span over the elements. No copy. - public ReadOnlySpan AsSpan() - { - return Items.AsSpan(); - } - - /// Allocation-free enumerator picked by . - public ImmutableArray.Enumerator GetEnumerator() - { - return Items.GetEnumerator(); - } - - /// - public bool Equals(EquatableArray other) - { - var left = Items; - var right = other.Items; - - // Same backing array: the usual case when an upstream step handed the previous array back. - if (left == right) return true; - - if (left.Length != right.Length) return false; - - var comparer = EqualityComparer.Default; - for (var i = 0; i < left.Length; i++) - if (!comparer.Equals(left[i], right[i])) - return false; - - return true; - } - - /// - public override bool Equals(object? obj) - { - return obj is EquatableArray other && Equals(other); - } - - /// - public override int GetHashCode() - { - // System.HashCode does not exist on netstandard2.0 and a package reference is not an option for a Roslyn - // component: order-dependent multiplicative combine (FNV offset basis, prime 31). - var items = Items; - var comparer = EqualityComparer.Default; - var hash = unchecked((int)2166136261); - for (var i = 0; i < items.Length; i++) - { - var item = items[i]; - hash = unchecked(hash * 31 + (item is null ? 0 : comparer.GetHashCode(item))); - } - - return hash; - } - - IEnumerator IEnumerable.GetEnumerator() - { - return ((IEnumerable)Items).GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return ((IEnumerable)Items).GetEnumerator(); - } + /// The empty array. + public static readonly EquatableArray Empty = new(ImmutableArray.Empty); + + // May be 'default' (null underlying array) when the struct itself is default-initialised: every member + // goes through Items, which normalises that case to the empty array. + private readonly ImmutableArray _items; + + /// Wraps without copying. A default array is treated as empty. + public EquatableArray(ImmutableArray items) + { + _items = items; + } + + /// Number of elements. + public int Length => Items.Length; + + /// when the array has no element. + public bool IsEmpty => Items.IsEmpty; + + /// Element at . + public T this[int index] => Items[index]; + + private ImmutableArray Items => _items.IsDefault ? ImmutableArray.Empty : _items; + + public static bool operator ==(EquatableArray left, EquatableArray right) + { + return left.Equals(right); + } + + public static bool operator !=(EquatableArray left, EquatableArray right) + { + return !left.Equals(right); + } + + public static implicit operator EquatableArray(ImmutableArray items) + { + return new EquatableArray(items); + } + + /// The underlying array (never default). No copy. + public ImmutableArray AsImmutableArray() + { + return Items; + } + + /// A span over the elements. No copy. + public ReadOnlySpan AsSpan() + { + return Items.AsSpan(); + } + + /// Allocation-free enumerator picked by . + public ImmutableArray.Enumerator GetEnumerator() + { + return Items.GetEnumerator(); + } + + /// + public bool Equals(EquatableArray other) + { + ImmutableArray left = Items; + ImmutableArray right = other.Items; + + // Same backing array: the usual case when an upstream step handed the previous array back. + if (left == right) + { + return true; + } + + if (left.Length != right.Length) + { + return false; + } + + EqualityComparer comparer = EqualityComparer.Default; + for (int i = 0; i < left.Length; i++) + { + if (!comparer.Equals(left[i], right[i])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) + { + return obj is EquatableArray other && Equals(other); + } + + /// + public override int GetHashCode() + { + // System.HashCode does not exist on netstandard2.0 and a package reference is not an option for a Roslyn + // component: order-dependent multiplicative combine (FNV offset basis, prime 31). + ImmutableArray items = Items; + EqualityComparer comparer = EqualityComparer.Default; + int hash = unchecked((int) 2166136261); + for (int i = 0; i < items.Length; i++) + { + T? item = items[i]; + hash = unchecked(hash * 31 + (item is null ? 0 : comparer.GetHashCode(item))); + } + + return hash; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable) Items).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable) Items).GetEnumerator(); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs index d5d0b1c8..a2925b84 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs @@ -31,48 +31,54 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class GeneratedCodeText { - /// First line of every generated file. - public const string AutoGeneratedComment = "// "; + /// First line of every generated file. + public const string AutoGeneratedComment = "// "; - /// Second line of every generated file. - public const string NullableEnable = "#nullable enable"; + /// Second line of every generated file. + public const string NullableEnable = "#nullable enable"; - /// Writes the two header lines followed by a blank line. - public static void WriteFileHeader(SourceWriter writer) - { - if (writer is null) throw new ArgumentNullException(nameof(writer)); + /// Writes the two header lines followed by a blank line. + public static void WriteFileHeader(SourceWriter writer) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } - writer.WriteLine(AutoGeneratedComment); - writer.WriteLine(NullableEnable); - writer.WriteLine(); - } + writer.WriteLine(AutoGeneratedComment); + writer.WriteLine(NullableEnable); + writer.WriteLine(); + } - /// - /// Builds the [global::System.CodeDom.Compiler.GeneratedCode("<assembly>", "<version>")] line - /// for the assembly that contains . Compute it once per generator (static - /// field): it allocates an . - /// - /// - /// The version is the assembly version (MinVer keeps it at major.0.0.0), not the informational - /// version: the text must not change with every commit of the SDK, or every consumer would see its generated - /// files churn on each package update. - /// - public static string CreateGeneratedCodeAttribute(Type generatorType) - { - if (generatorType is null) throw new ArgumentNullException(nameof(generatorType)); + /// + /// Builds the [global::System.CodeDom.Compiler.GeneratedCode("<assembly>", "<version>")] line + /// for the assembly that contains . Compute it once per generator (static + /// field): it allocates an . + /// + /// + /// The version is the assembly version (MinVer keeps it at major.0.0.0), not the informational + /// version: the text must not change with every commit of the SDK, or every consumer would see its generated + /// files churn on each package update. + /// + public static string CreateGeneratedCodeAttribute(Type generatorType) + { + if (generatorType is null) + { + throw new ArgumentNullException(nameof(generatorType)); + } - var assembly = generatorType.Assembly.GetName(); - var version = (assembly.Version ?? new Version(0, 0, 0, 0)).ToString(); - return "[global::System.CodeDom.Compiler.GeneratedCode(" - + CSharpLiteral.ToStringLiteral(assembly.Name ?? string.Empty) - + ", " - + CSharpLiteral.ToStringLiteral(version) - + ")]"; - } + AssemblyName assembly = generatorType.Assembly.GetName(); + string version = (assembly.Version ?? new Version(0, 0, 0, 0)).ToString(); + return "[global::System.CodeDom.Compiler.GeneratedCode(" + + CSharpLiteral.ToStringLiteral(assembly.Name ?? string.Empty) + + ", " + + CSharpLiteral.ToStringLiteral(version) + + ")]"; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs index 572b984e..fc7a6c50 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs @@ -20,128 +20,156 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// Roslyn rejects when sources are added to a generation pass. /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class HintNames { - private const string HexDigits = "0123456789abcdef"; - - /// - /// The hint name for (a dotted, namespace-qualified name as the emitter spells it, - /// without global::) with (for example .LuaFunctions.g.cs). - /// - public static string ForType(string typeName, string suffix) - { - return Build(typeName, suffix); - } - - /// - /// Same as , but always appends the hash, even when every character of - /// passed through unchanged. - /// - /// - /// A caller uses this to disambiguate a type whose plain hint name would collide with another type's in the - /// same generation pass: Roslyn's AdditionalSourcesCollection compares hint names case-insensitively - /// (an from AddSource otherwise), so two types whose dotted names - /// differ only in ASCII case produce the identical result (neither one has a replaced - /// character), which alone cannot tell apart. The hash is computed over the exact, - /// case-sensitive , so it differs between the two. - /// - public static string Disambiguated(string typeName, string suffix) - { - var readable = ForType(typeName, suffix); - StringBuilder builder = new(readable.Length + 9); - builder.Append(readable, 0, readable.Length - suffix.Length); - builder.Append('_'); - AppendHash(builder, typeName); - return builder.Append(suffix).ToString(); - } - - /// Creates the case-insensitive set used to reserve generated-source hint names. - /// - /// Roslyn compares source hint names with ordinal case insensitivity. Callers must use this set for an entire - /// generation pass and pass it to for every source they emit. - /// - public static HashSet CreateUsedNames() - { - return new HashSet(StringComparer.OrdinalIgnoreCase); - } - - /// Reserves and returns a unique, deterministic hint name for one generated source. - /// - /// The readable name is preferred. On a collision, the exact type name's deterministic hash is appended. If - /// that name is already reserved too, ordinal suffixes beginning at two are tried until - /// succeeds. The supplied set must use - /// so the reservation matches Roslyn's rule. - /// The Engine API generator currently needs a source-path identity in addition to a type name. Its allocator - /// remains intentionally separate until that identity rule is reconciled with this shared helper. - /// - public static string AllocateUnique(string typeName, string suffix, HashSet used) - { - if (used is null) throw new ArgumentNullException(nameof(used)); - - if (!StringComparer.OrdinalIgnoreCase.Equals(used.Comparer)) - throw new ArgumentException("Hint names must be reserved with StringComparer.OrdinalIgnoreCase.", - nameof(used)); - - var readable = ForType(typeName, suffix); - if (used.Add(readable)) return readable; - - var hashed = Disambiguated(typeName, suffix); - if (used.Add(hashed)) return hashed; - - for (var ordinal = 2;; ordinal++) - { - var suffixed = AppendOrdinal(hashed, suffix, ordinal); - if (used.Add(suffixed)) return suffixed; - } - } - - private static string Build(string typeName, string suffix) - { - if (typeName is null) throw new ArgumentNullException(nameof(typeName)); - - if (suffix is null) throw new ArgumentNullException(nameof(suffix)); - - StringBuilder builder = new(typeName.Length + suffix.Length + 9); - var replaced = false; - foreach (var c in typeName) - if (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '.' or '_') - { - builder.Append(c); - } - else - { - builder.Append('_'); - replaced = true; - } - - if (replaced) - { - builder.Append('_'); - AppendHash(builder, typeName); - } - - return builder.Append(suffix).ToString(); - } - - private static string AppendOrdinal(string hintName, string suffix, int ordinal) - { - StringBuilder builder = new(hintName.Length + 12); - builder.Append(hintName, 0, hintName.Length - suffix.Length); - builder.Append('_'); - builder.Append(ordinal.ToString(CultureInfo.InvariantCulture)); - return builder.Append(suffix).ToString(); - } - - // FNV-1a over the UTF-16 code units: stable across runtimes (string.GetHashCode is randomised per process). - private static void AppendHash(StringBuilder builder, string value) - { - var hash = 2166136261; - foreach (var c in value) hash = unchecked((hash ^ c) * 16777619); - - for (var shift = 28; shift >= 0; shift -= 4) builder.Append(HexDigits[(int)((hash >> shift) & 0xF)]); - } + private const string HexDigits = "0123456789abcdef"; + + /// + /// The hint name for (a dotted, namespace-qualified name as the emitter spells it, + /// without global::) with (for example .LuaFunctions.g.cs). + /// + public static string ForType(string typeName, string suffix) + { + return Build(typeName, suffix); + } + + /// + /// Same as , but always appends the hash, even when every character of + /// passed through unchanged. + /// + /// + /// A caller uses this to disambiguate a type whose plain hint name would collide with another type's in the + /// same generation pass: Roslyn's AdditionalSourcesCollection compares hint names case-insensitively + /// (an from AddSource otherwise), so two types whose dotted names + /// differ only in ASCII case produce the identical result (neither one has a replaced + /// character), which alone cannot tell apart. The hash is computed over the exact, + /// case-sensitive , so it differs between the two. + /// + public static string Disambiguated(string typeName, string suffix) + { + string readable = ForType(typeName, suffix); + StringBuilder builder = new(readable.Length + 9); + builder.Append(readable, 0, readable.Length - suffix.Length); + builder.Append('_'); + AppendHash(builder, typeName); + return builder.Append(suffix).ToString(); + } + + /// Creates the case-insensitive set used to reserve generated-source hint names. + /// + /// Roslyn compares source hint names with ordinal case insensitivity. Callers must use this set for an entire + /// generation pass and pass it to for every source they emit. + /// + public static HashSet CreateUsedNames() + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + /// Reserves and returns a unique, deterministic hint name for one generated source. + /// + /// The readable name is preferred. On a collision, the exact type name's deterministic hash is appended. If + /// that name is already reserved too, ordinal suffixes beginning at two are tried until + /// succeeds. The supplied set must use + /// so the reservation matches Roslyn's rule. + /// The Engine API generator currently needs a source-path identity in addition to a type name. Its allocator + /// remains intentionally separate until that identity rule is reconciled with this shared helper. + /// + public static string AllocateUnique(string typeName, string suffix, HashSet used) + { + if (used is null) + { + throw new ArgumentNullException(nameof(used)); + } + + if (!StringComparer.OrdinalIgnoreCase.Equals(used.Comparer)) + { + throw new ArgumentException("Hint names must be reserved with StringComparer.OrdinalIgnoreCase.", + nameof(used)); + } + + string readable = ForType(typeName, suffix); + if (used.Add(readable)) + { + return readable; + } + + string hashed = Disambiguated(typeName, suffix); + if (used.Add(hashed)) + { + return hashed; + } + + for (int ordinal = 2;; ordinal++) + { + string suffixed = AppendOrdinal(hashed, suffix, ordinal); + if (used.Add(suffixed)) + { + return suffixed; + } + } + } + + private static string Build(string typeName, string suffix) + { + if (typeName is null) + { + throw new ArgumentNullException(nameof(typeName)); + } + + if (suffix is null) + { + throw new ArgumentNullException(nameof(suffix)); + } + + StringBuilder builder = new(typeName.Length + suffix.Length + 9); + bool replaced = false; + foreach (char c in typeName) + { + if (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '.' or '_') + { + builder.Append(c); + } + else + { + builder.Append('_'); + replaced = true; + } + } + + if (replaced) + { + builder.Append('_'); + AppendHash(builder, typeName); + } + + return builder.Append(suffix).ToString(); + } + + private static string AppendOrdinal(string hintName, string suffix, int ordinal) + { + StringBuilder builder = new(hintName.Length + 12); + builder.Append(hintName, 0, hintName.Length - suffix.Length); + builder.Append('_'); + builder.Append(ordinal.ToString(CultureInfo.InvariantCulture)); + return builder.Append(suffix).ToString(); + } + + // FNV-1a over the UTF-16 code units: stable across runtimes (string.GetHashCode is randomised per process). + private static void AppendHash(StringBuilder builder, string value) + { + uint hash = 2166136261; + foreach (char c in value) + { + hash = unchecked((hash ^ c) * 16777619); + } + + for (int shift = 28; shift >= 0; shift -= 4) + { + builder.Append(HexDigits[(int) ((hash >> shift) & 0xF)]); + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/ContainingTypeIssues.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/ContainingTypeIssues.cs index b1913349..9102fec5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/ContainingTypeIssues.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/ContainingTypeIssues.cs @@ -9,30 +9,30 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; [Flags] internal enum ContainingTypeIssues { - /// The generated part can be added. - None = 0, + /// The generated part can be added. + None = 0, - /// - /// The declaring type, or a type it is nested in, is not : no second part can be - /// declared. - /// - NotPartial = 1 << 0, + /// + /// The declaring type, or a type it is nested in, is not : no second part can be + /// declared. + /// + NotPartial = 1 << 0, - /// - /// The declaring type, or a type it is nested in, has type parameters: the part cannot be named without type - /// arguments, and a per-instantiation cache would be wrong. - /// - Generic = 1 << 1, + /// + /// The declaring type, or a type it is nested in, has type parameters: the part cannot be named without type + /// arguments, and a per-instantiation cache would be wrong. + /// + Generic = 1 << 1, - /// - /// The declaring type, or a type it is nested in, is neither a class, a struct nor a record (interfaces, enums - /// and delegates take no generated members). - /// - NotClassOrStruct = 1 << 2, + /// + /// The declaring type, or a type it is nested in, is neither a class, a struct nor a record (interfaces, enums + /// and delegates take no generated members). + /// + NotClassOrStruct = 1 << 2, - /// - /// The declaring type, or a type it is nested in, is -local: a part in another file cannot - /// reach it. - /// - FileLocal = 1 << 3 + /// + /// The declaring type, or a type it is nested in, is -local: a part in another file cannot + /// reach it. + /// + FileLocal = 1 << 3 } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionShapeIssues.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionShapeIssues.cs index c6ec5882..b92d9917 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionShapeIssues.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionShapeIssues.cs @@ -13,70 +13,70 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; [Flags] internal enum LuaFunctionShapeIssues { - /// The method can be exported. - None = 0, + /// The method can be exported. + None = 0, - /// - /// The method is not an ordinary method (an accessor, an operator, a conversion, a local function, an explicit - /// interface implementation). - /// - NotOrdinaryMethod = 1 << 0, + /// + /// The method is not an ordinary method (an accessor, an operator, a conversion, a local function, an explicit + /// interface implementation). + /// + NotOrdinaryMethod = 1 << 0, - /// The method is an instance method: a thunk wrapping a static method has no receiver to call it on. - NotStatic = 1 << 1, + /// The method is an instance method: a thunk wrapping a static method has no receiver to call it on. + NotStatic = 1 << 1, - /// The method has type parameters. - Generic = 1 << 2, + /// The method has type parameters. + Generic = 1 << 2, - /// - /// The method is . An method returns - /// like any - /// other, so without this flag it would pass every other check: the thunk would call it and return before its - /// continuation runs, and an exception the continuation throws would not be the thunk's to - /// catch - /// (no managed exception may cross the native boundary uncaught). - /// - Async = 1 << 11, + /// + /// The method is . An method returns + /// like any + /// other, so without this flag it would pass every other check: the thunk would call it and return before its + /// continuation runs, and an exception the continuation throws would not be the thunk's to + /// catch + /// (no managed exception may cross the native boundary uncaught). + /// + Async = 1 << 11, - /// The attribute's name argument is missing, not a string, or not a Lua identifier (see LuaNames). - InvalidName = 1 << 3, + /// The attribute's name argument is missing, not a string, or not a Lua identifier (see LuaNames). + InvalidName = 1 << 3, - /// - /// A parameter has a type no marshaller reads: only , , - /// , , - /// , , ReadOnlySpan<byte> and - /// are accepted unless an explicit annotation names a type that implements the matching - /// ILuaMarshaller<T> contract. - /// - UnsupportedParameterType = 1 << 4, + /// + /// A parameter has a type no marshaller reads: only , , + /// , , + /// , , ReadOnlySpan<byte> and + /// are accepted unless an explicit annotation names a type that implements the matching + /// ILuaMarshaller<T> contract. + /// + UnsupportedParameterType = 1 << 4, - /// - /// A parameter is , , or - /// : a Lua argument is a value. - /// - ByRefParameter = 1 << 5, + /// + /// A parameter is , , or + /// : a Lua argument is a value. + /// + ByRefParameter = 1 << 5, - /// A parameter is : variadic exports are not supported. - ParamsParameter = 1 << 6, + /// A parameter is : variadic exports are not supported. + ParamsParameter = 1 << 6, - /// A parameter has a default value: optional Lua arguments are not supported (the thunk checks the exact count). - OptionalParameter = 1 << 7, + /// A parameter has a default value: optional Lua arguments are not supported (the thunk checks the exact count). + OptionalParameter = 1 << 7, - /// A LuaState parameter is not the first parameter: the state is passed first or not at all. - StateParameterNotFirst = 1 << 8, + /// A LuaState parameter is not the first parameter: the state is passed first or not at all. + StateParameterNotFirst = 1 << 8, - /// - /// The return type is neither , a built-in marshalled type nor a type with a valid - /// explicit ILuaMarshaller<T> annotation. - /// - UnsupportedReturnType = 1 << 9, + /// + /// The return type is neither , a built-in marshalled type nor a type with a valid + /// explicit ILuaMarshaller<T> annotation. + /// + UnsupportedReturnType = 1 << 9, - /// - /// Another [LuaFunction] of the same containing type carries the same name: one registration table - /// cannot bind a name twice. Not decided per member by this type's own inspector (LuaFunctionShape.Inspect - /// is given one method at a time): LuaFunctionTables.Group drops every member of a duplicated name from - /// its table, and on the analyzer side LuaBindingAnalyzer's compilation-end pass - /// (LuaFunctionDuplicateState) sets this flag once every sibling member has been seen. - /// - DuplicateName = 1 << 10 + /// + /// Another [LuaFunction] of the same containing type carries the same name: one registration table + /// cannot bind a name twice. Not decided per member by this type's own inspector (LuaFunctionShape.Inspect + /// is given one method at a time): LuaFunctionTables.Group drops every member of a duplicated name from + /// its table, and on the analyzer side LuaBindingAnalyzer's compilation-end pass + /// (LuaFunctionDuplicateState) sets this flag once every sibling member has been seen. + /// + DuplicateName = 1 << 10 } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionSignature.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionSignature.cs index c2d3fc1d..0ed26e87 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionSignature.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaFunctionSignature.cs @@ -8,15 +8,15 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; /// The kind pushed as the result, or for . /// An explicit static marshaller for the result, or . internal readonly record struct LuaFunctionSignature( - bool PassesState, - EquatableArray Arguments, - LuaValueKind? ReturnKind, - LuaCustomMarshallerModel? ReturnMarshaller = null) + bool PassesState, + EquatableArray Arguments, + LuaValueKind? ReturnKind, + LuaCustomMarshallerModel? ReturnMarshaller = null) { - /// Initializes a built-in scalar signature with the pre-custom-marshaller binary shape. - public LuaFunctionSignature(bool passesState, EquatableArray arguments, - LuaValueKind? returnKind) - : this(passesState, arguments, returnKind, null) - { - } + /// Initializes a built-in scalar signature with the pre-custom-marshaller binary shape. + public LuaFunctionSignature(bool passesState, EquatableArray arguments, + LuaValueKind? returnKind) + : this(passesState, arguments, returnKind, null) + { + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs index 389993e5..bf94b876 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs @@ -13,87 +13,87 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; [Flags] internal enum LuaGlobalShapeIssues { - /// The method can be implemented. - None = 0, - - /// - /// The method is not an ordinary method (an accessor, an operator, a local function, an explicit interface - /// implementation). - /// - NotOrdinaryMethod = 1 << 0, - - /// The method is an instance method: the generator binds static members only. - NotStatic = 1 << 1, - - /// - /// The attributed declaration is not the defining declaration of a method (it is not - /// partial, or it is the implementing part). - /// - NotPartialDefinition = 1 << 2, - - /// The partial method already has an implementing declaration: a generated one would be a second body. - AlreadyImplemented = 1 << 3, - - /// The method has type parameters. - Generic = 1 << 4, - - /// The method is . - Async = 1 << 5, - - /// The attribute's name argument is missing, not a string, or not a Lua identifier (see LuaNames). - InvalidName = 1 << 6, - - /// - /// An argument has a type no marshaller pushes: only , , - /// , , - /// , , ReadOnlySpan<byte> and - /// are accepted unless an explicit annotation names a type that implements the matching - /// ILuaMarshaller<T> contract. - /// - UnsupportedParameterType = 1 << 7, - - /// - /// A parameter is , or - /// : arguments are values, results are . - /// - ByRefParameter = 1 << 8, - - /// A parameter is . - ParamsParameter = 1 << 9, - - /// A parameter has a default value: the body pushes every argument, so a default would be meaningless. - OptionalParameter = 1 << 10, - - /// A LuaState parameter is not the first parameter. - StateParameterNotFirst = 1 << 11, - - /// - /// An parameter has a type no marshaller reads (including a named type without a valid - /// ILuaMarshaller<T> annotation), or a Span<byte> destination - /// is not followed by . - /// - UnsupportedResultType = 1 << 12, - - /// - /// A result is a ReadOnlySpan<byte> (an parameter or the return type): it - /// would point into a Lua string the body pops before returning. Use - /// Span<byte> destination, written, or - /// . - /// - SpanResult = 1 << 13, - - /// An argument follows a result: results ( parameters and copy-out pairs) must come last. - ResultBeforeArgument = 1 << 14, - - /// - /// The return type is neither , , a built-in marshalled type nor - /// a type with a valid explicit ILuaMarshaller<T> annotation. - /// - UnsupportedReturnType = 1 << 15, - - /// - /// The method has results but returns neither nor the SDK's - /// LuaOperationStatus: the throwing form has no parameter. - /// - TryFormReturnNotBool = 1 << 16 + /// The method can be implemented. + None = 0, + + /// + /// The method is not an ordinary method (an accessor, an operator, a local function, an explicit interface + /// implementation). + /// + NotOrdinaryMethod = 1 << 0, + + /// The method is an instance method: the generator binds static members only. + NotStatic = 1 << 1, + + /// + /// The attributed declaration is not the defining declaration of a method (it is not + /// partial, or it is the implementing part). + /// + NotPartialDefinition = 1 << 2, + + /// The partial method already has an implementing declaration: a generated one would be a second body. + AlreadyImplemented = 1 << 3, + + /// The method has type parameters. + Generic = 1 << 4, + + /// The method is . + Async = 1 << 5, + + /// The attribute's name argument is missing, not a string, or not a Lua identifier (see LuaNames). + InvalidName = 1 << 6, + + /// + /// An argument has a type no marshaller pushes: only , , + /// , , + /// , , ReadOnlySpan<byte> and + /// are accepted unless an explicit annotation names a type that implements the matching + /// ILuaMarshaller<T> contract. + /// + UnsupportedParameterType = 1 << 7, + + /// + /// A parameter is , or + /// : arguments are values, results are . + /// + ByRefParameter = 1 << 8, + + /// A parameter is . + ParamsParameter = 1 << 9, + + /// A parameter has a default value: the body pushes every argument, so a default would be meaningless. + OptionalParameter = 1 << 10, + + /// A LuaState parameter is not the first parameter. + StateParameterNotFirst = 1 << 11, + + /// + /// An parameter has a type no marshaller reads (including a named type without a valid + /// ILuaMarshaller<T> annotation), or a Span<byte> destination + /// is not followed by . + /// + UnsupportedResultType = 1 << 12, + + /// + /// A result is a ReadOnlySpan<byte> (an parameter or the return type): it + /// would point into a Lua string the body pops before returning. Use + /// Span<byte> destination, written, or + /// . + /// + SpanResult = 1 << 13, + + /// An argument follows a result: results ( parameters and copy-out pairs) must come last. + ResultBeforeArgument = 1 << 14, + + /// + /// The return type is neither , , a built-in marshalled type nor + /// a type with a valid explicit ILuaMarshaller<T> annotation. + /// + UnsupportedReturnType = 1 << 15, + + /// + /// The method has results but returns neither nor the SDK's + /// LuaOperationStatus: the throwing form has no parameter. + /// + TryFormReturnNotBool = 1 << 16 } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalSignature.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalSignature.cs index 2992b599..0ee76b1b 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalSignature.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalSignature.cs @@ -9,20 +9,23 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; /// The Try form's results; empty for the throwing form. /// The throwing form's result kind, or for . /// The throwing form returns string?. -/// An explicit static marshaller for the throwing-form return value, or . +/// +/// An explicit static marshaller for the throwing-form return value, or +/// . +/// internal readonly record struct LuaGlobalSignature( - string StateParameterName, - EquatableArray Arguments, - LuaCallForm Form, - EquatableArray Results, - LuaValueKind? ReturnKind, - bool ReturnIsNullable, - LuaCustomMarshallerModel? ReturnMarshaller = null) + string StateParameterName, + EquatableArray Arguments, + LuaCallForm Form, + EquatableArray Results, + LuaValueKind? ReturnKind, + bool ReturnIsNullable, + LuaCustomMarshallerModel? ReturnMarshaller = null) { - /// Initializes a built-in scalar signature with the pre-custom-marshaller binary shape. - public LuaGlobalSignature(string stateParameterName, EquatableArray arguments, - LuaCallForm form, EquatableArray results, LuaValueKind? returnKind, bool returnIsNullable) - : this(stateParameterName, arguments, form, results, returnKind, returnIsNullable, null) - { - } + /// Initializes a built-in scalar signature with the pre-custom-marshaller binary shape. + public LuaGlobalSignature(string stateParameterName, EquatableArray arguments, + LuaCallForm form, EquatableArray results, LuaValueKind? returnKind, bool returnIsNullable) + : this(stateParameterName, arguments, form, results, returnKind, returnIsNullable, null) + { + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs index 81e9f50a..ef0fb3bf 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; + using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -13,43 +15,58 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// out, no generator types: the CESDK2xxx analyzer links this file. /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class ContainingTypeShape { - /// Inspects and its containing types; never throws on malformed symbols. - public static ContainingTypeIssues Inspect(INamedTypeSymbol type, CancellationToken cancellationToken) - { - var issues = ContainingTypeIssues.None; - for (var current = type; current is not null; current = current.ContainingType) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (current.TypeKind is not (TypeKind.Class or TypeKind.Struct)) - issues |= ContainingTypeIssues.NotClassOrStruct; - - // True for a non-generic type nested in a generic one as well. - if (current.IsGenericType) issues |= ContainingTypeIssues.Generic; - - if (current.IsFileLocal) issues |= ContainingTypeIssues.FileLocal; - - if (!IsPartial(current, cancellationToken)) issues |= ContainingTypeIssues.NotPartial; - } - - return issues; - } - - // A type is partial when a declaration says so; the compiler reports a part without the modifier (CS0260) - // louder than any silence here could. - private static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellationToken) - { - foreach (var reference in type.DeclaringSyntaxReferences) - if (reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.PartialKeyword)) - return true; - - return false; - } + /// Inspects and its containing types; never throws on malformed symbols. + public static ContainingTypeIssues Inspect(INamedTypeSymbol type, CancellationToken cancellationToken) + { + ContainingTypeIssues issues = ContainingTypeIssues.None; + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (current.TypeKind is not (TypeKind.Class or TypeKind.Struct)) + { + issues |= ContainingTypeIssues.NotClassOrStruct; + } + + // True for a non-generic type nested in a generic one as well. + if (current.IsGenericType) + { + issues |= ContainingTypeIssues.Generic; + } + + if (current.IsFileLocal) + { + issues |= ContainingTypeIssues.FileLocal; + } + + if (!IsPartial(current, cancellationToken)) + { + issues |= ContainingTypeIssues.NotPartial; + } + } + + return issues; + } + + // A type is partial when a declaration says so; the compiler reports a part without the modifier (CS0260) + // louder than any silence here could. + private static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellationToken) + { + foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) + { + if (reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax declaration + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return true; + } + } + + return false; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs index 3e6fef77..bd68372a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs @@ -5,15 +5,15 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// Spells a symbol name as an identifier token of generated code. internal static class Identifiers { - /// - /// Prefixes with @ when it is a C# keyword or contextual keyword (@class, - /// @var); a symbol's Name never carries the prefix, a declaration sometimes must. - /// - public static string Escape(string name) - { - return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || - SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None - ? "@" + name - : name; - } + /// + /// Prefixes with @ when it is a C# keyword or contextual keyword (@class, + /// @var); a symbol's Name never carries the prefix, a declaration sometimes must. + /// + public static string Escape(string name) + { + return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || + SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None + ? "@" + name + : name; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs index ab111a1d..9c3397e8 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs @@ -1,4 +1,5 @@ using System; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; @@ -6,31 +7,33 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// Names reserved by the generated borrowed-handle identity surface. internal static class LuaClassGeneratedNames { - /// Whether is emitted as a generated borrowed-handle member. - public static bool IsGeneratedMember(string name) - { - return name is "_handle" or "Handle" or "FromHandle" or "Equals" or "GetHashCode" or "Push" or "TryRead"; - } + /// Whether is emitted as a generated borrowed-handle member. + public static bool IsGeneratedMember(string name) + { + return name is "_handle" or "Handle" or "FromHandle" or "Equals" or "GetHashCode" or "Push" or "TryRead"; + } - /// Whether cannot contain the generated borrowed-handle surface. - public static bool IsGeneratedType(string name) - { - return IsGeneratedMember(name); - } + /// Whether cannot contain the generated borrowed-handle surface. + public static bool IsGeneratedType(string name) + { + return IsGeneratedMember(name); + } - /// Whether an authored member would collide with the generated Handle property. - public static bool IsGeneratedAccessorCollision(ISymbol member, INamedTypeSymbol? ceObject) - { - if (member is not IMethodSymbol method) - return member.Name is "get_Handle" or "set_Handle"; + /// Whether an authored member would collide with the generated Handle property. + public static bool IsGeneratedAccessorCollision(ISymbol member, INamedTypeSymbol? ceObject) + { + if (member is not IMethodSymbol method) + { + return member.Name is "get_Handle" or "set_Handle"; + } - return method.MethodKind == MethodKind.Ordinary - && ((string.Equals(method.Name, "get_Handle", StringComparison.Ordinal) - && method.Parameters.Length == 0) - || (string.Equals(method.Name, "set_Handle", StringComparison.Ordinal) - && ceObject is not null - && method.Parameters.Length == 1 - && method.Parameters[0].RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, ceObject))); - } + return method.MethodKind == MethodKind.Ordinary + && ((string.Equals(method.Name, "get_Handle", StringComparison.Ordinal) + && method.Parameters.Length == 0) + || (string.Equals(method.Name, "set_Handle", StringComparison.Ordinal) + && ceObject is not null + && method.Parameters.Length == 1 + && method.Parameters[0].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, ceObject))); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs index 623f0211..20172c40 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs @@ -1,7 +1,9 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; @@ -23,137 +25,177 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// duplicate names are a group rule (LuaFunctionTables). /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class LuaFunctionShape { - /// Inspects against resolved SDK Lua binding contracts. - /// The consumer compilation that must be able to call a custom marshaller directly. - /// The attributed method. - /// The real Lua runtime state symbol, or when it is unavailable. - /// The real SDK marshaller annotation, or . - /// The real SDK static marshaller contract, or . - /// - /// What could be classified; complete only when the result is - /// . - /// - public static LuaFunctionShapeIssues Inspect(Compilation compilation, IMethodSymbol method, INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, - out LuaFunctionSignature signature) - { - return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, - out signature); - } - - /// - /// Compatibility overload for consumers that only validate the built-in scalar contract. The LuaBindings - /// generator and its analyzer call the overload that resolves [LuaMarshaller] explicitly. - /// - public static LuaFunctionShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, - out LuaFunctionSignature signature) - { - return InspectCore(null, method, luaState, null, null, out signature); - } - - private static LuaFunctionShapeIssues InspectCore(Compilation? compilation, IMethodSymbol method, - INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, out LuaFunctionSignature signature) - { - var issues = LuaFunctionShapeIssues.None; - - if (method.MethodKind != MethodKind.Ordinary) issues |= LuaFunctionShapeIssues.NotOrdinaryMethod; - - if (!method.IsStatic) issues |= LuaFunctionShapeIssues.NotStatic; - - if (method.IsGenericMethod) issues |= LuaFunctionShapeIssues.Generic; - - if (method.IsAsync) issues |= LuaFunctionShapeIssues.Async; - - issues |= InspectParameters(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, - out var passesState, out var arguments); - issues |= InspectReturn(compilation, method, luaMarshallerAttribute, luaMarshallerContract, out var returnKind, - out var returnMarshaller); - - signature = new LuaFunctionSignature(passesState, arguments, returnKind, returnMarshaller); - return issues; - } - - private static LuaFunctionShapeIssues InspectParameters(Compilation? compilation, IMethodSymbol method, - INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, - out bool passesState, - out EquatableArray arguments) - { - var issues = LuaFunctionShapeIssues.None; - passesState = false; - var builder = ImmutableArray.CreateBuilder(method.Parameters.Length); - for (var i = 0; i < method.Parameters.Length; i++) - { - var parameter = method.Parameters[i]; - if (parameter.RefKind != RefKind.None) issues |= LuaFunctionShapeIssues.ByRefParameter; - - if (parameter.IsParams) issues |= LuaFunctionShapeIssues.ParamsParameter; - - if (parameter.IsOptional || parameter.HasExplicitDefaultValue) - issues |= LuaFunctionShapeIssues.OptionalParameter; - - if (LuaValueKindMapper.IsLuaState(parameter.Type, luaState)) - { - if (i == 0) - passesState = true; - else - issues |= LuaFunctionShapeIssues.StateParameterNotFirst; - } - else if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, parameter.Type, - parameter.GetAttributes(), luaMarshallerAttribute, luaMarshallerContract, - out var customMarshaller, out _)) - { - issues |= LuaFunctionShapeIssues.UnsupportedParameterType; - } - else if (customMarshaller is not null) - { - builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), LuaValueKind.Int32, - IsNullable: false, CustomMarshaller: customMarshaller)); - } - else if (LuaValueKindMapper.TryMap(parameter.Type, out var kind, out var isNullable)) - { - builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), kind, isNullable)); - } - else - { - issues |= LuaFunctionShapeIssues.UnsupportedParameterType; - } - } - - arguments = new EquatableArray(builder.ToImmutable()); - return issues; - } - - private static LuaFunctionShapeIssues InspectReturn(Compilation? compilation, IMethodSymbol method, - INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, out LuaValueKind? returnKind, - out LuaCustomMarshallerModel? returnMarshaller) - { - returnKind = null; - returnMarshaller = null; - if (method.ReturnsVoid) return LuaFunctionShapeIssues.None; - - if (method is not { ReturnsByRef: false, ReturnsByRefReadonly: false }) - return LuaFunctionShapeIssues.UnsupportedReturnType; - - if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, method.ReturnType, - method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, - out returnMarshaller, out _) - || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) - return LuaFunctionShapeIssues.UnsupportedReturnType; - - if (returnMarshaller is not null) return LuaFunctionShapeIssues.None; - - if (!LuaValueKindMapper.TryMap(method.ReturnType, out var kind, out _)) - return LuaFunctionShapeIssues.UnsupportedReturnType; - returnKind = kind; - return LuaFunctionShapeIssues.None; - } + /// Inspects against resolved SDK Lua binding contracts. + /// The consumer compilation that must be able to call a custom marshaller directly. + /// The attributed method. + /// The real Lua runtime state symbol, or when it is unavailable. + /// The real SDK marshaller annotation, or . + /// The real SDK static marshaller contract, or . + /// + /// What could be classified; complete only when the result is + /// . + /// + public static LuaFunctionShapeIssues Inspect(Compilation compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, + out LuaFunctionSignature signature) + { + return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, + out signature); + } + + /// + /// Compatibility overload for consumers that only validate the built-in scalar contract. The LuaBindings + /// generator and its analyzer call the overload that resolves [LuaMarshaller] explicitly. + /// + public static LuaFunctionShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, + out LuaFunctionSignature signature) + { + return InspectCore(null, method, luaState, null, null, out signature); + } + + private static LuaFunctionShapeIssues InspectCore(Compilation? compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, out LuaFunctionSignature signature) + { + LuaFunctionShapeIssues issues = LuaFunctionShapeIssues.None; + + if (method.MethodKind != MethodKind.Ordinary) + { + issues |= LuaFunctionShapeIssues.NotOrdinaryMethod; + } + + if (!method.IsStatic) + { + issues |= LuaFunctionShapeIssues.NotStatic; + } + + if (method.IsGenericMethod) + { + issues |= LuaFunctionShapeIssues.Generic; + } + + if (method.IsAsync) + { + issues |= LuaFunctionShapeIssues.Async; + } + + issues |= InspectParameters(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, + out bool passesState, out EquatableArray arguments); + issues |= InspectReturn(compilation, method, luaMarshallerAttribute, luaMarshallerContract, + out LuaValueKind? returnKind, + out LuaCustomMarshallerModel? returnMarshaller); + + signature = new LuaFunctionSignature(passesState, arguments, returnKind, returnMarshaller); + return issues; + } + + private static LuaFunctionShapeIssues InspectParameters(Compilation? compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, + out bool passesState, + out EquatableArray arguments) + { + LuaFunctionShapeIssues issues = LuaFunctionShapeIssues.None; + passesState = false; + ImmutableArray.Builder builder = + ImmutableArray.CreateBuilder(method.Parameters.Length); + for (int i = 0; i < method.Parameters.Length; i++) + { + IParameterSymbol parameter = method.Parameters[i]; + if (parameter.RefKind != RefKind.None) + { + issues |= LuaFunctionShapeIssues.ByRefParameter; + } + + if (parameter.IsParams) + { + issues |= LuaFunctionShapeIssues.ParamsParameter; + } + + if (parameter.IsOptional || parameter.HasExplicitDefaultValue) + { + issues |= LuaFunctionShapeIssues.OptionalParameter; + } + + if (LuaValueKindMapper.IsLuaState(parameter.Type, luaState)) + { + if (i == 0) + { + passesState = true; + } + else + { + issues |= LuaFunctionShapeIssues.StateParameterNotFirst; + } + } + else if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, parameter.Type, + parameter.GetAttributes(), luaMarshallerAttribute, luaMarshallerContract, + out LuaCustomMarshallerModel? customMarshaller, out _)) + { + issues |= LuaFunctionShapeIssues.UnsupportedParameterType; + } + else if (customMarshaller is not null) + { + builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), LuaValueKind.Int32, + false, CustomMarshaller: customMarshaller)); + } + else if (LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool isNullable)) + { + builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), kind, isNullable)); + } + else + { + issues |= LuaFunctionShapeIssues.UnsupportedParameterType; + } + } + + arguments = new EquatableArray(builder.ToImmutable()); + return issues; + } + + private static LuaFunctionShapeIssues InspectReturn(Compilation? compilation, IMethodSymbol method, + INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, out LuaValueKind? returnKind, + out LuaCustomMarshallerModel? returnMarshaller) + { + returnKind = null; + returnMarshaller = null; + if (method.ReturnsVoid) + { + return LuaFunctionShapeIssues.None; + } + + if (method is not { ReturnsByRef: false, ReturnsByRefReadonly: false }) + { + return LuaFunctionShapeIssues.UnsupportedReturnType; + } + + if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, method.ReturnType, + method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, + out returnMarshaller, out _) + || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) + { + return LuaFunctionShapeIssues.UnsupportedReturnType; + } + + if (returnMarshaller is not null) + { + return LuaFunctionShapeIssues.None; + } + + if (!LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, out _)) + { + return LuaFunctionShapeIssues.UnsupportedReturnType; + } + + returnKind = kind; + return LuaFunctionShapeIssues.None; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs index caee9a04..eda3869b 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; @@ -27,247 +29,337 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// has results. Otherwise, any result makes the method a /// non-throwing Try form, which returns . No result makes it the throwing form, whose /// return type is -/// or a value of that conversion contract other than ReadOnlySpan<byte> (a return without +/// or a value of that conversion contract other than ReadOnlySpan<byte> (a +/// return without /// results is therefore a throwing wrapper that reads a Lua boolean). A Try form without a result cannot be /// written. /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class LuaGlobalShape { - /// Inspects against resolved SDK Lua binding contracts. - /// The consumer compilation that must be able to call a custom marshaller directly. - /// The attributed method. - /// The real Lua runtime state symbol, or when it is unavailable. - /// The real SDK marshaller annotation, or . - /// The real SDK static marshaller contract, or . - /// - /// What could be classified; complete only when the result is - /// . - /// - public static LuaGlobalShapeIssues Inspect(Compilation compilation, IMethodSymbol method, INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, - out LuaGlobalSignature signature) - { - return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, - out signature); - } - - private static LuaGlobalShapeIssues InspectCore(Compilation? compilation, IMethodSymbol method, - INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, out LuaGlobalSignature signature) - { - var issues = InspectMethod(method); - - ParameterWalk walk = new(); - issues |= walk.Run(compilation, method.ContainingType, method.Parameters, luaState, luaMarshallerAttribute, - luaMarshallerContract); - - EquatableArray results = new(walk.Results.ToImmutable()); - var form = IsLuaOperationStatus(method.ReturnType) - ? LuaCallForm.Outcome - : results.IsEmpty - ? LuaCallForm.Throwing - : LuaCallForm.Try; - issues |= InspectReturn(compilation, method, form, luaMarshallerAttribute, luaMarshallerContract, - out var returnKind, out var returnIsNullable, out var returnMarshaller); - - signature = new LuaGlobalSignature( - walk.StateParameterName, - new EquatableArray(walk.Arguments.ToImmutable()), - form, - results, - returnKind, - returnIsNullable, - returnMarshaller); - return issues; - } - - /// - /// Compatibility overload for validation that accepts only built-in scalar marshallers. The LuaBindings - /// generator and its analyzer pass the resolved custom-marshaller contracts. - /// - public static LuaGlobalShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, - out LuaGlobalSignature signature) - { - return InspectCore(null, method, luaState, null, null, out signature); - } - - private static LuaGlobalShapeIssues InspectMethod(IMethodSymbol method) - { - var issues = LuaGlobalShapeIssues.None; - if (method.MethodKind != MethodKind.Ordinary) issues |= LuaGlobalShapeIssues.NotOrdinaryMethod; - - if (!method.IsStatic) issues |= LuaGlobalShapeIssues.NotStatic; - - if (!method.IsPartialDefinition) - issues |= LuaGlobalShapeIssues.NotPartialDefinition; - else if (method.PartialImplementationPart is not null) issues |= LuaGlobalShapeIssues.AlreadyImplemented; - - if (method.IsGenericMethod) issues |= LuaGlobalShapeIssues.Generic; - - if (method.IsAsync) issues |= LuaGlobalShapeIssues.Async; - - return issues; - } - - private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMethodSymbol method, - LuaCallForm form, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, - out LuaValueKind? returnKind, out bool returnIsNullable, out LuaCustomMarshallerModel? returnMarshaller) - { - returnKind = null; - returnIsNullable = false; - returnMarshaller = null; - var byRef = method.ReturnsByRef || method.ReturnsByRefReadonly; - - if (form == LuaCallForm.Try) - return method.ReturnType.SpecialType == SpecialType.System_Boolean && !byRef - ? LuaGlobalShapeIssues.None - : LuaGlobalShapeIssues.TryFormReturnNotBool; - - if (form == LuaCallForm.Outcome) - return !byRef ? LuaGlobalShapeIssues.None : LuaGlobalShapeIssues.TryFormReturnNotBool; - - if (method.ReturnsVoid) return LuaGlobalShapeIssues.None; - - if (byRef) return LuaGlobalShapeIssues.UnsupportedReturnType; - - if (LuaValueKindMapper.IsReadOnlySpanOfByte(method.ReturnType)) return LuaGlobalShapeIssues.SpanResult; - - if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, method.ReturnType, - method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, - out returnMarshaller, out _) - || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) - return LuaGlobalShapeIssues.UnsupportedReturnType; - - if (returnMarshaller is not null) return LuaGlobalShapeIssues.None; - - if (!LuaValueKindMapper.TryMap(method.ReturnType, out var kind, out returnIsNullable) || - !LuaValueKinds.CanBeResult(kind)) return LuaGlobalShapeIssues.UnsupportedReturnType; - returnKind = kind; - return LuaGlobalShapeIssues.None; - } - - private static bool IsLuaOperationStatus(ITypeSymbol type) - { - return string.Equals( - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus", - StringComparison.Ordinal); - } - - // The parameter list, left to right: the leading state, the arguments, then the results. - private sealed class ParameterWalk - { - private bool _inResults; - - public string StateParameterName { get; private set; } = string.Empty; - - public ImmutableArray.Builder Arguments { get; } = - ImmutableArray.CreateBuilder(); - - public ImmutableArray.Builder Results { get; } = ImmutableArray.CreateBuilder(); - - public LuaGlobalShapeIssues Run(Compilation? compilation, INamedTypeSymbol bindingType, - ImmutableArray parameters, INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) - { - var issues = LuaGlobalShapeIssues.None; - for (var i = 0; i < parameters.Length; i++) - { - var parameter = parameters[i]; - if (parameter.IsParams) issues |= LuaGlobalShapeIssues.ParamsParameter; - - if (parameter.IsOptional || parameter.HasExplicitDefaultValue) - issues |= LuaGlobalShapeIssues.OptionalParameter; - - if (parameter.RefKind == RefKind.Out) - issues |= AddOutResult(compilation, bindingType, parameter, luaMarshallerAttribute, - luaMarshallerContract); - else if (parameter.RefKind != RefKind.None) - issues |= LuaGlobalShapeIssues.ByRefParameter; - else if (LuaValueKindMapper.IsSpanOfByte(parameter.Type)) - issues |= AddCopyOutResult(parameters, ref i); - else - issues |= AddArgument(compilation, bindingType, parameter, i, luaState, luaMarshallerAttribute, - luaMarshallerContract); - } - - return issues; - } - - private LuaGlobalShapeIssues AddOutResult(Compilation? compilation, INamedTypeSymbol bindingType, - IParameterSymbol parameter, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) - { - _inResults = true; - if (LuaValueKindMapper.IsReadOnlySpanOfByte(parameter.Type)) return LuaGlobalShapeIssues.SpanResult; - - if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), - luaMarshallerAttribute, luaMarshallerContract, out var customMarshaller, out _) - || (customMarshaller is not null && parameter.Type.IsRefLikeType)) - return LuaGlobalShapeIssues.UnsupportedResultType; - if (customMarshaller is not null) - { - Results.Add(LuaResultModel.Custom(customMarshaller, Identifiers.Escape(parameter.Name))); - return LuaGlobalShapeIssues.None; - } - - if (!LuaValueKindMapper.TryMap(parameter.Type, out var kind, out var isNullable) || - !LuaValueKinds.CanBeResult(kind)) return LuaGlobalShapeIssues.UnsupportedResultType; - Results.Add(LuaResultModel.Value(kind, Identifiers.Escape(parameter.Name), isNullable)); - return LuaGlobalShapeIssues.None; - } - - // A copy-out result is the destination and the count together: 'Span destination, out int written'. - private LuaGlobalShapeIssues AddCopyOutResult(ImmutableArray parameters, ref int index) - { - _inResults = true; - if (index + 1 >= parameters.Length - || parameters[index + 1] is - not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } written) - return LuaGlobalShapeIssues.UnsupportedResultType; - var destinationIsScoped = parameters[index].ScopedKind != ScopedKind.None; - Results.Add(LuaResultModel.CopyOut(Identifiers.Escape(parameters[index].Name), - Identifiers.Escape(written.Name), destinationIsScoped)); - index++; - return LuaGlobalShapeIssues.None; - } - - // A by-value parameter: an argument, or the leading state. - private LuaGlobalShapeIssues AddArgument(Compilation? compilation, INamedTypeSymbol bindingType, - IParameterSymbol parameter, int index, INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) - { - var issues = _inResults ? LuaGlobalShapeIssues.ResultBeforeArgument : LuaGlobalShapeIssues.None; - if (LuaValueKindMapper.IsLuaState(parameter.Type, luaState)) - { - if (index != 0) return issues | LuaGlobalShapeIssues.StateParameterNotFirst; - StateParameterName = Identifiers.Escape(parameter.Name); - return issues; - } - - if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), - luaMarshallerAttribute, luaMarshallerContract, out var customMarshaller, out _)) - return issues | LuaGlobalShapeIssues.UnsupportedParameterType; - if (customMarshaller is not null) - { - var customIsScoped = parameter.ScopedKind != ScopedKind.None; - Arguments.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), LuaValueKind.Int32, - IsNullable: false, customIsScoped, CustomMarshaller: customMarshaller)); - return issues; - } - - if (!LuaValueKindMapper.TryMap(parameter.Type, out var kind, out var isNullable)) - return issues | LuaGlobalShapeIssues.UnsupportedParameterType; - var isScoped = parameter.ScopedKind != ScopedKind.None; - Arguments.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), kind, isNullable, isScoped)); - return issues; - } - } + /// Inspects against resolved SDK Lua binding contracts. + /// The consumer compilation that must be able to call a custom marshaller directly. + /// The attributed method. + /// The real Lua runtime state symbol, or when it is unavailable. + /// The real SDK marshaller annotation, or . + /// The real SDK static marshaller contract, or . + /// + /// What could be classified; complete only when the result is + /// . + /// + public static LuaGlobalShapeIssues Inspect(Compilation compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, + out LuaGlobalSignature signature) + { + return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, + out signature); + } + + private static LuaGlobalShapeIssues InspectCore(Compilation? compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, out LuaGlobalSignature signature) + { + LuaGlobalShapeIssues issues = InspectMethod(method); + + ParameterWalk walk = new(); + issues |= walk.Run(compilation, method.ContainingType, method.Parameters, luaState, luaMarshallerAttribute, + luaMarshallerContract); + + EquatableArray results = new(walk.Results.ToImmutable()); + LuaCallForm form = IsLuaOperationStatus(method.ReturnType) + ? LuaCallForm.Outcome + : results.IsEmpty + ? LuaCallForm.Throwing + : LuaCallForm.Try; + issues |= InspectReturn(compilation, method, form, luaMarshallerAttribute, luaMarshallerContract, + out LuaValueKind? returnKind, out bool returnIsNullable, out LuaCustomMarshallerModel? returnMarshaller); + + signature = new LuaGlobalSignature( + walk.StateParameterName, + new EquatableArray(walk.Arguments.ToImmutable()), + form, + results, + returnKind, + returnIsNullable, + returnMarshaller); + return issues; + } + + /// + /// Compatibility overload for validation that accepts only built-in scalar marshallers. The LuaBindings + /// generator and its analyzer pass the resolved custom-marshaller contracts. + /// + public static LuaGlobalShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, + out LuaGlobalSignature signature) + { + return InspectCore(null, method, luaState, null, null, out signature); + } + + private static LuaGlobalShapeIssues InspectMethod(IMethodSymbol method) + { + LuaGlobalShapeIssues issues = LuaGlobalShapeIssues.None; + if (method.MethodKind != MethodKind.Ordinary) + { + issues |= LuaGlobalShapeIssues.NotOrdinaryMethod; + } + + if (!method.IsStatic) + { + issues |= LuaGlobalShapeIssues.NotStatic; + } + + if (!method.IsPartialDefinition) + { + issues |= LuaGlobalShapeIssues.NotPartialDefinition; + } + else if (method.PartialImplementationPart is not null) + { + issues |= LuaGlobalShapeIssues.AlreadyImplemented; + } + + if (method.IsGenericMethod) + { + issues |= LuaGlobalShapeIssues.Generic; + } + + if (method.IsAsync) + { + issues |= LuaGlobalShapeIssues.Async; + } + + return issues; + } + + private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMethodSymbol method, + LuaCallForm form, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, + out LuaValueKind? returnKind, out bool returnIsNullable, out LuaCustomMarshallerModel? returnMarshaller) + { + returnKind = null; + returnIsNullable = false; + returnMarshaller = null; + bool byRef = method.ReturnsByRef || method.ReturnsByRefReadonly; + + if (form == LuaCallForm.Try) + { + return method.ReturnType.SpecialType == SpecialType.System_Boolean && !byRef + ? LuaGlobalShapeIssues.None + : LuaGlobalShapeIssues.TryFormReturnNotBool; + } + + if (form == LuaCallForm.Outcome) + { + return !byRef ? LuaGlobalShapeIssues.None : LuaGlobalShapeIssues.TryFormReturnNotBool; + } + + if (method.ReturnsVoid) + { + return LuaGlobalShapeIssues.None; + } + + if (byRef) + { + return LuaGlobalShapeIssues.UnsupportedReturnType; + } + + if (LuaValueKindMapper.IsReadOnlySpanOfByte(method.ReturnType)) + { + return LuaGlobalShapeIssues.SpanResult; + } + + if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, method.ReturnType, + method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, + out returnMarshaller, out _) + || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) + { + return LuaGlobalShapeIssues.UnsupportedReturnType; + } + + if (returnMarshaller is not null) + { + return LuaGlobalShapeIssues.None; + } + + if (!LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, out returnIsNullable) || + !LuaValueKinds.CanBeResult(kind)) + { + return LuaGlobalShapeIssues.UnsupportedReturnType; + } + + returnKind = kind; + return LuaGlobalShapeIssues.None; + } + + private static bool IsLuaOperationStatus(ITypeSymbol type) + { + return string.Equals( + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + StringComparison.Ordinal); + } + + // The parameter list, left to right: the leading state, the arguments, then the results. + private sealed class ParameterWalk + { + private bool _inResults; + + public string StateParameterName + { + get; + private set; + } = string.Empty; + + public ImmutableArray.Builder Arguments + { + get; + } = + ImmutableArray.CreateBuilder(); + + public ImmutableArray.Builder Results + { + get; + } = ImmutableArray.CreateBuilder(); + + public LuaGlobalShapeIssues Run(Compilation? compilation, INamedTypeSymbol bindingType, + ImmutableArray parameters, INamedTypeSymbol? luaState, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) + { + LuaGlobalShapeIssues issues = LuaGlobalShapeIssues.None; + for (int i = 0; i < parameters.Length; i++) + { + IParameterSymbol parameter = parameters[i]; + if (parameter.IsParams) + { + issues |= LuaGlobalShapeIssues.ParamsParameter; + } + + if (parameter.IsOptional || parameter.HasExplicitDefaultValue) + { + issues |= LuaGlobalShapeIssues.OptionalParameter; + } + + if (parameter.RefKind == RefKind.Out) + { + issues |= AddOutResult(compilation, bindingType, parameter, luaMarshallerAttribute, + luaMarshallerContract); + } + else if (parameter.RefKind != RefKind.None) + { + issues |= LuaGlobalShapeIssues.ByRefParameter; + } + else if (LuaValueKindMapper.IsSpanOfByte(parameter.Type)) + { + issues |= AddCopyOutResult(parameters, ref i); + } + else + { + issues |= AddArgument(compilation, bindingType, parameter, i, luaState, luaMarshallerAttribute, + luaMarshallerContract); + } + } + + return issues; + } + + private LuaGlobalShapeIssues AddOutResult(Compilation? compilation, INamedTypeSymbol bindingType, + IParameterSymbol parameter, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) + { + _inResults = true; + if (LuaValueKindMapper.IsReadOnlySpanOfByte(parameter.Type)) + { + return LuaGlobalShapeIssues.SpanResult; + } + + if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), + luaMarshallerAttribute, luaMarshallerContract, out LuaCustomMarshallerModel? customMarshaller, + out _) + || (customMarshaller is not null && parameter.Type.IsRefLikeType)) + { + return LuaGlobalShapeIssues.UnsupportedResultType; + } + + if (customMarshaller is not null) + { + Results.Add(LuaResultModel.Custom(customMarshaller, Identifiers.Escape(parameter.Name))); + return LuaGlobalShapeIssues.None; + } + + if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool isNullable) || + !LuaValueKinds.CanBeResult(kind)) + { + return LuaGlobalShapeIssues.UnsupportedResultType; + } + + Results.Add(LuaResultModel.Value(kind, Identifiers.Escape(parameter.Name), isNullable)); + return LuaGlobalShapeIssues.None; + } + + // A copy-out result is the destination and the count together: 'Span destination, out int written'. + private LuaGlobalShapeIssues AddCopyOutResult(ImmutableArray parameters, ref int index) + { + _inResults = true; + if (index + 1 >= parameters.Length + || parameters[index + 1] is + not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } written) + { + return LuaGlobalShapeIssues.UnsupportedResultType; + } + + bool destinationIsScoped = parameters[index].ScopedKind != ScopedKind.None; + Results.Add(LuaResultModel.CopyOut(Identifiers.Escape(parameters[index].Name), + Identifiers.Escape(written.Name), destinationIsScoped)); + index++; + return LuaGlobalShapeIssues.None; + } + + // A by-value parameter: an argument, or the leading state. + private LuaGlobalShapeIssues AddArgument(Compilation? compilation, INamedTypeSymbol bindingType, + IParameterSymbol parameter, int index, INamedTypeSymbol? luaState, + INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) + { + LuaGlobalShapeIssues issues = + _inResults ? LuaGlobalShapeIssues.ResultBeforeArgument : LuaGlobalShapeIssues.None; + if (LuaValueKindMapper.IsLuaState(parameter.Type, luaState)) + { + if (index != 0) + { + return issues | LuaGlobalShapeIssues.StateParameterNotFirst; + } + + StateParameterName = Identifiers.Escape(parameter.Name); + return issues; + } + + if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), + luaMarshallerAttribute, luaMarshallerContract, out LuaCustomMarshallerModel? customMarshaller, + out _)) + { + return issues | LuaGlobalShapeIssues.UnsupportedParameterType; + } + + if (customMarshaller is not null) + { + bool customIsScoped = parameter.ScopedKind != ScopedKind.None; + Arguments.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), LuaValueKind.Int32, + false, customIsScoped, CustomMarshaller: customMarshaller)); + return issues; + } + + if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool isNullable)) + { + return issues | LuaGlobalShapeIssues.UnsupportedParameterType; + } + + bool isScoped = parameter.ScopedKind != ScopedKind.None; + Arguments.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), kind, isNullable, isScoped)); + return issues; + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs index 51d7b11a..fe7cd52d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; @@ -13,118 +15,152 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// internal static class LuaMarshallerResolver { - /// - /// Gets the explicit marshaller model for , or reports whether an annotation was - /// present but invalid. A missing annotation is not an error and leaves null. - /// - public static bool TryResolve(Compilation? compilation, INamedTypeSymbol bindingType, ITypeSymbol valueType, - ImmutableArray attributes, - INamedTypeSymbol? marshallerAttribute, INamedTypeSymbol? marshallerContract, - out LuaCustomMarshallerModel? marshaller, out bool hasAttribute) - { - marshaller = null; - hasAttribute = false; - if (marshallerAttribute is null || marshallerContract is null) return true; - - AttributeData? attribute = null; - foreach (var candidate in attributes) - { - if (!SymbolEqualityComparer.Default.Equals(candidate.AttributeClass, marshallerAttribute)) continue; - - attribute = candidate; - break; - } - - if (attribute is null) return true; - hasAttribute = true; - - var arguments = attribute.ConstructorArguments; - if (arguments.Length != 1 || arguments[0] is not { Kind: TypedConstantKind.Type, Value: INamedTypeSymbol type }) - return false; - - if (!Implements(type, marshallerContract, valueType) - || !HasCallableStaticContract(compilation, bindingType, type, marshallerContract, valueType)) - return false; - - var valueTypeName = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var marshallerTypeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - marshaller = new LuaCustomMarshallerModel(valueTypeName, marshallerTypeName, "value", valueType.IsReferenceType); - return true; - } - - private static bool Implements(INamedTypeSymbol candidate, INamedTypeSymbol contract, ITypeSymbol valueType) - { - foreach (var implementation in candidate.AllInterfaces) - { - if (!SymbolEqualityComparer.Default.Equals(implementation.OriginalDefinition, contract) - || implementation.TypeArguments.Length != 1) - continue; - - if (SymbolEqualityComparer.Default.Equals(implementation.TypeArguments[0], valueType)) return true; - } - - return false; - } - - // ILuaMarshaller admits explicit static implementations. They satisfy a generic constraint, but the generated - // source intentionally emits TMarshaller.Push(...) and TMarshaller.TryRead(...), which cannot name an explicit - // interface member. Resolve the closed SDK contract so this remains exact if its LuaState or out-value types evolve. - private static bool HasCallableStaticContract(Compilation? compilation, INamedTypeSymbol bindingType, - INamedTypeSymbol candidate, INamedTypeSymbol contract, ITypeSymbol valueType) - { - if (compilation is not null && !compilation.IsSymbolAccessibleWithin(candidate, bindingType)) return false; - - var closedContract = contract.Construct(valueType); - return HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "Push") - && HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "TryRead"); - } - - private static bool HasCallableStaticMethod(Compilation? compilation, INamedTypeSymbol bindingType, - INamedTypeSymbol candidate, INamedTypeSymbol closedContract, string name) - { - IMethodSymbol? required = null; - foreach (var member in closedContract.GetMembers(name)) - if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) - { - required = method; - break; - } - - if (required is null) return false; - - foreach (var member in candidate.GetMembers(name)) - { - if (member is not IMethodSymbol method - || method.MethodKind != MethodKind.Ordinary - || !method.IsStatic - || method.IsAbstract - || method.Arity != 0 - || (compilation is not null && !compilation.IsSymbolAccessibleWithin(method, bindingType)) - || !HasMatchingSignature(method, required)) - continue; - - return true; - } - - return false; - } - - private static bool HasMatchingSignature(IMethodSymbol candidate, IMethodSymbol required) - { - if (candidate.ReturnsVoid != required.ReturnsVoid - || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, required.ReturnType) - || candidate.Parameters.Length != required.Parameters.Length) - return false; - - for (var i = 0; i < candidate.Parameters.Length; i++) - { - var actual = candidate.Parameters[i]; - var expected = required.Parameters[i]; - if (actual.RefKind != expected.RefKind - || !SymbolEqualityComparer.Default.Equals(actual.Type, expected.Type)) - return false; - } - - return true; - } + /// + /// Gets the explicit marshaller model for , or reports whether an annotation was + /// present but invalid. A missing annotation is not an error and leaves null. + /// + public static bool TryResolve(Compilation? compilation, INamedTypeSymbol bindingType, ITypeSymbol valueType, + ImmutableArray attributes, + INamedTypeSymbol? marshallerAttribute, INamedTypeSymbol? marshallerContract, + out LuaCustomMarshallerModel? marshaller, out bool hasAttribute) + { + marshaller = null; + hasAttribute = false; + if (marshallerAttribute is null || marshallerContract is null) + { + return true; + } + + AttributeData? attribute = null; + foreach (AttributeData candidate in attributes) + { + if (!SymbolEqualityComparer.Default.Equals(candidate.AttributeClass, marshallerAttribute)) + { + continue; + } + + attribute = candidate; + break; + } + + if (attribute is null) + { + return true; + } + + hasAttribute = true; + + ImmutableArray arguments = attribute.ConstructorArguments; + if (arguments.Length != 1 || arguments[0] is not { Kind: TypedConstantKind.Type, Value: INamedTypeSymbol type }) + { + return false; + } + + if (!Implements(type, marshallerContract, valueType) + || !HasCallableStaticContract(compilation, bindingType, type, marshallerContract, valueType)) + { + return false; + } + + string valueTypeName = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + string marshallerTypeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + marshaller = + new LuaCustomMarshallerModel(valueTypeName, marshallerTypeName, "value", valueType.IsReferenceType); + return true; + } + + private static bool Implements(INamedTypeSymbol candidate, INamedTypeSymbol contract, ITypeSymbol valueType) + { + foreach (INamedTypeSymbol implementation in candidate.AllInterfaces) + { + if (!SymbolEqualityComparer.Default.Equals(implementation.OriginalDefinition, contract) + || implementation.TypeArguments.Length != 1) + { + continue; + } + + if (SymbolEqualityComparer.Default.Equals(implementation.TypeArguments[0], valueType)) + { + return true; + } + } + + return false; + } + + // ILuaMarshaller admits explicit static implementations. They satisfy a generic constraint, but the generated + // source intentionally emits TMarshaller.Push(...) and TMarshaller.TryRead(...), which cannot name an explicit + // interface member. Resolve the closed SDK contract so this remains exact if its LuaState or out-value types evolve. + private static bool HasCallableStaticContract(Compilation? compilation, INamedTypeSymbol bindingType, + INamedTypeSymbol candidate, INamedTypeSymbol contract, ITypeSymbol valueType) + { + if (compilation is not null && !compilation.IsSymbolAccessibleWithin(candidate, bindingType)) + { + return false; + } + + INamedTypeSymbol closedContract = contract.Construct(valueType); + return HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "Push") + && HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "TryRead"); + } + + private static bool HasCallableStaticMethod(Compilation? compilation, INamedTypeSymbol bindingType, + INamedTypeSymbol candidate, INamedTypeSymbol closedContract, string name) + { + IMethodSymbol? required = null; + foreach (ISymbol member in closedContract.GetMembers(name)) + { + if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) + { + required = method; + break; + } + } + + if (required is null) + { + return false; + } + + foreach (ISymbol member in candidate.GetMembers(name)) + { + if (member is not IMethodSymbol method + || method.MethodKind != MethodKind.Ordinary + || !method.IsStatic + || method.IsAbstract + || method.Arity != 0 + || (compilation is not null && !compilation.IsSymbolAccessibleWithin(method, bindingType)) + || !HasMatchingSignature(method, required)) + { + continue; + } + + return true; + } + + return false; + } + + private static bool HasMatchingSignature(IMethodSymbol candidate, IMethodSymbol required) + { + if (candidate.ReturnsVoid != required.ReturnsVoid + || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, required.ReturnType) + || candidate.Parameters.Length != required.Parameters.Length) + { + return false; + } + + for (int i = 0; i < candidate.Parameters.Length; i++) + { + IParameterSymbol actual = candidate.Parameters[i]; + IParameterSymbol expected = required.Parameters[i]; + if (actual.RefKind != expected.RefKind + || !SymbolEqualityComparer.Default.Equals(actual.Type, expected.Type)) + { + return false; + } + } + + return true; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs index 7e84aa45..721e5a20 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs @@ -1,5 +1,7 @@ using System; + using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; @@ -18,78 +20,78 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// internal static class LuaValueKindMapper { - /// - /// Classifies . is for - /// string?; a nullable value type (int?) is not a supported kind. - /// - public static bool TryMap(ITypeSymbol type, out LuaValueKind kind, out bool isNullable) - { - isNullable = false; - if (type is null) - { - kind = default; - return false; - } + /// + /// Classifies . is for + /// string?; a nullable value type (int?) is not a supported kind. + /// + public static bool TryMap(ITypeSymbol type, out LuaValueKind kind, out bool isNullable) + { + isNullable = false; + if (type is null) + { + kind = default; + return false; + } - switch (type.SpecialType) - { - case SpecialType.System_Int32: - kind = LuaValueKind.Int32; - return true; - case SpecialType.System_Int64: - kind = LuaValueKind.Int64; - return true; - case SpecialType.System_Single: - kind = LuaValueKind.Single; - return true; - case SpecialType.System_Double: - kind = LuaValueKind.Double; - return true; - case SpecialType.System_Boolean: - kind = LuaValueKind.Boolean; - return true; - case SpecialType.System_UIntPtr: - kind = LuaValueKind.Address; - return true; - case SpecialType.System_String: - kind = LuaValueKind.String; - isNullable = type.NullableAnnotation == NullableAnnotation.Annotated; - return true; - } + switch (type.SpecialType) + { + case SpecialType.System_Int32: + kind = LuaValueKind.Int32; + return true; + case SpecialType.System_Int64: + kind = LuaValueKind.Int64; + return true; + case SpecialType.System_Single: + kind = LuaValueKind.Single; + return true; + case SpecialType.System_Double: + kind = LuaValueKind.Double; + return true; + case SpecialType.System_Boolean: + kind = LuaValueKind.Boolean; + return true; + case SpecialType.System_UIntPtr: + kind = LuaValueKind.Address; + return true; + case SpecialType.System_String: + kind = LuaValueKind.String; + isNullable = type.NullableAnnotation == NullableAnnotation.Annotated; + return true; + } - if (IsReadOnlySpanOfByte(type)) - { - kind = LuaValueKind.Utf8; - return true; - } + if (IsReadOnlySpanOfByte(type)) + { + kind = LuaValueKind.Utf8; + return true; + } - kind = default; - return false; - } + kind = default; + return false; + } - /// Whether is the resolved SDK LuaState symbol. - public static bool IsLuaState(ITypeSymbol type, INamedTypeSymbol? expectedLuaState) - { - return expectedLuaState is not null && SymbolEqualityComparer.Default.Equals(type, expectedLuaState); - } + /// Whether is the resolved SDK LuaState symbol. + public static bool IsLuaState(ITypeSymbol type, INamedTypeSymbol? expectedLuaState) + { + return expectedLuaState is not null && SymbolEqualityComparer.Default.Equals(type, expectedLuaState); + } - /// Whether is System.ReadOnlySpan<byte>. - public static bool IsReadOnlySpanOfByte(ITypeSymbol type) - { - return IsSystemSpanOfByte(type, "ReadOnlySpan"); - } + /// Whether is System.ReadOnlySpan<byte>. + public static bool IsReadOnlySpanOfByte(ITypeSymbol type) + { + return IsSystemSpanOfByte(type, "ReadOnlySpan"); + } - /// Whether is System.Span<byte>. - public static bool IsSpanOfByte(ITypeSymbol type) - { - return IsSystemSpanOfByte(type, "Span"); - } + /// Whether is System.Span<byte>. + public static bool IsSpanOfByte(ITypeSymbol type) + { + return IsSystemSpanOfByte(type, "Span"); + } - private static bool IsSystemSpanOfByte(ITypeSymbol type, string name) - { - return type is INamedTypeSymbol { Arity: 1, ContainingType: null } named - && string.Equals(named.Name, name, StringComparison.Ordinal) - && named.TypeArguments[0].SpecialType == SpecialType.System_Byte - && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; - } + private static bool IsSystemSpanOfByte(ITypeSymbol type, string name) + { + return type is INamedTypeSymbol { Arity: 1, ContainingType: null } named + && string.Equals(named.Name, name, StringComparison.Ordinal) + && named.TypeArguments[0].SpecialType == SpecialType.System_Byte + && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs index d5251497..f7f1efe7 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs @@ -12,98 +12,98 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// internal static class LuaApiNames { - /// The state view every operation starts from. - public const string LuaState = "global::CheatEngine.SDK.Lua.State.LuaState"; + /// The state view every operation starts from. + public const string LuaState = "global::CheatEngine.SDK.Lua.State.LuaState"; - /// The protected-call status. - public const string LuaStatus = "global::CheatEngine.SDK.Lua.Calls.LuaStatus"; + /// The protected-call status. + public const string LuaStatus = "global::CheatEngine.SDK.Lua.Calls.LuaStatus"; - /// The compact outcome returned by an opt-in detailed generated global binding. - public const string LuaOperationStatus = "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus"; + /// The compact outcome returned by an opt-in detailed generated global binding. + public const string LuaOperationStatus = "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus"; - /// The cached registry reference a wrapper class holds per bound global. - public const string LuaRef = "global::CheatEngine.SDK.Lua.References.LuaRef"; + /// The cached registry reference a wrapper class holds per bound global. + public const string LuaRef = "global::CheatEngine.SDK.Lua.References.LuaRef"; - /// The exception a throwing wrapper raises. - public const string LuaException = "global::CheatEngine.SDK.Lua.Calls.LuaException"; + /// The exception a throwing wrapper raises. + public const string LuaException = "global::CheatEngine.SDK.Lua.Calls.LuaException"; - /// The address of a managed lua_CFunction. - public const string LuaNativeFunction = "global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction"; + /// The address of a managed lua_CFunction. + public const string LuaNativeFunction = "global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction"; - /// The SDK-owned result of a lease-producing Lua registration transaction. - public const string LuaRegistrationResult = "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult"; + /// The SDK-owned result of a lease-producing Lua registration transaction. + public const string LuaRegistrationResult = "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult"; - /// The collision policy passed explicitly to the SDK registration transaction. - public const string LuaRegistrationCollisionPolicy = - "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy"; + /// The collision policy passed explicitly to the SDK registration transaction. + public const string LuaRegistrationCollisionPolicy = + "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy"; - /// One generated global-name/native-thunk registration descriptor. - public const string LuaRegistrationEntry = "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry"; + /// One generated global-name/native-thunk registration descriptor. + public const string LuaRegistrationEntry = "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry"; - /// The SDK-owned registration-set transaction. - public const string LuaRegistrationSet = "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationSet"; + /// The SDK-owned registration-set transaction. + public const string LuaRegistrationSet = "global::CheatEngine.SDK.Lua.Registration.LuaRegistrationSet"; - /// What a thunk calls to report failures. - public const string LuaThunk = "global::CheatEngine.SDK.Lua.Callbacks.LuaThunk"; + /// What a thunk calls to report failures. + public const string LuaThunk = "global::CheatEngine.SDK.Lua.Callbacks.LuaThunk"; - /// The stack-bound lifecycle lease generated calls hold through their final stack restoration. - public const string LuaRuntimeOperation = "global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation"; + /// The stack-bound lifecycle lease generated calls hold through their final stack restoration. + public const string LuaRuntimeOperation = "global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation"; - /// AcquireOperation(): one provider call per generated call. - public const string AcquireOperation = "global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation()"; + /// AcquireOperation(): one provider call per generated call. + public const string AcquireOperation = "global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation()"; - /// The runtime owner of a state-supplied generated call. - public const string LuaRuntime = "global::CheatEngine.SDK.Lua.Runtime.LuaRuntime"; + /// The runtime owner of a state-supplied generated call. + public const string LuaRuntime = "global::CheatEngine.SDK.Lua.Runtime.LuaRuntime"; - /// Generator-facing push of a cached global function. - public const string LuaGlobalFunctions = "global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions"; + /// Generator-facing push of a cached global function. + public const string LuaGlobalFunctions = "global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions"; - /// Generator-facing cold exits of a call body. - public const string LuaCallSupport = "global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport"; + /// Generator-facing cold exits of a call body. + public const string LuaCallSupport = "global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport"; - /// The untyped, borrowed Cheat Engine object handle used by generated class wrappers. - public const string CEObject = "global::CheatEngine.SDK.Engine.Objects.CEObject"; + /// The untyped, borrowed Cheat Engine object handle used by generated class wrappers. + public const string CEObject = "global::CheatEngine.SDK.Engine.Objects.CEObject"; - /// The static-abstract borrowed-handle contract implemented by generated class wrappers. - public const string ICEObject = "global::CheatEngine.SDK.Engine.Objects.ICEObject"; + /// The static-abstract borrowed-handle contract implemented by generated class wrappers. + public const string ICEObject = "global::CheatEngine.SDK.Engine.Objects.ICEObject"; - /// The static Lua marshaller contract implemented by generated class wrappers. - public const string ILuaMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller"; + /// The static Lua marshaller contract implemented by generated class wrappers. + public const string ILuaMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller"; - /// The attribute every thunk carries, with its cdecl convention. - public const string UnmanagedCallersOnlyCdecl = - "[global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })]"; + /// The attribute every thunk carries, with its cdecl convention. + public const string UnmanagedCallersOnlyCdecl = + "[global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })]"; - /// The one exception type a thunk catches. - public const string Exception = "global::System.Exception"; + /// The one exception type a thunk catches. + public const string Exception = "global::System.Exception"; - /// UTF-8 bytes, the primary string type. - public const string ReadOnlySpanOfByte = "global::System.ReadOnlySpan"; + /// UTF-8 bytes, the primary string type. + public const string ReadOnlySpanOfByte = "global::System.ReadOnlySpan"; - /// The destination of a copy-out string result. - public const string SpanOfByte = "global::System.Span"; + /// The destination of a copy-out string result. + public const string SpanOfByte = "global::System.Span"; - /// Int32Marshaller. - public const string Int32Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller"; + /// Int32Marshaller. + public const string Int32Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller"; - /// Int64Marshaller. - public const string Int64Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller"; + /// Int64Marshaller. + public const string Int64Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller"; - /// SingleMarshaller. - public const string SingleMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.SingleMarshaller"; + /// SingleMarshaller. + public const string SingleMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.SingleMarshaller"; - /// DoubleMarshaller. - public const string DoubleMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller"; + /// DoubleMarshaller. + public const string DoubleMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller"; - /// BooleanMarshaller. - public const string BooleanMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller"; + /// BooleanMarshaller. + public const string BooleanMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller"; - /// AddressMarshaller. - public const string AddressMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller"; + /// AddressMarshaller. + public const string AddressMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller"; - /// Utf8Marshaller. - public const string Utf8Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller"; + /// Utf8Marshaller. + public const string Utf8Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller"; - /// StringMarshaller. - public const string StringMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller"; + /// StringMarshaller. + public const string StringMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller"; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs index 708930da..b4f439c6 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs @@ -31,28 +31,30 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// scalar marshallers represented by . /// internal sealed record LuaArgumentModel( - string Name, - LuaValueKind Kind, - bool IsNullable, - bool IsScoped = false, - string? FixedValue = null, - LuaCustomMarshallerModel? CustomMarshaller = null) + string Name, + LuaValueKind Kind, + bool IsNullable, + bool IsScoped = false, + string? FixedValue = null, + LuaCustomMarshallerModel? CustomMarshaller = null) { - /// Initializes a built-in scalar argument model with the pre-custom-marshaller binary shape. - public LuaArgumentModel(string name, LuaValueKind kind, bool isNullable, bool isScoped, string? fixedValue) - : this(name, kind, isNullable, isScoped, fixedValue, null) - { - } + /// Initializes a built-in scalar argument model with the pre-custom-marshaller binary shape. + public LuaArgumentModel(string name, LuaValueKind kind, bool isNullable, bool isScoped, string? fixedValue) + : this(name, kind, isNullable, isScoped, fixedValue, null) + { + } - /// Whether this value is pushed directly instead of being supplied by a wrapper parameter. - public bool IsFixed => FixedValue is not null; + /// Whether this value is pushed directly instead of being supplied by a wrapper parameter. + public bool IsFixed => FixedValue is not null; - /// The concrete marshaller that emitted code calls directly. - public string GeneratedMarshallerTypeName => CustomMarshaller?.MarshallerTypeName ?? LuaValueKinds.MarshallerTypeName(Kind); + /// The concrete marshaller that emitted code calls directly. + public string GeneratedMarshallerTypeName => + CustomMarshaller?.MarshallerTypeName ?? LuaValueKinds.MarshallerTypeName(Kind); - /// The C# type spelling used in an emitted parameter or local. - public string GeneratedTypeName => CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); + /// The C# type spelling used in an emitted parameter or local. + public string GeneratedTypeName => CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); - /// The Lua-facing expected type in a generated bad-argument message. - public string ExpectedArgumentTypeName => CustomMarshaller?.ExpectedTypeName ?? LuaValueKinds.ExpectedArgument(Kind); + /// The Lua-facing expected type in a generated bad-argument message. + public string ExpectedArgumentTypeName => + CustomMarshaller?.ExpectedTypeName ?? LuaValueKinds.ExpectedArgument(Kind); } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCallForm.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCallForm.cs index 5b6decf6..98f09880 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCallForm.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCallForm.cs @@ -3,25 +3,25 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// The bodies a bound global can get. internal enum LuaCallForm { - /// - /// return, results as parameters: every failure (unresolved global, - /// raised call, result - /// of the wrong kind or nil) is with the results defaulted, through - /// LuaCallSupport.Fail. Never throws for a Lua-side reason. - /// - Try, + /// + /// return, results as parameters: every failure (unresolved global, + /// raised call, result + /// of the wrong kind or nil) is with the results defaulted, through + /// LuaCallSupport.Fail. Never throws for a Lua-side reason. + /// + Try, - /// - /// LuaOperationStatus return, with results as parameters. It preserves the - /// factual resolution, protected-call and result-shape cause without allocating error text. This is opt-in; - /// existing declarations retain their exact contract. - /// - Outcome, + /// + /// LuaOperationStatus return, with results as parameters. It preserves the + /// factual resolution, protected-call and result-shape cause without allocating error text. This is opt-in; + /// existing declarations retain their exact contract. + /// + Outcome, - /// - /// The result is the return value (or the method is ): every failure is a LuaException - /// from - /// the [DoesNotReturn] helpers of LuaCallSupport, which restore the stack first. - /// - Throwing + /// + /// The result is the return value (or the method is ): every failure is a LuaException + /// from + /// the [DoesNotReturn] helpers of LuaCallSupport, which restore the stack first. + /// + Throwing } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCustomMarshallerModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCustomMarshallerModel.cs index d85602e5..f8903362 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCustomMarshallerModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaCustomMarshallerModel.cs @@ -10,7 +10,7 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// A compact Lua-facing type name for a generated failure message. /// Whether defaulting the value requires the null-forgiving operator. internal sealed record LuaCustomMarshallerModel( - string ValueTypeName, - string MarshallerTypeName, - string ExpectedTypeName, - bool IsReferenceType); + string ValueTypeName, + string MarshallerTypeName, + string ExpectedTypeName, + bool IsReferenceType); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs index 687dc0a0..70f2df48 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs @@ -36,640 +36,689 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class LuaGlobalCallEmitter { - /// - /// Bodies that push or keep more values than this call TryEnsureStack first: a C function is - /// guaranteed LUA_MINSTACK = 20 free slots (Lua 5.3 manual, section 4.2) and the protected helpers - /// need up to four. - /// - public const int StackCheckThreshold = 16; - - private const string State = "__L"; - private const string Operation = "__operation"; - private const string Top = "__top"; - private const string Ok = "__ok"; - private const string Status = "__status"; - private const string Result = "__result"; - - /// Writes the method, signature and body, at the writer's current indentation. - public static void Emit(SourceWriter writer, LuaGlobalCallModel model) - { - if (writer is null) throw new ArgumentNullException(nameof(writer)); - - if (model is null) throw new ArgumentNullException(nameof(model)); - - WriteSignature(writer, model); - writer.OpenBlock(); - WriteBody(writer, model); - writer.CloseBlock(); - } - - /// Writes the parameter list, parentheses included: the state, the arguments, then the results. - public static void WriteParameterList(SourceWriter writer, LuaGlobalCallModel model) - { - if (writer is null) throw new ArgumentNullException(nameof(writer)); - - if (model is null) throw new ArgumentNullException(nameof(model)); - - writer.Write('('); - var first = true; - var isExtensionReceiver = model.IsExtensionMethod; - if (model.TakesState) - { - if (isExtensionReceiver) writer.Write("this "); - - writer.Write(LuaApiNames.LuaState); - writer.Write(' '); - writer.Write(model.StateParameterName); - first = false; - isExtensionReceiver = false; - } - - for (var i = 0; i < model.Arguments.Length; i++) - { - var argument = model.Arguments[i]; - if (argument.IsFixed) continue; - - WriteSeparator(writer, ref first); - WriteArgumentParameter(writer, argument, isExtensionReceiver); - isExtensionReceiver = false; - } - - if (model.IsTryLike) - foreach (var result in model.Results) - { - WriteSeparator(writer, ref first); - WriteResultParameter(writer, result, isExtensionReceiver); - isExtensionReceiver = false; - } - - writer.Write(')'); - } - - // 'scoped name': the scoped modifier is written first when the declaration used it (Utf8 is the only - // argument kind of ref struct type; every other kind's IsScoped is always false, see LuaArgumentModel). - private static void WriteArgumentParameter(SourceWriter writer, LuaArgumentModel argument, bool isExtensionReceiver) - { - if (isExtensionReceiver) writer.Write("this "); - - if (argument.IsScoped) writer.Write("scoped "); - - writer.Write(argument.GeneratedTypeName); - writer.Write(' '); - writer.Write(argument.Name); - } - - // 'scoped Span destination, out int written' or 'out name'. - private static void WriteResultParameter(SourceWriter writer, LuaResultModel result, bool isExtensionReceiver) - { - if (result.Shape == LuaResultShape.CopyOut) - { - if (isExtensionReceiver) writer.Write("this "); - - if (result.DestinationIsScoped) writer.Write("scoped "); - - writer.Write(LuaApiNames.SpanOfByte); - writer.Write(' '); - writer.Write(result.DestinationName); - writer.Write(", out int "); - } - else - { - writer.Write("out "); - writer.Write(result.GeneratedTypeName); - writer.Write(' '); - } - - writer.Write(result.Name); - } - - /// - /// The return type as written in the signature: for the Try form, the result type or - /// - /// otherwise. - /// - public static string ReturnTypeName(LuaGlobalCallModel model) - { - if (model is null) throw new ArgumentNullException(nameof(model)); - - return model.Form == LuaCallForm.Try - ? "bool" - : model.Form == LuaCallForm.Outcome - ? LuaApiNames.LuaOperationStatus - : model.HasReturn - ? model.ReturnTypeName - : "void"; - } - - private static void WriteSignature(SourceWriter writer, LuaGlobalCallModel model) - { - if (model.Modifiers.Length > 0) - { - writer.Write(model.Modifiers); - writer.Write(' '); - } - - writer.Write(ReturnTypeName(model)); - writer.Write(' '); - writer.Write(model.MethodName); - WriteParameterList(writer, model); - writer.WriteLine(); - } - - private static void WriteBody(SourceWriter writer, LuaGlobalCallModel model) - { - var argumentCount = model.Arguments.Length; - var resultCount = model.ResultCount; - - WriteStateAndTop(writer, model); - WriteProtectedBody(writer, model, argumentCount, resultCount); - WriteExceptionHandler(writer, model); - WriteStackRestore(writer); - } - - private static void WriteProtectedBody(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, - int resultCount) - { - writer.WriteLine("try"); - writer.OpenBlock(); - - // Only bodies that would exceed the guaranteed free slots check the stack. - var slots = Math.Max(1 + argumentCount, resultCount); - if (slots > StackCheckThreshold) WriteStackCheck(writer, model, slots); - - WriteGlobalPush(writer, model); - WriteArguments(writer, model); - WriteCallAndResults(writer, model, argumentCount, resultCount); - writer.CloseBlock(); - } - - private static void WriteArguments(SourceWriter writer, LuaGlobalCallModel model) - { - foreach (var argument in model.Arguments) - { - writer.Write(argument.GeneratedMarshallerTypeName); - writer.Write(".Push("); - writer.Write(State); - writer.Write(", "); - writer.Write(argument.FixedValue ?? argument.Name); - writer.WriteLine(");"); - } - } - - private static void WriteCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, - int resultCount) - { - if (model.Form == LuaCallForm.Try) - { - WriteTryCallAndResults(writer, model, argumentCount, resultCount); - } - else if (model.Form == LuaCallForm.Outcome) - { - WriteOutcomeCallAndResults(writer, model, argumentCount, resultCount); - } - else - { - WriteThrowingCall(writer, argumentCount, resultCount); - WriteThrowingResult(writer, model); - } - } - - private static void WriteExceptionHandler(SourceWriter writer, LuaGlobalCallModel model) - { - if (model.Form == LuaCallForm.Try) - { - writer.Write("catch ("); - writer.Write(LuaApiNames.LuaException); - writer.WriteLine(")"); - writer.OpenBlock(); - WriteTryExceptionFailure(writer, model); - writer.CloseBlock(); - } - else if (model.Form == LuaCallForm.Outcome) - { - writer.Write("catch ("); - writer.Write(LuaApiNames.LuaException); - writer.Write(' '); - writer.Write("__exception"); - writer.WriteLine(")"); - writer.OpenBlock(); - WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".LuaFailure(__exception.Status)", 0); - writer.CloseBlock(); - } - } - - private static void WriteStackRestore(SourceWriter writer) - { - writer.WriteLine("finally"); - writer.OpenBlock(); - writer.Write(State); - writer.Write(".SetTop("); - writer.Write(Top); - writer.WriteLine(");"); - writer.CloseBlock(); - } - - private static void WriteStateAndTop(SourceWriter writer, LuaGlobalCallModel model) - { - writer.Write("using "); - writer.Write(LuaApiNames.LuaRuntimeOperation); - writer.Write(' '); - writer.Write(Operation); - writer.Write(" = "); - if (model.TakesState) - { - writer.Write(LuaApiNames.LuaRuntime); - writer.Write(".AcquireOperation("); - writer.Write(model.StateParameterName); - writer.Write(')'); - } - else - { - writer.Write(LuaApiNames.AcquireOperation); - } - - writer.WriteLine(";"); - writer.Write(LuaApiNames.LuaState); - writer.Write(' '); - writer.Write(State); - writer.Write(" = "); - writer.Write(Operation); - writer.Write(".State"); - writer.WriteLine(";"); - writer.Write("int "); - writer.Write(Top); - writer.Write(" = "); - writer.Write(State); - writer.WriteLine(".Top;"); - } - - private static void WriteStackCheck(SourceWriter writer, LuaGlobalCallModel model, int slots) - { - writer.Write("if (!"); - writer.Write(State); - writer.Write(".TryEnsureStack("); - writer.Write(slots.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine("))"); - writer.OpenBlock(); - WriteStackExit(writer, model, slots); - writer.CloseBlock(); - writer.WriteLine(); - } - - // Exit 1: the global could not be resolved. - private static void WriteGlobalPush(SourceWriter writer, LuaGlobalCallModel model) - { - if (model.Form == LuaCallForm.Outcome) - { - writer.Write("var __resolution = "); - writer.Write(LuaApiNames.LuaGlobalFunctions); - writer.Write(".TryPushWithOutcome("); - writer.Write(State); - writer.Write(", "); - writer.Write(model.CacheFieldReference); - writer.Write(", "); - writer.Write(CSharpLiteral.ToUtf8Literal(model.GlobalName)); - writer.WriteLine(");"); - writer.Write("if (!__resolution.IsSuccess)"); - writer.OpenBlock(); - WriteOutcomeFailure(writer, model, "__resolution.ToOperationStatus()", 0); - writer.CloseBlock(); - writer.WriteLine(); - return; - } - - writer.Write("if (!"); - writer.Write(LuaApiNames.LuaGlobalFunctions); - writer.Write(".TryPush("); - writer.Write(State); - writer.Write(", "); - writer.Write(model.CacheFieldReference); - writer.Write(", "); - writer.Write(CSharpLiteral.ToUtf8Literal(model.GlobalName)); - writer.WriteLine("))"); - writer.OpenBlock(); - if (model.Form == LuaCallForm.Try) - { - WriteTryFailure(writer, model, 0); - } - else - { - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".ThrowUnresolvedGlobal("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", "); - writer.Write(CSharpLiteral.ToStringLiteral(model.GlobalName)); - writer.WriteLine(");"); - } - - writer.CloseBlock(); - writer.WriteLine(); - } - - private static void WriteTryCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, - int resultCount) - { - // Exit 2: the call raised. - writer.Write("if (!"); - writer.Write(State); - writer.Write(".TryCall("); - writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); - writer.Write(", "); - writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(").IsOk)"); - writer.OpenBlock(); - WriteTryFailure(writer, model, 0); - writer.CloseBlock(); - writer.WriteLine(); - - if (resultCount == 1) - { - // Exit 3 folded into the return value: one read, restore, return. - writer.Write("bool "); - writer.Write(Ok); - writer.Write(" = "); - WriteResultRead(writer, model.Results[0], -1); - writer.WriteLine(";"); - } - else - { - // Exit 3 per result: each read that fails defaults the other results and takes the cold exit. - for (var i = 0; i < resultCount; i++) - { - writer.Write("if (!"); - WriteResultRead(writer, model.Results[i], i - resultCount); - writer.WriteLine(")"); - writer.OpenBlock(); - WriteTryFailure(writer, model, i); - writer.CloseBlock(); - writer.WriteLine(); - } - } - - writer.Write("return "); - writer.Write(resultCount == 1 ? Ok : "true"); - writer.WriteLine(";"); - } - - private static void WriteOutcomeCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, - int resultCount) - { - WriteOutcomeCall(writer, model, argumentCount, resultCount); - WriteOutcomeResults(writer, model, resultCount); - writer.Write("return "); - writer.Write(LuaApiNames.LuaOperationStatus); - writer.WriteLine(".Success;"); - } - - private static void WriteOutcomeCall(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, - int resultCount) - { - writer.Write(LuaApiNames.LuaStatus); - writer.Write(' '); - writer.Write(Status); - writer.Write(" = "); - writer.Write(State); - writer.Write(".TryCall("); - writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); - writer.Write(", "); - writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(");"); - writer.Write("if (!"); - writer.Write(Status); - writer.WriteLine(".IsOk)"); - writer.OpenBlock(); - WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".LuaFailure(" + Status + ")", 0); - writer.CloseBlock(); - writer.WriteLine(); - } - - private static void WriteOutcomeResults(SourceWriter writer, LuaGlobalCallModel model, int resultCount) - { - for (var i = 0; i < resultCount; i++) - { - WriteOutcomeResult(writer, model, i, i - resultCount); - } - } - - private static void WriteOutcomeResult(SourceWriter writer, LuaGlobalCallModel model, int resultIndex, - int stackIndex) - { - writer.Write("if (!"); - WriteResultRead(writer, model.Results[resultIndex], stackIndex); - writer.WriteLine(")"); - writer.OpenBlock(); - writer.Write("return "); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", "); - writer.Write(State); - writer.Write(".IsNil("); - writer.Write(stackIndex.ToString(CultureInfo.InvariantCulture)); - writer.Write(") ? "); - writer.Write(LuaApiNames.LuaOperationStatus); - writer.Write(".NilResult : "); - writer.Write(LuaApiNames.LuaOperationStatus); - writer.Write(".InvalidResult, out "); - writer.Write(model.Results[resultIndex].Name); - writer.WriteLine(");"); - writer.CloseBlock(); - writer.WriteLine(); - } - - // Exit 2: the call raised; the status travels to the throw helper, which reads the error value. - private static void WriteThrowingCall(SourceWriter writer, int argumentCount, int resultCount) - { - writer.Write(LuaApiNames.LuaStatus); - writer.Write(' '); - writer.Write(Status); - writer.Write(" = "); - writer.Write(State); - writer.Write(".TryCall("); - writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); - writer.Write(", "); - writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(");"); - writer.Write("if (!"); - writer.Write(Status); - writer.WriteLine(".IsOk)"); - writer.OpenBlock(); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Throw("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", "); - writer.Write(Status); - writer.WriteLine(");"); - writer.CloseBlock(); - } - - // Exit 3: the result is nil or of another kind; the helper names the Lua type it found. - private static void WriteThrowingResult(SourceWriter writer, LuaGlobalCallModel model) - { - if (!model.HasReturn) - // A void call keeps no result: the successful call already left the stack at its recorded top. - return; - - writer.WriteLine(); - writer.Write("if (!"); - writer.Write(model.ReturnMarshallerTypeName); - writer.Write(".TryRead("); - writer.Write(State); - writer.Write(", -1, out "); - // A string local is declared nullable: the marshaller's out parameter is [MaybeNullWhen(false)], and the - // flow analysis knows it is not null once the read succeeded. - writer.Write(model.ReturnMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(model.ReturnKind!.Value, true)); - writer.Write(' '); - writer.Write(Result); - writer.WriteLine("))"); - writer.OpenBlock(); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".ThrowUnexpectedResult("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", -1, "); - writer.Write(CSharpLiteral.ToStringLiteral(model.GlobalName)); - writer.Write(", "); - writer.Write(CSharpLiteral.ToStringLiteral(model.ExpectedReturnTypeName)); - writer.WriteLine(");"); - writer.CloseBlock(); - writer.WriteLine(); - writer.Write("return "); - writer.Write(Result); - writer.WriteLine(";"); - } - - // The read of one result at a (negative) stack index, as a boolean expression. - private static void WriteResultRead(SourceWriter writer, LuaResultModel result, int index) - { - if (result.Shape == LuaResultShape.CopyOut) - { - writer.Write(State); - writer.Write(".TryCopyUtf8("); - writer.Write(index.ToString(CultureInfo.InvariantCulture)); - writer.Write(", "); - writer.Write(result.DestinationName); - writer.Write(", out "); - writer.Write(result.Name); - writer.Write(')'); - return; - } - - writer.Write(result.GeneratedMarshallerTypeName); - writer.Write(".TryRead("); - writer.Write(State); - writer.Write(", "); - writer.Write(index.ToString(CultureInfo.InvariantCulture)); - writer.Write(", out "); - writer.Write(result.Name); - writer.Write(')'); - } - - // The Try form's cold exit: every result other than 'failing' is defaulted in place, that one goes through - // LuaCallSupport.Fail, which restores the stack, defaults it and returns false. - private static void WriteTryFailure(SourceWriter writer, LuaGlobalCallModel model, int failing) - { - for (var i = 0; i < model.Results.Length; i++) - { - if (i == failing) continue; - - var other = model.Results[i]; - writer.Write(other.Name); - writer.WriteLine(other.IsReferenceType ? " = default!;" : " = default;"); - } - - writer.Write("return "); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", out "); - writer.Write(model.Results[failing].Name); - writer.WriteLine(");"); - } - - private static void WriteOutcomeFailure(SourceWriter writer, LuaGlobalCallModel model, string status, int failing) - { - for (var i = 0; i < model.Results.Length; i++) - { - if (i == failing) continue; - - var other = model.Results[i]; - writer.Write(other.Name); - writer.WriteLine(other.IsReferenceType ? " = default!;" : " = default;"); - } - - writer.Write("return "); - writer.Write(LuaApiNames.LuaCallSupport); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Top); - writer.Write(", "); - writer.Write(status); - if (!model.Results.IsEmpty) - { - writer.Write(", out "); - writer.Write(model.Results[failing].Name); - } - - writer.WriteLine(");"); - } - - // Push and conversion operations can throw managed LuaException after native failures. A Try wrapper keeps its - // ordinary failure contract for that path; the surrounding finally restores its stack snapshot. - private static void WriteTryExceptionFailure(SourceWriter writer, LuaGlobalCallModel model) - { - foreach (var result in model.Results) - { - writer.Write(result.Name); - writer.WriteLine(result.IsReferenceType ? " = default!;" : " = default;"); - } - - writer.WriteLine("return false;"); - } - - // The exit taken when the stack cannot grow: nothing was pushed yet, so the top needs no restoring. - private static void WriteStackExit(SourceWriter writer, LuaGlobalCallModel model, int slots) - { - if (model.Form == LuaCallForm.Try) - { - WriteTryFailure(writer, model, 0); - return; - } - - if (model.Form == LuaCallForm.Outcome) - { - WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".StackUnavailable", 0); - return; - } - - writer.Write("throw new "); - writer.Write(LuaApiNames.LuaException); - writer.Write('('); - writer.Write(CSharpLiteral.ToStringLiteral( - "The Lua stack could not grow by " + slots.ToString(CultureInfo.InvariantCulture) + " slots to call '" + - model.GlobalName + "'.")); - writer.WriteLine(");"); - } - - private static void WriteSeparator(SourceWriter writer, ref bool first) - { - if (!first) writer.Write(", "); - - first = false; - } + /// + /// Bodies that push or keep more values than this call TryEnsureStack first: a C function is + /// guaranteed LUA_MINSTACK = 20 free slots (Lua 5.3 manual, section 4.2) and the protected helpers + /// need up to four. + /// + public const int StackCheckThreshold = 16; + + private const string State = "__L"; + private const string Operation = "__operation"; + private const string Top = "__top"; + private const string Ok = "__ok"; + private const string Status = "__status"; + private const string Result = "__result"; + + /// Writes the method, signature and body, at the writer's current indentation. + public static void Emit(SourceWriter writer, LuaGlobalCallModel model) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (model is null) + { + throw new ArgumentNullException(nameof(model)); + } + + WriteSignature(writer, model); + writer.OpenBlock(); + WriteBody(writer, model); + writer.CloseBlock(); + } + + /// Writes the parameter list, parentheses included: the state, the arguments, then the results. + public static void WriteParameterList(SourceWriter writer, LuaGlobalCallModel model) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (model is null) + { + throw new ArgumentNullException(nameof(model)); + } + + writer.Write('('); + bool first = true; + bool isExtensionReceiver = model.IsExtensionMethod; + if (model.TakesState) + { + if (isExtensionReceiver) + { + writer.Write("this "); + } + + writer.Write(LuaApiNames.LuaState); + writer.Write(' '); + writer.Write(model.StateParameterName); + first = false; + isExtensionReceiver = false; + } + + for (int i = 0; i < model.Arguments.Length; i++) + { + LuaArgumentModel argument = model.Arguments[i]; + if (argument.IsFixed) + { + continue; + } + + WriteSeparator(writer, ref first); + WriteArgumentParameter(writer, argument, isExtensionReceiver); + isExtensionReceiver = false; + } + + if (model.IsTryLike) + { + foreach (LuaResultModel result in model.Results) + { + WriteSeparator(writer, ref first); + WriteResultParameter(writer, result, isExtensionReceiver); + isExtensionReceiver = false; + } + } + + writer.Write(')'); + } + + // 'scoped name': the scoped modifier is written first when the declaration used it (Utf8 is the only + // argument kind of ref struct type; every other kind's IsScoped is always false, see LuaArgumentModel). + private static void WriteArgumentParameter(SourceWriter writer, LuaArgumentModel argument, bool isExtensionReceiver) + { + if (isExtensionReceiver) + { + writer.Write("this "); + } + + if (argument.IsScoped) + { + writer.Write("scoped "); + } + + writer.Write(argument.GeneratedTypeName); + writer.Write(' '); + writer.Write(argument.Name); + } + + // 'scoped Span destination, out int written' or 'out name'. + private static void WriteResultParameter(SourceWriter writer, LuaResultModel result, bool isExtensionReceiver) + { + if (result.Shape == LuaResultShape.CopyOut) + { + if (isExtensionReceiver) + { + writer.Write("this "); + } + + if (result.DestinationIsScoped) + { + writer.Write("scoped "); + } + + writer.Write(LuaApiNames.SpanOfByte); + writer.Write(' '); + writer.Write(result.DestinationName); + writer.Write(", out int "); + } + else + { + writer.Write("out "); + writer.Write(result.GeneratedTypeName); + writer.Write(' '); + } + + writer.Write(result.Name); + } + + /// + /// The return type as written in the signature: for the Try form, the result type or + /// + /// otherwise. + /// + public static string ReturnTypeName(LuaGlobalCallModel model) + { + if (model is null) + { + throw new ArgumentNullException(nameof(model)); + } + + return model.Form == LuaCallForm.Try + ? "bool" + : model.Form == LuaCallForm.Outcome + ? LuaApiNames.LuaOperationStatus + : model.HasReturn + ? model.ReturnTypeName + : "void"; + } + + private static void WriteSignature(SourceWriter writer, LuaGlobalCallModel model) + { + if (model.Modifiers.Length > 0) + { + writer.Write(model.Modifiers); + writer.Write(' '); + } + + writer.Write(ReturnTypeName(model)); + writer.Write(' '); + writer.Write(model.MethodName); + WriteParameterList(writer, model); + writer.WriteLine(); + } + + private static void WriteBody(SourceWriter writer, LuaGlobalCallModel model) + { + int argumentCount = model.Arguments.Length; + int resultCount = model.ResultCount; + + WriteStateAndTop(writer, model); + WriteProtectedBody(writer, model, argumentCount, resultCount); + WriteExceptionHandler(writer, model); + WriteStackRestore(writer); + } + + private static void WriteProtectedBody(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, + int resultCount) + { + writer.WriteLine("try"); + writer.OpenBlock(); + + // Only bodies that would exceed the guaranteed free slots check the stack. + int slots = Math.Max(1 + argumentCount, resultCount); + if (slots > StackCheckThreshold) + { + WriteStackCheck(writer, model, slots); + } + + WriteGlobalPush(writer, model); + WriteArguments(writer, model); + WriteCallAndResults(writer, model, argumentCount, resultCount); + writer.CloseBlock(); + } + + private static void WriteArguments(SourceWriter writer, LuaGlobalCallModel model) + { + foreach (LuaArgumentModel argument in model.Arguments) + { + writer.Write(argument.GeneratedMarshallerTypeName); + writer.Write(".Push("); + writer.Write(State); + writer.Write(", "); + writer.Write(argument.FixedValue ?? argument.Name); + writer.WriteLine(");"); + } + } + + private static void WriteCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, + int resultCount) + { + if (model.Form == LuaCallForm.Try) + { + WriteTryCallAndResults(writer, model, argumentCount, resultCount); + } + else if (model.Form == LuaCallForm.Outcome) + { + WriteOutcomeCallAndResults(writer, model, argumentCount, resultCount); + } + else + { + WriteThrowingCall(writer, argumentCount, resultCount); + WriteThrowingResult(writer, model); + } + } + + private static void WriteExceptionHandler(SourceWriter writer, LuaGlobalCallModel model) + { + if (model.Form == LuaCallForm.Try) + { + writer.Write("catch ("); + writer.Write(LuaApiNames.LuaException); + writer.WriteLine(")"); + writer.OpenBlock(); + WriteTryExceptionFailure(writer, model); + writer.CloseBlock(); + } + else if (model.Form == LuaCallForm.Outcome) + { + writer.Write("catch ("); + writer.Write(LuaApiNames.LuaException); + writer.Write(' '); + writer.Write("__exception"); + writer.WriteLine(")"); + writer.OpenBlock(); + WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".LuaFailure(__exception.Status)", 0); + writer.CloseBlock(); + } + } + + private static void WriteStackRestore(SourceWriter writer) + { + writer.WriteLine("finally"); + writer.OpenBlock(); + writer.Write(State); + writer.Write(".SetTop("); + writer.Write(Top); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + private static void WriteStateAndTop(SourceWriter writer, LuaGlobalCallModel model) + { + writer.Write("using "); + writer.Write(LuaApiNames.LuaRuntimeOperation); + writer.Write(' '); + writer.Write(Operation); + writer.Write(" = "); + if (model.TakesState) + { + writer.Write(LuaApiNames.LuaRuntime); + writer.Write(".AcquireOperation("); + writer.Write(model.StateParameterName); + writer.Write(')'); + } + else + { + writer.Write(LuaApiNames.AcquireOperation); + } + + writer.WriteLine(";"); + writer.Write(LuaApiNames.LuaState); + writer.Write(' '); + writer.Write(State); + writer.Write(" = "); + writer.Write(Operation); + writer.Write(".State"); + writer.WriteLine(";"); + writer.Write("int "); + writer.Write(Top); + writer.Write(" = "); + writer.Write(State); + writer.WriteLine(".Top;"); + } + + private static void WriteStackCheck(SourceWriter writer, LuaGlobalCallModel model, int slots) + { + writer.Write("if (!"); + writer.Write(State); + writer.Write(".TryEnsureStack("); + writer.Write(slots.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine("))"); + writer.OpenBlock(); + WriteStackExit(writer, model, slots); + writer.CloseBlock(); + writer.WriteLine(); + } + + // Exit 1: the global could not be resolved. + private static void WriteGlobalPush(SourceWriter writer, LuaGlobalCallModel model) + { + if (model.Form == LuaCallForm.Outcome) + { + writer.Write("var __resolution = "); + writer.Write(LuaApiNames.LuaGlobalFunctions); + writer.Write(".TryPushWithOutcome("); + writer.Write(State); + writer.Write(", "); + writer.Write(model.CacheFieldReference); + writer.Write(", "); + writer.Write(CSharpLiteral.ToUtf8Literal(model.GlobalName)); + writer.WriteLine(");"); + writer.Write("if (!__resolution.IsSuccess)"); + writer.OpenBlock(); + WriteOutcomeFailure(writer, model, "__resolution.ToOperationStatus()", 0); + writer.CloseBlock(); + writer.WriteLine(); + return; + } + + writer.Write("if (!"); + writer.Write(LuaApiNames.LuaGlobalFunctions); + writer.Write(".TryPush("); + writer.Write(State); + writer.Write(", "); + writer.Write(model.CacheFieldReference); + writer.Write(", "); + writer.Write(CSharpLiteral.ToUtf8Literal(model.GlobalName)); + writer.WriteLine("))"); + writer.OpenBlock(); + if (model.Form == LuaCallForm.Try) + { + WriteTryFailure(writer, model, 0); + } + else + { + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".ThrowUnresolvedGlobal("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", "); + writer.Write(CSharpLiteral.ToStringLiteral(model.GlobalName)); + writer.WriteLine(");"); + } + + writer.CloseBlock(); + writer.WriteLine(); + } + + private static void WriteTryCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, + int resultCount) + { + // Exit 2: the call raised. + writer.Write("if (!"); + writer.Write(State); + writer.Write(".TryCall("); + writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(", "); + writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(").IsOk)"); + writer.OpenBlock(); + WriteTryFailure(writer, model, 0); + writer.CloseBlock(); + writer.WriteLine(); + + if (resultCount == 1) + { + // Exit 3 folded into the return value: one read, restore, return. + writer.Write("bool "); + writer.Write(Ok); + writer.Write(" = "); + WriteResultRead(writer, model.Results[0], -1); + writer.WriteLine(";"); + } + else + { + // Exit 3 per result: each read that fails defaults the other results and takes the cold exit. + for (int i = 0; i < resultCount; i++) + { + writer.Write("if (!"); + WriteResultRead(writer, model.Results[i], i - resultCount); + writer.WriteLine(")"); + writer.OpenBlock(); + WriteTryFailure(writer, model, i); + writer.CloseBlock(); + writer.WriteLine(); + } + } + + writer.Write("return "); + writer.Write(resultCount == 1 ? Ok : "true"); + writer.WriteLine(";"); + } + + private static void WriteOutcomeCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, + int resultCount) + { + WriteOutcomeCall(writer, model, argumentCount, resultCount); + WriteOutcomeResults(writer, model, resultCount); + writer.Write("return "); + writer.Write(LuaApiNames.LuaOperationStatus); + writer.WriteLine(".Success;"); + } + + private static void WriteOutcomeCall(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, + int resultCount) + { + writer.Write(LuaApiNames.LuaStatus); + writer.Write(' '); + writer.Write(Status); + writer.Write(" = "); + writer.Write(State); + writer.Write(".TryCall("); + writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(", "); + writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(");"); + writer.Write("if (!"); + writer.Write(Status); + writer.WriteLine(".IsOk)"); + writer.OpenBlock(); + WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".LuaFailure(" + Status + ")", 0); + writer.CloseBlock(); + writer.WriteLine(); + } + + private static void WriteOutcomeResults(SourceWriter writer, LuaGlobalCallModel model, int resultCount) + { + for (int i = 0; i < resultCount; i++) + { + WriteOutcomeResult(writer, model, i, i - resultCount); + } + } + + private static void WriteOutcomeResult(SourceWriter writer, LuaGlobalCallModel model, int resultIndex, + int stackIndex) + { + writer.Write("if (!"); + WriteResultRead(writer, model.Results[resultIndex], stackIndex); + writer.WriteLine(")"); + writer.OpenBlock(); + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", "); + writer.Write(State); + writer.Write(".IsNil("); + writer.Write(stackIndex.ToString(CultureInfo.InvariantCulture)); + writer.Write(") ? "); + writer.Write(LuaApiNames.LuaOperationStatus); + writer.Write(".NilResult : "); + writer.Write(LuaApiNames.LuaOperationStatus); + writer.Write(".InvalidResult, out "); + writer.Write(model.Results[resultIndex].Name); + writer.WriteLine(");"); + writer.CloseBlock(); + writer.WriteLine(); + } + + // Exit 2: the call raised; the status travels to the throw helper, which reads the error value. + private static void WriteThrowingCall(SourceWriter writer, int argumentCount, int resultCount) + { + writer.Write(LuaApiNames.LuaStatus); + writer.Write(' '); + writer.Write(Status); + writer.Write(" = "); + writer.Write(State); + writer.Write(".TryCall("); + writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(", "); + writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(");"); + writer.Write("if (!"); + writer.Write(Status); + writer.WriteLine(".IsOk)"); + writer.OpenBlock(); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Throw("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", "); + writer.Write(Status); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + // Exit 3: the result is nil or of another kind; the helper names the Lua type it found. + private static void WriteThrowingResult(SourceWriter writer, LuaGlobalCallModel model) + { + if (!model.HasReturn) + // A void call keeps no result: the successful call already left the stack at its recorded top. + { + return; + } + + writer.WriteLine(); + writer.Write("if (!"); + writer.Write(model.ReturnMarshallerTypeName); + writer.Write(".TryRead("); + writer.Write(State); + writer.Write(", -1, out "); + // A string local is declared nullable: the marshaller's out parameter is [MaybeNullWhen(false)], and the + // flow analysis knows it is not null once the read succeeded. + writer.Write(model.ReturnMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(model.ReturnKind!.Value, true)); + writer.Write(' '); + writer.Write(Result); + writer.WriteLine("))"); + writer.OpenBlock(); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".ThrowUnexpectedResult("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", -1, "); + writer.Write(CSharpLiteral.ToStringLiteral(model.GlobalName)); + writer.Write(", "); + writer.Write(CSharpLiteral.ToStringLiteral(model.ExpectedReturnTypeName)); + writer.WriteLine(");"); + writer.CloseBlock(); + writer.WriteLine(); + writer.Write("return "); + writer.Write(Result); + writer.WriteLine(";"); + } + + // The read of one result at a (negative) stack index, as a boolean expression. + private static void WriteResultRead(SourceWriter writer, LuaResultModel result, int index) + { + if (result.Shape == LuaResultShape.CopyOut) + { + writer.Write(State); + writer.Write(".TryCopyUtf8("); + writer.Write(index.ToString(CultureInfo.InvariantCulture)); + writer.Write(", "); + writer.Write(result.DestinationName); + writer.Write(", out "); + writer.Write(result.Name); + writer.Write(')'); + return; + } + + writer.Write(result.GeneratedMarshallerTypeName); + writer.Write(".TryRead("); + writer.Write(State); + writer.Write(", "); + writer.Write(index.ToString(CultureInfo.InvariantCulture)); + writer.Write(", out "); + writer.Write(result.Name); + writer.Write(')'); + } + + // The Try form's cold exit: every result other than 'failing' is defaulted in place, that one goes through + // LuaCallSupport.Fail, which restores the stack, defaults it and returns false. + private static void WriteTryFailure(SourceWriter writer, LuaGlobalCallModel model, int failing) + { + for (int i = 0; i < model.Results.Length; i++) + { + if (i == failing) + { + continue; + } + + LuaResultModel other = model.Results[i]; + writer.Write(other.Name); + writer.WriteLine(other.IsReferenceType ? " = default!;" : " = default;"); + } + + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", out "); + writer.Write(model.Results[failing].Name); + writer.WriteLine(");"); + } + + private static void WriteOutcomeFailure(SourceWriter writer, LuaGlobalCallModel model, string status, int failing) + { + for (int i = 0; i < model.Results.Length; i++) + { + if (i == failing) + { + continue; + } + + LuaResultModel other = model.Results[i]; + writer.Write(other.Name); + writer.WriteLine(other.IsReferenceType ? " = default!;" : " = default;"); + } + + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", "); + writer.Write(status); + if (!model.Results.IsEmpty) + { + writer.Write(", out "); + writer.Write(model.Results[failing].Name); + } + + writer.WriteLine(");"); + } + + // Push and conversion operations can throw managed LuaException after native failures. A Try wrapper keeps its + // ordinary failure contract for that path; the surrounding finally restores its stack snapshot. + private static void WriteTryExceptionFailure(SourceWriter writer, LuaGlobalCallModel model) + { + foreach (LuaResultModel result in model.Results) + { + writer.Write(result.Name); + writer.WriteLine(result.IsReferenceType ? " = default!;" : " = default;"); + } + + writer.WriteLine("return false;"); + } + + // The exit taken when the stack cannot grow: nothing was pushed yet, so the top needs no restoring. + private static void WriteStackExit(SourceWriter writer, LuaGlobalCallModel model, int slots) + { + if (model.Form == LuaCallForm.Try) + { + WriteTryFailure(writer, model, 0); + return; + } + + if (model.Form == LuaCallForm.Outcome) + { + WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".StackUnavailable", 0); + return; + } + + writer.Write("throw new "); + writer.Write(LuaApiNames.LuaException); + writer.Write('('); + writer.Write(CSharpLiteral.ToStringLiteral( + "The Lua stack could not grow by " + slots.ToString(CultureInfo.InvariantCulture) + " slots to call '" + + model.GlobalName + "'.")); + writer.WriteLine(");"); + } + + private static void WriteSeparator(SourceWriter writer, ref bool first) + { + if (!first) + { + writer.Write(", "); + } + + first = false; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs index 6799e7ed..c2a976c7 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs @@ -47,73 +47,76 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// selects a built-in marshaller. /// internal sealed record LuaGlobalCallModel( - string GlobalName, - string CacheFieldName, - string Modifiers, - string MethodName, - string StateParameterName, - EquatableArray Arguments, - LuaCallForm Form, - EquatableArray Results, - LuaValueKind? ReturnKind, - bool ReturnIsNullable, - bool IsExtensionMethod = false, - string? CacheFieldAccess = null, - LuaCustomMarshallerModel? ReturnMarshaller = null) + string GlobalName, + string CacheFieldName, + string Modifiers, + string MethodName, + string StateParameterName, + EquatableArray Arguments, + LuaCallForm Form, + EquatableArray Results, + LuaValueKind? ReturnKind, + bool ReturnIsNullable, + bool IsExtensionMethod = false, + string? CacheFieldAccess = null, + LuaCustomMarshallerModel? ReturnMarshaller = null) { - /// Initializes a call model with the pre-custom-marshaller binary shape. - public LuaGlobalCallModel(string globalName, string cacheFieldName, string modifiers, string methodName, - string stateParameterName, EquatableArray arguments, LuaCallForm form, - EquatableArray results, LuaValueKind? returnKind, bool returnIsNullable, - bool isExtensionMethod, string? cacheFieldAccess) - : this(globalName, cacheFieldName, modifiers, methodName, stateParameterName, arguments, form, results, - returnKind, returnIsNullable, isExtensionMethod, cacheFieldAccess, null) - { - } + /// Prefix of the cache field a file emitter declares for a global. + public const string CacheFieldPrefix = "s_luaGlobal_"; - /// Prefix of the cache field a file emitter declares for a global. - public const string CacheFieldPrefix = "s_luaGlobal_"; + /// Initializes a call model with the pre-custom-marshaller binary shape. + public LuaGlobalCallModel(string globalName, string cacheFieldName, string modifiers, string methodName, + string stateParameterName, EquatableArray arguments, LuaCallForm form, + EquatableArray results, LuaValueKind? returnKind, bool returnIsNullable, + bool isExtensionMethod, string? cacheFieldAccess) + : this(globalName, cacheFieldName, modifiers, methodName, stateParameterName, arguments, form, results, + returnKind, returnIsNullable, isExtensionMethod, cacheFieldAccess, null) + { + } - /// Number of results the protected call keeps: the result count of either non-throwing form, 0 or 1 for the throwing form. - public int ResultCount => IsTryLike ? Results.Length : ReturnKind is null && ReturnMarshaller is null ? 0 : 1; + /// + /// Number of results the protected call keeps: the result count of either non-throwing form, 0 or 1 for the + /// throwing form. + /// + public int ResultCount => IsTryLike ? Results.Length : ReturnKind is null && ReturnMarshaller is null ? 0 : 1; - /// Gets whether this shape returns its Lua values through parameters. - public bool IsTryLike => Form is LuaCallForm.Try or LuaCallForm.Outcome; + /// Gets whether this shape returns its Lua values through parameters. + public bool IsTryLike => Form is LuaCallForm.Try or LuaCallForm.Outcome; - /// Gets whether this is the opt-in detailed non-throwing form. - public bool IsOutcome => Form == LuaCallForm.Outcome; + /// Gets whether this is the opt-in detailed non-throwing form. + public bool IsOutcome => Form == LuaCallForm.Outcome; - /// Whether the throwing form returns one Lua value. - public bool HasReturn => ReturnKind is not null || ReturnMarshaller is not null; + /// Whether the throwing form returns one Lua value. + public bool HasReturn => ReturnKind is not null || ReturnMarshaller is not null; - /// The concrete static marshaller for the throwing-form return value. - public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? - LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); + /// The concrete static marshaller for the throwing-form return value. + public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? + LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); - /// The C# type spelling for the generated return and result local. - public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? - LuaValueKinds.TypeName(ReturnKind!.Value, ReturnIsNullable); + /// The C# type spelling for the generated return and result local. + public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? + LuaValueKinds.TypeName(ReturnKind!.Value, ReturnIsNullable); - /// The Lua-facing expected type for a throwing-form result failure. - public string ExpectedReturnTypeName => ReturnMarshaller?.ExpectedTypeName ?? - LuaValueKinds.ExpectedResult(ReturnKind!.Value); + /// The Lua-facing expected type for a throwing-form result failure. + public string ExpectedReturnTypeName => ReturnMarshaller?.ExpectedTypeName ?? + LuaValueKinds.ExpectedResult(ReturnKind!.Value); - /// Whether the body reads the state from rather than from the runtime. - public bool TakesState => StateParameterName.Length > 0; + /// Whether the body reads the state from rather than from the runtime. + public bool TakesState => StateParameterName.Length > 0; - /// - /// The expression that accesses from the generated method body. Bindings qualify - /// this with their containing type so a parameter cannot shadow the static cache; spec-generated wrappers keep - /// the unqualified field name. - /// - public string CacheFieldReference => CacheFieldAccess ?? CacheFieldName; + /// + /// The expression that accesses from the generated method body. Bindings qualify + /// this with their containing type so a parameter cannot shadow the static cache; spec-generated wrappers keep + /// the unqualified field name. + /// + public string CacheFieldReference => CacheFieldAccess ?? CacheFieldName; - /// - /// The cache field name for : + the name (a Lua - /// name is a C# identifier). - /// - public static string CacheFieldFor(string globalName) - { - return CacheFieldPrefix + globalName; - } + /// + /// The cache field name for : + the name (a Lua + /// name is a C# identifier). + /// + public static string CacheFieldFor(string globalName) + { + return CacheFieldPrefix + globalName; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaNames.cs index 97fd9c73..23ce6c45 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaNames.cs @@ -20,45 +20,52 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// already length-agnostic concern of the CheatEngine.SDK.Lua layer). /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class LuaNames { - /// The 22 reserved words of Lua 5.3 (manual, section 3.1), which cannot name a global a script can reference. - private static readonly string[] ReservedWords = - [ - "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in", - "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while" - ]; + /// The 22 reserved words of Lua 5.3 (manual, section 3.1), which cannot name a global a script can reference. + private static readonly string[] ReservedWords = + [ + "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in", + "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while" + ]; - /// Whether is a Lua identifier that is not a reserved word. - /// The candidate; and empty are invalid. - public static bool IsValidName(string? name) - { - if (string.IsNullOrEmpty(name) || !IsIdentifierStart(name![0])) return false; + /// Whether is a Lua identifier that is not a reserved word. + /// The candidate; and empty are invalid. + public static bool IsValidName(string? name) + { + if (string.IsNullOrEmpty(name) || !IsIdentifierStart(name![0])) + { + return false; + } - for (var i = 1; i < name.Length; i++) - if (!IsIdentifierPart(name[i])) - return false; + for (int i = 1; i < name.Length; i++) + { + if (!IsIdentifierPart(name[i])) + { + return false; + } + } - return Array.IndexOf(ReservedWords, name) < 0; - } + return Array.IndexOf(ReservedWords, name) < 0; + } - /// Whether is one of Lua's reserved words. - public static bool IsReservedWord(string name) - { - return name is not null && Array.IndexOf(ReservedWords, name) >= 0; - } + /// Whether is one of Lua's reserved words. + public static bool IsReservedWord(string name) + { + return name is not null && Array.IndexOf(ReservedWords, name) >= 0; + } - private static bool IsIdentifierStart(char c) - { - return c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or '_'; - } + private static bool IsIdentifierStart(char c) + { + return c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or '_'; + } - private static bool IsIdentifierPart(char c) - { - return IsIdentifierStart(c) || c is >= '0' and <= '9'; - } + private static bool IsIdentifierPart(char c) + { + return IsIdentifierStart(c) || c is >= '0' and <= '9'; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaRegistrationEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaRegistrationEmitter.cs index fb10c0bf..821fc522 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaRegistrationEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaRegistrationEmitter.cs @@ -22,244 +22,260 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class LuaRegistrationEmitter { - /// Name of the generated registration method. - public const string RegisterMethodName = "RegisterLuaFunctions"; + /// Name of the generated registration method. + public const string RegisterMethodName = "RegisterLuaFunctions"; - /// Name of the additive ownership-aware registration method. - public const string RegisterLeaseMethodName = "TryRegisterLuaFunctions"; + /// Name of the additive ownership-aware registration method. + public const string RegisterLeaseMethodName = "TryRegisterLuaFunctions"; - /// Name of the generated unregistration method. - public const string UnregisterMethodName = "UnregisterLuaFunctions"; + /// Name of the generated unregistration method. + public const string UnregisterMethodName = "UnregisterLuaFunctions"; - /// The stack contract of both methods, as their documentation states it. - public const string StatusProtocol = - "Stack: +0 on success; +1 (the error value) on failure, as for every protected operation."; + /// The stack contract of both methods, as their documentation states it. + public const string StatusProtocol = + "Stack: +0 on success; +1 (the error value) on failure, as for every protected operation."; - private const string StateParameter = "state"; - private const string Status = "__status"; + private const string StateParameter = "state"; + private const string Status = "__status"; - /// - /// Writes the ownership-aware method and legacy pair, separated by blank lines, at the writer's current - /// indentation. Thunks are registered in the order given. - /// - /// The writer. - /// The thunks of the containing type; must not be empty. - /// - /// Attribute lines to put on each method (for example [GeneratedCode]); may be - /// empty. - /// - public static void Emit(SourceWriter writer, EquatableArray thunks, string memberAttributes) - { - if (writer is null) throw new ArgumentNullException(nameof(writer)); + /// + /// Writes the ownership-aware method and legacy pair, separated by blank lines, at the writer's current + /// indentation. Thunks are registered in the order given. + /// + /// The writer. + /// The thunks of the containing type; must not be empty. + /// + /// Attribute lines to put on each method (for example [GeneratedCode]); may be + /// empty. + /// + public static void Emit(SourceWriter writer, EquatableArray thunks, string memberAttributes) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } - if (thunks.IsEmpty) - throw new ArgumentException("A registration table needs at least one thunk.", nameof(thunks)); + if (thunks.IsEmpty) + { + throw new ArgumentException("A registration table needs at least one thunk.", nameof(thunks)); + } - WriteRegisterLease(writer, thunks, memberAttributes); - writer.WriteLine(); - WriteRegister(writer, thunks, memberAttributes); - writer.WriteLine(); - WriteUnregister(writer, thunks, memberAttributes); - } + WriteRegisterLease(writer, thunks, memberAttributes); + writer.WriteLine(); + WriteRegister(writer, thunks, memberAttributes); + writer.WriteLine(); + WriteUnregister(writer, thunks, memberAttributes); + } - private static void WriteRegister(SourceWriter writer, EquatableArray thunks, - string memberAttributes) - { - writer.WriteLine("/// "); - writer.Write( - "/// Registers every [LuaFunction] of this type as a global of : "); - WriteNameList(writer, thunks); - writer.WriteLine("."); - writer.Write("/// "); - writer.WriteLine(StatusProtocol); - writer.WriteLine("/// "); - writer.WriteLine( - "/// The state to register on; the calling thread's. Requires the plugin to be enabled."); - writer.WriteLine("/// The status of the first failing operation, or LuaStatus.Ok."); - WriteAttributes(writer, memberAttributes); - WriteMethodOpening(writer, "public static unsafe ", RegisterMethodName); - foreach (var thunk in thunks) WriteRegistration(writer, thunk); + private static void WriteRegister(SourceWriter writer, EquatableArray thunks, + string memberAttributes) + { + writer.WriteLine("/// "); + writer.Write( + "/// Registers every [LuaFunction] of this type as a global of : "); + WriteNameList(writer, thunks); + writer.WriteLine("."); + writer.Write("/// "); + writer.WriteLine(StatusProtocol); + writer.WriteLine("/// "); + writer.WriteLine( + "/// The state to register on; the calling thread's. Requires the plugin to be enabled."); + writer.WriteLine("/// The status of the first failing operation, or LuaStatus.Ok."); + WriteAttributes(writer, memberAttributes); + WriteMethodOpening(writer, "public static unsafe ", RegisterMethodName); + foreach (LuaThunkModel thunk in thunks) + { + WriteRegistration(writer, thunk); + } - WriteMethodClosing(writer); - } + WriteMethodClosing(writer); + } - private static void WriteRegisterLease(SourceWriter writer, EquatableArray thunks, - string memberAttributes) - { - writer.WriteLine("/// "); - writer.Write("/// Registers every [LuaFunction] of this type as an ownership-aware global lease: "); - WriteNameList(writer, thunks); - writer.WriteLine("."); - writer.WriteLine("/// "); - writer.WriteLine("/// The calling thread's state of the attached Lua runtime."); - writer.WriteLine("/// The explicit policy for effective globals that already exist."); - writer.WriteLine("/// A factual registration result and a lease that releases only values this call still owns."); - WriteAttributes(writer, memberAttributes); - WriteRegisterLeaseOpening(writer); - writer.OpenBlock(); - writer.Write("return "); - writer.Write(LuaApiNames.LuaRegistrationSet); - writer.Write(".Register("); - writer.Write(StateParameter); - writer.WriteLine(","); - writer.WriteLine("["); - WriteRegistrationEntries(writer, thunks); + private static void WriteRegisterLease(SourceWriter writer, EquatableArray thunks, + string memberAttributes) + { + writer.WriteLine("/// "); + writer.Write("/// Registers every [LuaFunction] of this type as an ownership-aware global lease: "); + WriteNameList(writer, thunks); + writer.WriteLine("."); + writer.WriteLine("/// "); + writer.WriteLine("/// The calling thread's state of the attached Lua runtime."); + writer.WriteLine( + "/// The explicit policy for effective globals that already exist."); + writer.WriteLine( + "/// A factual registration result and a lease that releases only values this call still owns."); + WriteAttributes(writer, memberAttributes); + WriteRegisterLeaseOpening(writer); + writer.OpenBlock(); + writer.Write("return "); + writer.Write(LuaApiNames.LuaRegistrationSet); + writer.Write(".Register("); + writer.Write(StateParameter); + writer.WriteLine(","); + writer.WriteLine("["); + WriteRegistrationEntries(writer, thunks); - writer.WriteLine("],"); - writer.WriteLine("collisionPolicy);"); - writer.CloseBlock(); - } + writer.WriteLine("],"); + writer.WriteLine("collisionPolicy);"); + writer.CloseBlock(); + } - private static void WriteRegisterLeaseOpening(SourceWriter writer) - { - writer.Write("public static unsafe "); - writer.Write(LuaApiNames.LuaRegistrationResult); - writer.Write(' '); - writer.Write(RegisterLeaseMethodName); - writer.Write('('); - writer.Write(LuaApiNames.LuaState); - writer.Write(' '); - writer.Write(StateParameter); - writer.Write(", "); - writer.Write(LuaApiNames.LuaRegistrationCollisionPolicy); - writer.Write(" collisionPolicy = "); - writer.Write(LuaApiNames.LuaRegistrationCollisionPolicy); - writer.WriteLine(".RejectExisting)"); - } + private static void WriteRegisterLeaseOpening(SourceWriter writer) + { + writer.Write("public static unsafe "); + writer.Write(LuaApiNames.LuaRegistrationResult); + writer.Write(' '); + writer.Write(RegisterLeaseMethodName); + writer.Write('('); + writer.Write(LuaApiNames.LuaState); + writer.Write(' '); + writer.Write(StateParameter); + writer.Write(", "); + writer.Write(LuaApiNames.LuaRegistrationCollisionPolicy); + writer.Write(" collisionPolicy = "); + writer.Write(LuaApiNames.LuaRegistrationCollisionPolicy); + writer.WriteLine(".RejectExisting)"); + } - private static void WriteRegistrationEntries(SourceWriter writer, EquatableArray thunks) - { - foreach (var thunk in thunks) - { - writer.Write("new "); - writer.Write(LuaApiNames.LuaRegistrationEntry); - writer.Write('('); - writer.Write(CSharpLiteral.ToStringLiteral(thunk.LuaName)); - writer.Write(", new "); - writer.Write(LuaApiNames.LuaNativeFunction); - writer.Write("(&"); - writer.Write(thunk.ThunkMethodName); - writer.WriteLine(")),"); - } - } + private static void WriteRegistrationEntries(SourceWriter writer, EquatableArray thunks) + { + foreach (LuaThunkModel thunk in thunks) + { + writer.Write("new "); + writer.Write(LuaApiNames.LuaRegistrationEntry); + writer.Write('('); + writer.Write(CSharpLiteral.ToStringLiteral(thunk.LuaName)); + writer.Write(", new "); + writer.Write(LuaApiNames.LuaNativeFunction); + writer.Write("(&"); + writer.Write(thunk.ThunkMethodName); + writer.WriteLine(")),"); + } + } - private static void WriteUnregister(SourceWriter writer, EquatableArray thunks, - string memberAttributes) - { - writer.WriteLine("/// "); - writer.Write("/// Assigns nil to every global that registers: "); - WriteNameList(writer, thunks); - writer.WriteLine("."); - writer.Write("/// "); - writer.WriteLine(StatusProtocol); - writer.WriteLine("/// "); - writer.WriteLine("/// The state to unregister from; the calling thread's."); - writer.WriteLine("/// The status of the first failing assignment, or LuaStatus.Ok."); - WriteAttributes(writer, memberAttributes); - WriteMethodOpening(writer, "public static ", UnregisterMethodName); - foreach (var thunk in thunks) - { - writer.Write(StateParameter); - writer.WriteLine(".PushNil();"); - WriteSetGlobal(writer, thunk); - } + private static void WriteUnregister(SourceWriter writer, EquatableArray thunks, + string memberAttributes) + { + writer.WriteLine("/// "); + writer.Write("/// Assigns nil to every global that registers: "); + WriteNameList(writer, thunks); + writer.WriteLine("."); + writer.Write("/// "); + writer.WriteLine(StatusProtocol); + writer.WriteLine("/// "); + writer.WriteLine("/// The state to unregister from; the calling thread's."); + writer.WriteLine("/// The status of the first failing assignment, or LuaStatus.Ok."); + WriteAttributes(writer, memberAttributes); + WriteMethodOpening(writer, "public static ", UnregisterMethodName); + foreach (LuaThunkModel thunk in thunks) + { + writer.Write(StateParameter); + writer.WriteLine(".PushNil();"); + WriteSetGlobal(writer, thunk); + } - WriteMethodClosing(writer); - } + WriteMethodClosing(writer); + } - // ' LuaStatus (LuaState state) {' and the status local. - private static void WriteMethodOpening(SourceWriter writer, string modifiers, string name) - { - writer.Write(modifiers); - writer.Write(LuaApiNames.LuaStatus); - writer.Write(' '); - writer.Write(name); - writer.Write('('); - writer.Write(LuaApiNames.LuaState); - writer.Write(' '); - writer.Write(StateParameter); - writer.WriteLine(")"); - writer.OpenBlock(); - writer.Write(LuaApiNames.LuaStatus); - writer.Write(' '); - writer.Write(Status); - writer.WriteLine(";"); - } + // ' LuaStatus (LuaState state) {' and the status local. + private static void WriteMethodOpening(SourceWriter writer, string modifiers, string name) + { + writer.Write(modifiers); + writer.Write(LuaApiNames.LuaStatus); + writer.Write(' '); + writer.Write(name); + writer.Write('('); + writer.Write(LuaApiNames.LuaState); + writer.Write(' '); + writer.Write(StateParameter); + writer.WriteLine(")"); + writer.OpenBlock(); + writer.Write(LuaApiNames.LuaStatus); + writer.Write(' '); + writer.Write(Status); + writer.WriteLine(";"); + } - private static void WriteMethodClosing(SourceWriter writer) - { - writer.Write("return "); - writer.Write(LuaApiNames.LuaStatus); - writer.WriteLine(".Ok;"); - writer.CloseBlock(); - } + private static void WriteMethodClosing(SourceWriter writer) + { + writer.Write("return "); + writer.Write(LuaApiNames.LuaStatus); + writer.WriteLine(".Ok;"); + writer.CloseBlock(); + } - // Push the wrapped closure, then assign it to the global; each step is a protected operation with a status. - private static void WriteRegistration(SourceWriter writer, LuaThunkModel thunk) - { - writer.Write(Status); - writer.Write(" = "); - writer.Write(LuaApiNames.LuaRuntime); - writer.Write(".TryPushGeneratedFunction("); - writer.Write(StateParameter); - writer.Write(", new "); - writer.Write(LuaApiNames.LuaNativeFunction); - writer.Write("(&"); - writer.Write(thunk.ThunkMethodName); - writer.WriteLine("));"); - WriteStatusCheck(writer); - WriteSetGlobal(writer, thunk); - } + // Push the wrapped closure, then assign it to the global; each step is a protected operation with a status. + private static void WriteRegistration(SourceWriter writer, LuaThunkModel thunk) + { + writer.Write(Status); + writer.Write(" = "); + writer.Write(LuaApiNames.LuaRuntime); + writer.Write(".TryPushGeneratedFunction("); + writer.Write(StateParameter); + writer.Write(", new "); + writer.Write(LuaApiNames.LuaNativeFunction); + writer.Write("(&"); + writer.Write(thunk.ThunkMethodName); + writer.WriteLine("));"); + WriteStatusCheck(writer); + WriteSetGlobal(writer, thunk); + } - private static void WriteSetGlobal(SourceWriter writer, LuaThunkModel thunk) - { - writer.Write(Status); - writer.Write(" = "); - writer.Write(StateParameter); - writer.Write(".TrySetGlobal("); - writer.Write(CSharpLiteral.ToUtf8Literal(thunk.LuaName)); - writer.WriteLine(");"); - WriteStatusCheck(writer); - } + private static void WriteSetGlobal(SourceWriter writer, LuaThunkModel thunk) + { + writer.Write(Status); + writer.Write(" = "); + writer.Write(StateParameter); + writer.Write(".TrySetGlobal("); + writer.Write(CSharpLiteral.ToUtf8Literal(thunk.LuaName)); + writer.WriteLine(");"); + WriteStatusCheck(writer); + } - private static void WriteStatusCheck(SourceWriter writer) - { - writer.Write("if (!"); - writer.Write(Status); - writer.WriteLine(".IsOk)"); - writer.OpenBlock(); - writer.Write("return "); - writer.Write(Status); - writer.WriteLine(";"); - writer.CloseBlock(); - writer.WriteLine(); - } + private static void WriteStatusCheck(SourceWriter writer) + { + writer.Write("if (!"); + writer.Write(Status); + writer.WriteLine(".IsOk)"); + writer.OpenBlock(); + writer.Write("return "); + writer.Write(Status); + writer.WriteLine(";"); + writer.CloseBlock(); + writer.WriteLine(); + } - private static void WriteAttributes(SourceWriter writer, string memberAttributes) - { - if (!string.IsNullOrEmpty(memberAttributes)) writer.WriteLine(memberAttributes); - } + private static void WriteAttributes(SourceWriter writer, string memberAttributes) + { + if (!string.IsNullOrEmpty(memberAttributes)) + { + writer.WriteLine(memberAttributes); + } + } - // The names in a documentation comment: Lua names are identifiers, so no XML escaping is needed. - private static void WriteNameList(SourceWriter writer, EquatableArray thunks) - { - var first = true; - foreach (var thunk in thunks) - { - if (!first) writer.Write(", "); + // The names in a documentation comment: Lua names are identifiers, so no XML escaping is needed. + private static void WriteNameList(SourceWriter writer, EquatableArray thunks) + { + bool first = true; + foreach (LuaThunkModel thunk in thunks) + { + if (!first) + { + writer.Write(", "); + } - first = false; - writer.Write(""); - writer.Write(thunk.LuaName); - writer.Write(""); - } - } + first = false; + writer.Write(""); + writer.Write(thunk.LuaName); + writer.Write(""); + } + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs index 39a4c740..7f177ce7 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs @@ -28,55 +28,56 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// marshaller represented by . /// internal sealed record LuaResultModel( - LuaResultShape Shape, - LuaValueKind Kind, - string Name, - bool IsNullable, - string DestinationName, - bool DestinationIsScoped = false, - LuaCustomMarshallerModel? CustomMarshaller = null) + LuaResultShape Shape, + LuaValueKind Kind, + string Name, + bool IsNullable, + string DestinationName, + bool DestinationIsScoped = false, + LuaCustomMarshallerModel? CustomMarshaller = null) { - /// Initializes a built-in scalar result model with the pre-custom-marshaller binary shape. - public LuaResultModel(LuaResultShape shape, LuaValueKind kind, string name, bool isNullable, - string destinationName, bool destinationIsScoped) - : this(shape, kind, name, isNullable, destinationName, destinationIsScoped, null) - { - } + /// Initializes a built-in scalar result model with the pre-custom-marshaller binary shape. + public LuaResultModel(LuaResultShape shape, LuaValueKind kind, string name, bool isNullable, + string destinationName, bool destinationIsScoped) + : this(shape, kind, name, isNullable, destinationName, destinationIsScoped, null) + { + } - /// A value result: out <type> name. - public static LuaResultModel Value(LuaValueKind kind, string name, bool isNullable = false) - { - return new LuaResultModel(LuaResultShape.Value, kind, name, isNullable, string.Empty); - } + /// The concrete marshaller that emitted code calls directly. + public string GeneratedMarshallerTypeName => + CustomMarshaller?.MarshallerTypeName ?? LuaValueKinds.MarshallerTypeName(Kind); - /// A value result read through an explicitly selected static marshaller. - public static LuaResultModel Custom(LuaCustomMarshallerModel marshaller, string name) - { - return new LuaResultModel(LuaResultShape.Value, LuaValueKind.Int32, name, IsNullable: false, - string.Empty, CustomMarshaller: marshaller); - } + /// The C# type spelling used in an emitted parameter or local. + public string GeneratedTypeName => CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); - /// A copy-out string result: Span<byte> destination, out int written. - public static LuaResultModel CopyOut(string destinationName, string writtenName, bool destinationIsScoped = false) - { - return new LuaResultModel( - LuaResultShape.CopyOut, - LuaValueKind.Int32, - writtenName, - IsNullable: false, - destinationName, - destinationIsScoped); - } + /// The Lua-facing expected type in a generated failure message. + public string ExpectedResultTypeName => CustomMarshaller?.ExpectedTypeName ?? LuaValueKinds.ExpectedResult(Kind); - /// The concrete marshaller that emitted code calls directly. - public string GeneratedMarshallerTypeName => CustomMarshaller?.MarshallerTypeName ?? LuaValueKinds.MarshallerTypeName(Kind); + /// Whether the generated default assignment needs the null-forgiving operator. + public bool IsReferenceType => CustomMarshaller?.IsReferenceType ?? LuaValueKinds.IsReferenceType(Kind); - /// The C# type spelling used in an emitted parameter or local. - public string GeneratedTypeName => CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); + /// A value result: out <type> name. + public static LuaResultModel Value(LuaValueKind kind, string name, bool isNullable = false) + { + return new LuaResultModel(LuaResultShape.Value, kind, name, isNullable, string.Empty); + } - /// The Lua-facing expected type in a generated failure message. - public string ExpectedResultTypeName => CustomMarshaller?.ExpectedTypeName ?? LuaValueKinds.ExpectedResult(Kind); + /// A value result read through an explicitly selected static marshaller. + public static LuaResultModel Custom(LuaCustomMarshallerModel marshaller, string name) + { + return new LuaResultModel(LuaResultShape.Value, LuaValueKind.Int32, name, false, + string.Empty, CustomMarshaller: marshaller); + } - /// Whether the generated default assignment needs the null-forgiving operator. - public bool IsReferenceType => CustomMarshaller?.IsReferenceType ?? LuaValueKinds.IsReferenceType(Kind); + /// A copy-out string result: Span<byte> destination, out int written. + public static LuaResultModel CopyOut(string destinationName, string writtenName, bool destinationIsScoped = false) + { + return new LuaResultModel( + LuaResultShape.CopyOut, + LuaValueKind.Int32, + writtenName, + IsNullable: false, + destinationName, + destinationIsScoped); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs index 6a675541..6f6523d7 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs @@ -3,16 +3,16 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// How a Try-form wrapper hands one Lua result back to its caller. internal enum LuaResultShape { - /// - /// An parameter read through the kind's marshaller: - /// value, value. - /// - Value, + /// + /// An parameter read through the kind's marshaller: + /// value, value. + /// + Value, - /// - /// A string copied into a caller buffer while it is still on the stack, allocation-free: - /// Span<byte> destination, written ( - /// LuaState.TryCopyUtf8). - /// - CopyOut + /// + /// A string copied into a caller buffer while it is still on the stack, allocation-free: + /// Span<byte> destination, written ( + /// LuaState.TryCopyUtf8). + /// + CopyOut } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs index a77b8ae1..9d66bc6d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs @@ -41,177 +41,186 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class LuaThunkEmitter { - private const string Handle = "__handle"; - private const string State = "__L"; - private const string ArgumentPrefix = "__arg"; - private const string Result = "__result"; - private const string Exception = "__exception"; - - /// Writes the attribute, signature and body of the thunk at the writer's current indentation. - public static void Emit(SourceWriter writer, LuaThunkModel model) - { - if (writer is null) throw new ArgumentNullException(nameof(writer)); - - if (model is null) throw new ArgumentNullException(nameof(model)); - - writer.WriteLine(LuaApiNames.UnmanagedCallersOnlyCdecl); - writer.Write("private static int "); - writer.Write(model.ThunkMethodName); - writer.Write("(nint "); - writer.Write(Handle); - writer.WriteLine(")"); - writer.OpenBlock(); - - writer.Write(LuaApiNames.LuaState); - writer.Write(' '); - writer.Write(State); - writer.Write(" = new("); - writer.Write(Handle); - writer.WriteLine(");"); - writer.WriteLine("try"); - writer.OpenBlock(); - WriteArgumentCountCheck(writer, model); - for (var i = 0; i < model.Arguments.Length; i++) - { - writer.WriteLine(); - WriteArgumentRead(writer, model.Arguments[i], i); - } - - writer.WriteLine(); - WriteCallAndResult(writer, model); - writer.CloseBlock(); - WriteCatchAll(writer); - - writer.CloseBlock(); - } - - /// The message a thunk reports when it is called with the wrong number of arguments. - public static string WrongArgumentCountMessage(string luaName, int expected) - { - return "wrong number of arguments to '" + luaName + "' (" + expected.ToString(CultureInfo.InvariantCulture) + - " expected)"; - } - - // The count check first, so that a missing argument and a surplus one get the same, complete message. - private static void WriteArgumentCountCheck(SourceWriter writer, LuaThunkModel model) - { - var count = model.Arguments.Length; - writer.Write("if ("); - writer.Write(State); - writer.Write(".Top != "); - writer.Write(count.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine(")"); - writer.OpenBlock(); - writer.Write("return "); - writer.Write(LuaApiNames.LuaThunk); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(CSharpLiteral.ToUtf8Literal(WrongArgumentCountMessage(model.LuaName, count))); - writer.WriteLine(");"); - writer.CloseBlock(); - } - - // One read per argument, at its 1-based stack index, which is also the argument number in the message. - private static void WriteArgumentRead(SourceWriter writer, LuaArgumentModel argument, int index) - { - var position = (index + 1).ToString(CultureInfo.InvariantCulture); - writer.Write("if (!"); - writer.Write(argument.GeneratedMarshallerTypeName); - writer.Write(".TryRead("); - writer.Write(State); - writer.Write(", "); - writer.Write(position); - writer.Write(", out "); - // A string local is declared nullable: the marshaller's out parameter is [MaybeNullWhen(false)], and the - // flow analysis knows it is not null once the read succeeded, so it flows into a 'string' parameter. - writer.Write(argument.CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(argument.Kind, true)); - writer.Write(' '); - writer.Write(ArgumentPrefix); - writer.Write(index.ToString(CultureInfo.InvariantCulture)); - writer.WriteLine("))"); - writer.OpenBlock(); - writer.Write("return "); - writer.Write(LuaApiNames.LuaThunk); - writer.Write(".FailBadArgument("); - writer.Write(State); - writer.Write(", "); - writer.Write(position); - writer.Write(", "); - writer.Write(CSharpLiteral.ToUtf8Literal(argument.ExpectedArgumentTypeName)); - writer.WriteLine(");"); - writer.CloseBlock(); - } - - // The call, its result pushed as the single Lua result. - private static void WriteCallAndResult(SourceWriter writer, LuaThunkModel model) - { - if (model.HasReturn) - { - // A string target may return null (pushed as nil), whatever its annotation says. - writer.Write(model.ReturnTypeName); - writer.Write(' '); - writer.Write(Result); - writer.Write(" = "); - } - - writer.Write(model.TargetMethod); - writer.Write('('); - var first = true; - if (model.PassesState) - { - writer.Write(State); - first = false; - } - - for (var i = 0; i < model.Arguments.Length; i++) - { - if (!first) writer.Write(", "); - - first = false; - writer.Write(ArgumentPrefix); - writer.Write(i.ToString(CultureInfo.InvariantCulture)); - } - - writer.WriteLine(");"); - - if (model.HasReturn) - { - writer.Write(model.ReturnMarshallerTypeName); - writer.Write(".Push("); - writer.Write(State); - writer.Write(", "); - writer.Write(Result); - writer.WriteLine(");"); - writer.WriteLine("return 1;"); - } - else - { - writer.WriteLine("return 0;"); - } - } - - private static void WriteCatchAll(SourceWriter writer) - { - writer.Write("catch ("); - writer.Write(LuaApiNames.Exception); - writer.Write(' '); - writer.Write(Exception); - writer.WriteLine(")"); - writer.OpenBlock(); - writer.Write("return "); - writer.Write(LuaApiNames.LuaThunk); - writer.Write(".Fail("); - writer.Write(State); - writer.Write(", "); - writer.Write(Exception); - writer.WriteLine(");"); - writer.CloseBlock(); - } + private const string Handle = "__handle"; + private const string State = "__L"; + private const string ArgumentPrefix = "__arg"; + private const string Result = "__result"; + private const string Exception = "__exception"; + + /// Writes the attribute, signature and body of the thunk at the writer's current indentation. + public static void Emit(SourceWriter writer, LuaThunkModel model) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (model is null) + { + throw new ArgumentNullException(nameof(model)); + } + + writer.WriteLine(LuaApiNames.UnmanagedCallersOnlyCdecl); + writer.Write("private static int "); + writer.Write(model.ThunkMethodName); + writer.Write("(nint "); + writer.Write(Handle); + writer.WriteLine(")"); + writer.OpenBlock(); + + writer.Write(LuaApiNames.LuaState); + writer.Write(' '); + writer.Write(State); + writer.Write(" = new("); + writer.Write(Handle); + writer.WriteLine(");"); + writer.WriteLine("try"); + writer.OpenBlock(); + WriteArgumentCountCheck(writer, model); + for (int i = 0; i < model.Arguments.Length; i++) + { + writer.WriteLine(); + WriteArgumentRead(writer, model.Arguments[i], i); + } + + writer.WriteLine(); + WriteCallAndResult(writer, model); + writer.CloseBlock(); + WriteCatchAll(writer); + + writer.CloseBlock(); + } + + /// The message a thunk reports when it is called with the wrong number of arguments. + public static string WrongArgumentCountMessage(string luaName, int expected) + { + return "wrong number of arguments to '" + luaName + "' (" + expected.ToString(CultureInfo.InvariantCulture) + + " expected)"; + } + + // The count check first, so that a missing argument and a surplus one get the same, complete message. + private static void WriteArgumentCountCheck(SourceWriter writer, LuaThunkModel model) + { + int count = model.Arguments.Length; + writer.Write("if ("); + writer.Write(State); + writer.Write(".Top != "); + writer.Write(count.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(")"); + writer.OpenBlock(); + writer.Write("return "); + writer.Write(LuaApiNames.LuaThunk); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(CSharpLiteral.ToUtf8Literal(WrongArgumentCountMessage(model.LuaName, count))); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + // One read per argument, at its 1-based stack index, which is also the argument number in the message. + private static void WriteArgumentRead(SourceWriter writer, LuaArgumentModel argument, int index) + { + string position = (index + 1).ToString(CultureInfo.InvariantCulture); + writer.Write("if (!"); + writer.Write(argument.GeneratedMarshallerTypeName); + writer.Write(".TryRead("); + writer.Write(State); + writer.Write(", "); + writer.Write(position); + writer.Write(", out "); + // A string local is declared nullable: the marshaller's out parameter is [MaybeNullWhen(false)], and the + // flow analysis knows it is not null once the read succeeded, so it flows into a 'string' parameter. + writer.Write(argument.CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(argument.Kind, true)); + writer.Write(' '); + writer.Write(ArgumentPrefix); + writer.Write(index.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine("))"); + writer.OpenBlock(); + writer.Write("return "); + writer.Write(LuaApiNames.LuaThunk); + writer.Write(".FailBadArgument("); + writer.Write(State); + writer.Write(", "); + writer.Write(position); + writer.Write(", "); + writer.Write(CSharpLiteral.ToUtf8Literal(argument.ExpectedArgumentTypeName)); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + // The call, its result pushed as the single Lua result. + private static void WriteCallAndResult(SourceWriter writer, LuaThunkModel model) + { + if (model.HasReturn) + { + // A string target may return null (pushed as nil), whatever its annotation says. + writer.Write(model.ReturnTypeName); + writer.Write(' '); + writer.Write(Result); + writer.Write(" = "); + } + + writer.Write(model.TargetMethod); + writer.Write('('); + bool first = true; + if (model.PassesState) + { + writer.Write(State); + first = false; + } + + for (int i = 0; i < model.Arguments.Length; i++) + { + if (!first) + { + writer.Write(", "); + } + + first = false; + writer.Write(ArgumentPrefix); + writer.Write(i.ToString(CultureInfo.InvariantCulture)); + } + + writer.WriteLine(");"); + + if (model.HasReturn) + { + writer.Write(model.ReturnMarshallerTypeName); + writer.Write(".Push("); + writer.Write(State); + writer.Write(", "); + writer.Write(Result); + writer.WriteLine(");"); + writer.WriteLine("return 1;"); + } + else + { + writer.WriteLine("return 0;"); + } + } + + private static void WriteCatchAll(SourceWriter writer) + { + writer.Write("catch ("); + writer.Write(LuaApiNames.Exception); + writer.Write(' '); + writer.Write(Exception); + writer.WriteLine(")"); + writer.OpenBlock(); + writer.Write("return "); + writer.Write(LuaApiNames.LuaThunk); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Exception); + writer.WriteLine(");"); + writer.CloseBlock(); + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs index 3cf9d9ed..aca4152d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs @@ -31,40 +31,41 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// selects a built-in marshaller. /// internal sealed record LuaThunkModel( - string LuaName, - string ThunkMethodName, - string TargetMethod, - bool PassesState, - EquatableArray Arguments, - LuaValueKind? ReturnKind, - string DeclaredDiagnosticIds = "", - LuaCustomMarshallerModel? ReturnMarshaller = null) + string LuaName, + string ThunkMethodName, + string TargetMethod, + bool PassesState, + EquatableArray Arguments, + LuaValueKind? ReturnKind, + string DeclaredDiagnosticIds = "", + LuaCustomMarshallerModel? ReturnMarshaller = null) { - /// Initializes a thunk model with the pre-custom-marshaller binary shape. - public LuaThunkModel(string luaName, string thunkMethodName, string targetMethod, bool passesState, - EquatableArray arguments, LuaValueKind? returnKind, string declaredDiagnosticIds) - : this(luaName, thunkMethodName, targetMethod, passesState, arguments, returnKind, declaredDiagnosticIds, null) - { - } + /// Prefix of every generated thunk name. + public const string ThunkPrefix = "__LuaThunk_"; - /// Whether the target returns one Lua value. - public bool HasReturn => ReturnKind is not null || ReturnMarshaller is not null; + /// Initializes a thunk model with the pre-custom-marshaller binary shape. + public LuaThunkModel(string luaName, string thunkMethodName, string targetMethod, bool passesState, + EquatableArray arguments, LuaValueKind? returnKind, string declaredDiagnosticIds) + : this(luaName, thunkMethodName, targetMethod, passesState, arguments, returnKind, declaredDiagnosticIds, null) + { + } - /// The concrete static marshaller for the return value. - public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? - LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); + /// Whether the target returns one Lua value. + public bool HasReturn => ReturnKind is not null || ReturnMarshaller is not null; - /// The C# type spelling for the generated result local. - public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(ReturnKind!.Value, true); - /// Prefix of every generated thunk name. - public const string ThunkPrefix = "__LuaThunk_"; + /// The concrete static marshaller for the return value. + public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? + LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); - /// - /// The thunk name for : + the name (a Lua name is a C# - /// identifier). - /// - public static string ThunkNameFor(string luaName) - { - return ThunkPrefix + luaName; - } + /// The C# type spelling for the generated result local. + public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(ReturnKind!.Value, true); + + /// + /// The thunk name for : + the name (a Lua name is a C# + /// identifier). + /// + public static string ThunkNameFor(string luaName) + { + return ThunkPrefix + luaName; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKind.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKind.cs index a23962d1..e54b1f7f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKind.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKind.cs @@ -12,36 +12,36 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// internal enum LuaValueKind { - /// through Int32Marshaller: a Lua integer that fits 32 bits. - Int32, - - /// through Int64Marshaller: a Lua integer. - Int64, - - /// through SingleMarshaller: a Lua number. - Single, - - /// through DoubleMarshaller: a Lua number. - Double, - - /// - /// through BooleanMarshaller: a Lua boolean (strict; nil is not - /// ). - /// - Boolean, - - /// - /// through AddressMarshaller: a target-process address as a Lua integer, bits - /// reinterpreted. - /// - Address, - - /// - /// ReadOnlySpan<byte> (UTF-8) through Utf8Marshaller. Arguments only: a span read from a - /// result would point into a Lua string the wrapper pops before returning. - /// - Utf8, - - /// through StringMarshaller: the allocating convenience. - String + /// through Int32Marshaller: a Lua integer that fits 32 bits. + Int32, + + /// through Int64Marshaller: a Lua integer. + Int64, + + /// through SingleMarshaller: a Lua number. + Single, + + /// through DoubleMarshaller: a Lua number. + Double, + + /// + /// through BooleanMarshaller: a Lua boolean (strict; nil is not + /// ). + /// + Boolean, + + /// + /// through AddressMarshaller: a target-process address as a Lua integer, bits + /// reinterpreted. + /// + Address, + + /// + /// ReadOnlySpan<byte> (UTF-8) through Utf8Marshaller. Arguments only: a span read from a + /// result would point into a Lua string the wrapper pops before returning. + /// + Utf8, + + /// through StringMarshaller: the allocating convenience. + String } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs index 41c01f87..2fcc7cf9 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs @@ -17,97 +17,97 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// internal static class LuaValueKinds { - /// The global::-qualified marshaller that pushes and reads values of . - public static string MarshallerTypeName(LuaValueKind kind) - { - return kind switch - { - LuaValueKind.Int32 => LuaApiNames.Int32Marshaller, - LuaValueKind.Int64 => LuaApiNames.Int64Marshaller, - LuaValueKind.Single => LuaApiNames.SingleMarshaller, - LuaValueKind.Double => LuaApiNames.DoubleMarshaller, - LuaValueKind.Boolean => LuaApiNames.BooleanMarshaller, - LuaValueKind.Address => LuaApiNames.AddressMarshaller, - LuaValueKind.Utf8 => LuaApiNames.Utf8Marshaller, - LuaValueKind.String => LuaApiNames.StringMarshaller, - _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }; - } + /// The global::-qualified marshaller that pushes and reads values of . + public static string MarshallerTypeName(LuaValueKind kind) + { + return kind switch + { + LuaValueKind.Int32 => LuaApiNames.Int32Marshaller, + LuaValueKind.Int64 => LuaApiNames.Int64Marshaller, + LuaValueKind.Single => LuaApiNames.SingleMarshaller, + LuaValueKind.Double => LuaApiNames.DoubleMarshaller, + LuaValueKind.Boolean => LuaApiNames.BooleanMarshaller, + LuaValueKind.Address => LuaApiNames.AddressMarshaller, + LuaValueKind.Utf8 => LuaApiNames.Utf8Marshaller, + LuaValueKind.String => LuaApiNames.StringMarshaller, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } - /// - /// The C# type as generated code spells it: the keyword for the primitives (, - /// , ...), - /// global::-qualified for the span, or ? for text. - /// - /// The kind. - /// - /// For : whether the declaration wrote string?. Ignored - /// for the other kinds. - /// - public static string TypeName(LuaValueKind kind, bool isNullable = false) - { - return kind switch - { - LuaValueKind.Int32 => "int", - LuaValueKind.Int64 => "long", - LuaValueKind.Single => "float", - LuaValueKind.Double => "double", - LuaValueKind.Boolean => "bool", - LuaValueKind.Address => "nuint", - LuaValueKind.Utf8 => LuaApiNames.ReadOnlySpanOfByte, - LuaValueKind.String => isNullable ? "string?" : "string", - _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }; - } + /// + /// The C# type as generated code spells it: the keyword for the primitives (, + /// , ...), + /// global::-qualified for the span, or ? for text. + /// + /// The kind. + /// + /// For : whether the declaration wrote string?. Ignored + /// for the other kinds. + /// + public static string TypeName(LuaValueKind kind, bool isNullable = false) + { + return kind switch + { + LuaValueKind.Int32 => "int", + LuaValueKind.Int64 => "long", + LuaValueKind.Single => "float", + LuaValueKind.Double => "double", + LuaValueKind.Boolean => "bool", + LuaValueKind.Address => "nuint", + LuaValueKind.Utf8 => LuaApiNames.ReadOnlySpanOfByte, + LuaValueKind.String => isNullable ? "string?" : "string", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } - /// - /// The Lua type a thunk expects for an argument of this kind, in Lua's own words - /// (integer, number, boolean, - /// string). - /// - public static string ExpectedArgument(LuaValueKind kind) - { - return kind switch - { - LuaValueKind.Int32 or LuaValueKind.Int64 or LuaValueKind.Address => "integer", - LuaValueKind.Single or LuaValueKind.Double => "number", - LuaValueKind.Boolean => "boolean", - LuaValueKind.Utf8 or LuaValueKind.String => "string", - _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }; - } + /// + /// The Lua type a thunk expects for an argument of this kind, in Lua's own words + /// (integer, number, boolean, + /// string). + /// + public static string ExpectedArgument(LuaValueKind kind) + { + return kind switch + { + LuaValueKind.Int32 or LuaValueKind.Int64 or LuaValueKind.Address => "integer", + LuaValueKind.Single or LuaValueKind.Double => "number", + LuaValueKind.Boolean => "boolean", + LuaValueKind.Utf8 or LuaValueKind.String => "string", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } - /// - /// What a throwing wrapper expected from a result of this kind, with its article (an integer, - /// a number, a boolean, a string). - /// - public static string ExpectedResult(LuaValueKind kind) - { - return kind switch - { - LuaValueKind.Int32 or LuaValueKind.Int64 or LuaValueKind.Address => "an integer", - LuaValueKind.Single or LuaValueKind.Double => "a number", - LuaValueKind.Boolean => "a boolean", - LuaValueKind.Utf8 or LuaValueKind.String => "a string", - _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }; - } + /// + /// What a throwing wrapper expected from a result of this kind, with its article (an integer, + /// a number, a boolean, a string). + /// + public static string ExpectedResult(LuaValueKind kind) + { + return kind switch + { + LuaValueKind.Int32 or LuaValueKind.Int64 or LuaValueKind.Address => "an integer", + LuaValueKind.Single or LuaValueKind.Double => "a number", + LuaValueKind.Boolean => "a boolean", + LuaValueKind.Utf8 or LuaValueKind.String => "a string", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } - /// - /// Whether a wrapper may return a value of this kind. may not: the span would - /// point into a Lua string that the wrapper pops before it returns. - /// - public static bool CanBeResult(LuaValueKind kind) - { - return kind != LuaValueKind.Utf8; - } + /// + /// Whether a wrapper may return a value of this kind. may not: the span would + /// point into a Lua string that the wrapper pops before it returns. + /// + public static bool CanBeResult(LuaValueKind kind) + { + return kind != LuaValueKind.Utf8; + } - /// - /// Whether values of this kind are reference types, whose defaulting in generated code needs default! - /// under nullable analysis. - /// - public static bool IsReferenceType(LuaValueKind kind) - { - return kind == LuaValueKind.String; - } + /// + /// Whether values of this kind are reference types, whose defaulting in generated code needs default! + /// under nullable analysis. + /// + public static bool IsReferenceType(LuaValueKind kind) + { + return kind == LuaValueKind.String; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/ManagedEntryPointNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/ManagedEntryPointNames.cs index a3e02aa2..9e36175c 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/ManagedEntryPointNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/ManagedEntryPointNames.cs @@ -18,18 +18,18 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests). /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class ManagedEntryPointNames { - /// Namespace of the bootstrap type, as written into the emitted namespace declaration. - public const string Namespace = "CESDK"; + /// Namespace of the bootstrap type, as written into the emitted namespace declaration. + public const string Namespace = "CESDK"; - /// Simple name of the bootstrap type, as written into the emitted class declaration. - public const string TypeName = "CESDK"; + /// Simple name of the bootstrap type, as written into the emitted class declaration. + public const string TypeName = "CESDK"; - /// Name of the public static bootstrap method the host calls. - public const string MethodName = "CEPluginInitialize"; + /// Name of the public static bootstrap method the host calls. + public const string MethodName = "CEPluginInitialize"; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs index 804e8d82..dbd0ab2a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.Shared.Shapes; @@ -31,272 +32,355 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.Shapes; /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class PluginShape { - // Namespace and type name of the entry point that Cheat Engine looks up: 'CESDK.CESDK'. - private const string ReservedName = "CESDK"; - - // The fallback spelling of the plugin base type, retained only for consumers not yet upgraded to the symbol-aware - // overload. New consumers must pass the actual SDK assembly symbol. - private const string PluginBaseName = "CheatEnginePlugin"; - private const string HostingNamespaceName = "Hosting"; - private const string PluginNamespaceName = "Plugin"; - private const string SdkNamespaceName = "SDK"; - private const string SdkRootNamespaceName = "CheatEngine"; - - /// Inspects ; never throws on malformed (error) symbols. - /// A class that carries the plugin attribute. - /// - /// The application of the plugin attribute on , or when the - /// caller could not find one (read as "no usable name"). - /// - /// - /// The resolved System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute, or - /// when the compilation has none, in which case no constructor counts as setting the required members. - /// - /// - /// The resolved System.ObsoleteAttribute, or when the compilation has none, in - /// which case obsolete errors are not looked for. - /// - /// - /// The attribute's name argument, verbatim; empty when it is missing, not a constant string, or the argument - /// list does not have exactly one argument. - /// - public static PluginShapeIssues Inspect( - INamedTypeSymbol type, - AttributeData? attribute, - INamedTypeSymbol? setsRequiredMembersAttribute, - INamedTypeSymbol? obsoleteAttribute, - out string displayName) - { - return Inspect( - type, - attribute, - pluginBase: null, - setsRequiredMembersAttribute, - obsoleteAttribute, - out displayName, - out _); - } - - /// - /// Inspects against the actual SDK plugin-base symbol and returns the exact - /// zero-parameter constructor the generated new T() expression names. - /// - /// The class carrying the SDK plugin marker. - /// The marker application on , or . - /// - /// The resolved CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin symbol. When , - /// the legacy structural fallback is used only for compatibility with consumers not yet upgraded to this - /// overload. - /// - /// - /// The resolved BCL SetsRequiredMembersAttribute, or when unavailable. - /// - /// - /// The resolved BCL ObsoleteAttribute, or when unavailable. - /// - /// The marker's display name; empty when its argument is unusable. - /// - /// The actual zero-parameter instance constructor selected by the generated expression, including an implicit - /// constructor; otherwise . Constructors whose parameters are optional or - /// are deliberately not selected. - /// - public static PluginShapeIssues Inspect( - INamedTypeSymbol type, - AttributeData? attribute, - INamedTypeSymbol? pluginBase, - INamedTypeSymbol? setsRequiredMembersAttribute, - INamedTypeSymbol? obsoleteAttribute, - out string displayName, - out IMethodSymbol? parameterlessConstructor) - { - displayName = ReadDisplayName(attribute); - var issues = string.IsNullOrWhiteSpace(displayName) ? PluginShapeIssues.InvalidName : PluginShapeIssues.None; - parameterlessConstructor = null; - - if (type.IsStatic) - // A static class is also abstract and sealed in metadata, has no base class and no instance - // constructor: one message instead of four. - return issues | PluginShapeIssues.Static; - - if (type.IsAbstract) issues |= PluginShapeIssues.Abstract; - - if (type.Arity > 0) issues |= PluginShapeIssues.Generic; - - if (type.ContainingType is { IsGenericType: true }) issues |= PluginShapeIssues.NestedInGeneric; - - if (!DerivesFromPluginBase(type, pluginBase)) issues |= PluginShapeIssues.NotDerivedFromPluginBase; - - for (var current = type; current is not null; current = current.ContainingType) - { - if (!IsAssemblyWide(current.DeclaredAccessibility)) issues |= PluginShapeIssues.Inaccessible; - - if (current.IsFileLocal) issues |= PluginShapeIssues.FileLocal; - - // 'new global::Outer.Plugin()' names every type of the chain. - if (IsObsoleteError(current, obsoleteAttribute)) issues |= PluginShapeIssues.ObsoleteError; - } - - if (IsOrIsNestedInEntryPointType(type)) issues |= PluginShapeIssues.ReservedEntryPointName; - - return issues | InspectConstructors( - type, - setsRequiredMembersAttribute, - obsoleteAttribute, - out parameterlessConstructor); - } - - // A missing or mistyped argument is a compiler error already (CS7036, CS1503); a well-formed but unusable name - // (null, empty, white space) is what the shape check reports. - private static string ReadDisplayName(AttributeData? attribute) - { - if (attribute is null || attribute.ConstructorArguments.Length != 1) return string.Empty; - - var name = attribute.ConstructorArguments[0]; - return name is { Kind: TypedConstantKind.Primitive, Value: string text } ? text : string.Empty; - } - - // The generated file declares the top-level type 'CESDK.CESDK' (the host looks it up by that name). An outermost - // type of the author with that very name would make it a second declaration (CS0101), so the class cannot be - // constructed from a generated file. Nothing wider is reserved: 'Demo.CESDK' and 'CESDK.Samples.CESDK' are other types. - private static bool IsOrIsNestedInEntryPointType(INamedTypeSymbol type) - { - var outermost = type; - while (outermost.ContainingType is { } containing) outermost = containing; - - return outermost is - { - Name: ReservedName, Arity: 0, - ContainingNamespace: { Name: ReservedName, ContainingNamespace.IsGlobalNamespace: true } - }; - } - - private static bool DerivesFromPluginBase(INamedTypeSymbol type, INamedTypeSymbol? pluginBase) - { - for (var current = type.BaseType; current is not null; current = current.BaseType) - if (pluginBase is null - ? IsPluginBaseFallback(current) - : SymbolEqualityComparer.Default.Equals(current, pluginBase)) - return true; - - return false; - } - - private static bool IsPluginBaseFallback(INamedTypeSymbol type) - { - return type is - { - Name: PluginBaseName, Arity: 0, ContainingType: null, ContainingNamespace: - { - Name: PluginNamespaceName, - ContainingNamespace: - { - Name: HostingNamespaceName, - ContainingNamespace: - { - Name: SdkNamespaceName, - ContainingNamespace: - { - Name: SdkRootNamespaceName, - ContainingNamespace.IsGlobalNamespace: true, - }, - } - } - } - }; - } - - private static PluginShapeIssues InspectConstructors( - INamedTypeSymbol type, - INamedTypeSymbol? setsRequiredMembersAttribute, - INamedTypeSymbol? obsoleteAttribute, - out IMethodSymbol? parameterlessConstructor) - { - // The generated factory has an explicit contract: it invokes a real parameterless constructor. C# permits an - // empty argument list to bind to optional or params parameters, but accepting that broadens a construction - // contract that cannot be represented in the generated factory's documentation or lifecycle model. An - // implicitly declared zero-parameter constructor is a real constructor and is accepted. - IMethodSymbol? accessible = null; - IMethodSymbol? inaccessible = null; - foreach (var constructor in type.InstanceConstructors) - { - if (!constructor.Parameters.IsEmpty) continue; - - // The implicit constructor is public, except on an abstract class (protected), which is reported as - // Abstract: once 'abstract' is gone the implicit constructor is public again. - if (constructor.IsImplicitlyDeclared || IsAssemblyWide(constructor.DeclaredAccessibility)) - accessible ??= constructor; - else - inaccessible ??= constructor; - } - - parameterlessConstructor = accessible ?? inaccessible; - var issues = parameterlessConstructor switch - { - null => PluginShapeIssues.MissingParameterlessConstructor, - _ when accessible is null => PluginShapeIssues.InaccessibleParameterlessConstructor, - _ => PluginShapeIssues.None - }; - - var setsRequiredMembers = false; - if (parameterlessConstructor is not null) - { - if (IsObsoleteError(parameterlessConstructor, obsoleteAttribute)) issues |= PluginShapeIssues.ObsoleteError; - - setsRequiredMembers = HasAttribute(parameterlessConstructor, setsRequiredMembersAttribute); - } - - // 'new T()' has no object initializer: required members make it CS9035 unless the constructor it binds to - // promises to set them. Reported next to a missing constructor too: adding a plain one would not be enough. - if (!setsRequiredMembers && HasRequiredMembers(type)) issues |= PluginShapeIssues.RequiredMembers; - - return issues; - } - - // Required members are inherited: the whole base-class chain counts. - private static bool HasRequiredMembers(INamedTypeSymbol type) - { - for (var current = type; current is not null; current = current.BaseType) - foreach (var member in current.GetMembers()) - if (member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true }) - return true; - - return false; - } - - // [Obsolete(message, error: true)]: the only constructor of the attribute with a second argument. - private static bool IsObsoleteError(ISymbol symbol, INamedTypeSymbol? obsoleteAttribute) - { - if (obsoleteAttribute is null) return false; - - foreach (var attribute in symbol.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute) - && attribute.ConstructorArguments is [_, { Value: true } _]) - return true; - - return false; - } - - private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol? attributeClass) - { - if (attributeClass is null) return false; - - foreach (var attribute in symbol.GetAttributes()) - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) - return true; - - return false; - } - - // The generated factory is a top-level type of the same assembly with no inheritance relation to the plugin: - // 'internal' is the minimum at every nesting level, and 'protected internal' grants it. - private static bool IsAssemblyWide(Accessibility accessibility) - { - return accessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; - } + // Namespace and type name of the entry point that Cheat Engine looks up: 'CESDK.CESDK'. + private const string ReservedName = "CESDK"; + + // The fallback spelling of the plugin base type, retained only for consumers not yet upgraded to the symbol-aware + // overload. New consumers must pass the actual SDK assembly symbol. + private const string PluginBaseName = "CheatEnginePlugin"; + private const string HostingNamespaceName = "Hosting"; + private const string PluginNamespaceName = "Plugin"; + private const string SdkNamespaceName = "SDK"; + private const string SdkRootNamespaceName = "CheatEngine"; + + /// Inspects ; never throws on malformed (error) symbols. + /// A class that carries the plugin attribute. + /// + /// The application of the plugin attribute on , or when the + /// caller could not find one (read as "no usable name"). + /// + /// + /// The resolved System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute, or + /// when the compilation has none, in which case no constructor counts as setting the required members. + /// + /// + /// The resolved System.ObsoleteAttribute, or when the compilation has none, in + /// which case obsolete errors are not looked for. + /// + /// + /// The attribute's name argument, verbatim; empty when it is missing, not a constant string, or the argument + /// list does not have exactly one argument. + /// + public static PluginShapeIssues Inspect( + INamedTypeSymbol type, + AttributeData? attribute, + INamedTypeSymbol? setsRequiredMembersAttribute, + INamedTypeSymbol? obsoleteAttribute, + out string displayName) + { + return Inspect( + type, + attribute, + null, + setsRequiredMembersAttribute, + obsoleteAttribute, + out displayName, + out _); + } + + /// + /// Inspects against the actual SDK plugin-base symbol and returns the exact + /// zero-parameter constructor the generated new T() expression names. + /// + /// The class carrying the SDK plugin marker. + /// The marker application on , or . + /// + /// The resolved CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin symbol. When , + /// the legacy structural fallback is used only for compatibility with consumers not yet upgraded to this + /// overload. + /// + /// + /// The resolved BCL SetsRequiredMembersAttribute, or when unavailable. + /// + /// + /// The resolved BCL ObsoleteAttribute, or when unavailable. + /// + /// The marker's display name; empty when its argument is unusable. + /// + /// The actual zero-parameter instance constructor selected by the generated expression, including an implicit + /// constructor; otherwise . Constructors whose parameters are optional or + /// are deliberately not selected. + /// + public static PluginShapeIssues Inspect( + INamedTypeSymbol type, + AttributeData? attribute, + INamedTypeSymbol? pluginBase, + INamedTypeSymbol? setsRequiredMembersAttribute, + INamedTypeSymbol? obsoleteAttribute, + out string displayName, + out IMethodSymbol? parameterlessConstructor) + { + displayName = ReadDisplayName(attribute); + PluginShapeIssues issues = string.IsNullOrWhiteSpace(displayName) + ? PluginShapeIssues.InvalidName + : PluginShapeIssues.None; + parameterlessConstructor = null; + + if (type.IsStatic) + // A static class is also abstract and sealed in metadata, has no base class and no instance + // constructor: one message instead of four. + { + return issues | PluginShapeIssues.Static; + } + + issues |= InspectTypeShape(type, pluginBase, obsoleteAttribute); + + return issues | InspectConstructors( + type, + setsRequiredMembersAttribute, + obsoleteAttribute, + out parameterlessConstructor); + } + + private static PluginShapeIssues InspectTypeShape( + INamedTypeSymbol type, + INamedTypeSymbol? pluginBase, + INamedTypeSymbol? obsoleteAttribute) + { + PluginShapeIssues issues = PluginShapeIssues.None; + + if (type.IsAbstract) + { + issues |= PluginShapeIssues.Abstract; + } + + if (type.Arity > 0) + { + issues |= PluginShapeIssues.Generic; + } + + if (type.ContainingType is { IsGenericType: true }) + { + issues |= PluginShapeIssues.NestedInGeneric; + } + + if (!DerivesFromPluginBase(type, pluginBase)) + { + issues |= PluginShapeIssues.NotDerivedFromPluginBase; + } + + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + if (!IsAssemblyWide(current.DeclaredAccessibility)) + { + issues |= PluginShapeIssues.Inaccessible; + } + + if (current.IsFileLocal) + { + issues |= PluginShapeIssues.FileLocal; + } + + // 'new global::Outer.Plugin()' names every type of the chain. + if (IsObsoleteError(current, obsoleteAttribute)) + { + issues |= PluginShapeIssues.ObsoleteError; + } + } + + if (IsOrIsNestedInEntryPointType(type)) + { + issues |= PluginShapeIssues.ReservedEntryPointName; + } + + return issues; + } + + // A missing or mistyped argument is a compiler error already (CS7036, CS1503); a well-formed but unusable name + // (null, empty, white space) is what the shape check reports. + private static string ReadDisplayName(AttributeData? attribute) + { + if (attribute is null || attribute.ConstructorArguments.Length != 1) + { + return string.Empty; + } + + TypedConstant name = attribute.ConstructorArguments[0]; + return name is { Kind: TypedConstantKind.Primitive, Value: string text } ? text : string.Empty; + } + + // The generated file declares the top-level type 'CESDK.CESDK' (the host looks it up by that name). An outermost + // type of the author with that very name would make it a second declaration (CS0101), so the class cannot be + // constructed from a generated file. Nothing wider is reserved: 'Demo.CESDK' and 'CESDK.Samples.CESDK' are other types. + private static bool IsOrIsNestedInEntryPointType(INamedTypeSymbol type) + { + INamedTypeSymbol outermost = type; + while (outermost.ContainingType is { } containing) + { + outermost = containing; + } + + return outermost is + { + Name: ReservedName, Arity: 0, + ContainingNamespace: { Name: ReservedName, ContainingNamespace.IsGlobalNamespace: true } + }; + } + + private static bool DerivesFromPluginBase(INamedTypeSymbol type, INamedTypeSymbol? pluginBase) + { + for (INamedTypeSymbol? current = type.BaseType; current is not null; current = current.BaseType) + { + if (pluginBase is null + ? IsPluginBaseFallback(current) + : SymbolEqualityComparer.Default.Equals(current, pluginBase)) + { + return true; + } + } + + return false; + } + + private static bool IsPluginBaseFallback(INamedTypeSymbol type) + { + return type is + { + Name: PluginBaseName, Arity: 0, ContainingType: null, ContainingNamespace: + { + Name: PluginNamespaceName, + ContainingNamespace: + { + Name: HostingNamespaceName, + ContainingNamespace: + { + Name: SdkNamespaceName, + ContainingNamespace: + { + Name: SdkRootNamespaceName, + ContainingNamespace.IsGlobalNamespace: true + } + } + } + } + }; + } + + private static PluginShapeIssues InspectConstructors( + INamedTypeSymbol type, + INamedTypeSymbol? setsRequiredMembersAttribute, + INamedTypeSymbol? obsoleteAttribute, + out IMethodSymbol? parameterlessConstructor) + { + // The generated factory has an explicit contract: it invokes a real parameterless constructor. C# permits an + // empty argument list to bind to optional or params parameters, but accepting that broadens a construction + // contract that cannot be represented in the generated factory's documentation or lifecycle model. An + // implicitly declared zero-parameter constructor is a real constructor and is accepted. + IMethodSymbol? accessible = null; + IMethodSymbol? inaccessible = null; + foreach (IMethodSymbol constructor in type.InstanceConstructors) + { + if (!constructor.Parameters.IsEmpty) + { + continue; + } + + // The implicit constructor is public, except on an abstract class (protected), which is reported as + // Abstract: once 'abstract' is gone the implicit constructor is public again. + if (constructor.IsImplicitlyDeclared || IsAssemblyWide(constructor.DeclaredAccessibility)) + { + accessible ??= constructor; + } + else + { + inaccessible ??= constructor; + } + } + + parameterlessConstructor = accessible ?? inaccessible; + PluginShapeIssues issues = parameterlessConstructor switch + { + null => PluginShapeIssues.MissingParameterlessConstructor, + _ when accessible is null => PluginShapeIssues.InaccessibleParameterlessConstructor, + _ => PluginShapeIssues.None + }; + + bool setsRequiredMembers = false; + if (parameterlessConstructor is not null) + { + if (IsObsoleteError(parameterlessConstructor, obsoleteAttribute)) + { + issues |= PluginShapeIssues.ObsoleteError; + } + + setsRequiredMembers = HasAttribute(parameterlessConstructor, setsRequiredMembersAttribute); + } + + // 'new T()' has no object initializer: required members make it CS9035 unless the constructor it binds to + // promises to set them. Reported next to a missing constructor too: adding a plain one would not be enough. + if (!setsRequiredMembers && HasRequiredMembers(type)) + { + issues |= PluginShapeIssues.RequiredMembers; + } + + return issues; + } + + // Required members are inherited: the whole base-class chain counts. + private static bool HasRequiredMembers(INamedTypeSymbol type) + { + for (INamedTypeSymbol? current = type; current is not null; current = current.BaseType) + { + foreach (ISymbol member in current.GetMembers()) + { + if (member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true }) + { + return true; + } + } + } + + return false; + } + + // [Obsolete(message, error: true)]: the only constructor of the attribute with a second argument. + private static bool IsObsoleteError(ISymbol symbol, INamedTypeSymbol? obsoleteAttribute) + { + if (obsoleteAttribute is null) + { + return false; + } + + foreach (AttributeData attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute) + && attribute.ConstructorArguments is [_, { Value: true } _]) + { + return true; + } + } + + return false; + } + + private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol? attributeClass) + { + if (attributeClass is null) + { + return false; + } + + foreach (AttributeData attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) + { + return true; + } + } + + return false; + } + + // The generated factory is a top-level type of the same assembly with no inheritance relation to the plugin: + // 'internal' is the minimum at every nesting level, and 'protected internal' grants it. + private static bool IsAssemblyWide(Accessibility accessibility) + { + return accessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShapeIssues.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShapeIssues.cs index 5519ffc7..32bf26fe 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShapeIssues.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShapeIssues.cs @@ -20,69 +20,69 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.Shapes; [Flags] internal enum PluginShapeIssues { - /// The class can be constructed by the generated factory. - None = 0, + /// The class can be constructed by the generated factory. + None = 0, - /// The class is . Its own bit: every other problem follows from it. - Static = 1 << 0, + /// The class is . Its own bit: every other problem follows from it. + Static = 1 << 0, - /// The class is . - Abstract = 1 << 1, + /// The class is . + Abstract = 1 << 1, - /// The class itself has type parameters. - Generic = 1 << 2, + /// The class itself has type parameters. + Generic = 1 << 2, - /// A type the class is nested in has type parameters. - NestedInGeneric = 1 << 3, + /// A type the class is nested in has type parameters. + NestedInGeneric = 1 << 3, - /// - /// No base class is the resolved CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin symbol in the base-type - /// chain. Current generators and analyzers pass that symbol from the referenced SDK assembly, so a same-named - /// source or foreign-reference type cannot impersonate it. The legacy structural fallback exists only for older - /// internal callers that have not supplied a symbol. - /// - NotDerivedFromPluginBase = 1 << 4, + /// + /// No base class is the resolved CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin symbol in the base-type + /// chain. Current generators and analyzers pass that symbol from the referenced SDK assembly, so a same-named + /// source or foreign-reference type cannot impersonate it. The legacy structural fallback exists only for older + /// internal callers that have not supplied a symbol. + /// + NotDerivedFromPluginBase = 1 << 4, - /// - /// The class, or a type it is nested in, is , or - /// : not reachable from a generated top-level type in another file. - /// - Inaccessible = 1 << 5, + /// + /// The class, or a type it is nested in, is , or + /// : not reachable from a generated top-level type in another file. + /// + Inaccessible = 1 << 5, - /// The class, or a type it is nested in, is a type: out of reach from another file. - FileLocal = 1 << 6, + /// The class, or a type it is nested in, is a type: out of reach from another file. + FileLocal = 1 << 6, - /// - /// Constructors are declared, and none of them has zero parameters. A constructor with optional or - /// parameters is not the explicit parameterless construction contract the generated - /// factory requires. - /// - MissingParameterlessConstructor = 1 << 7, + /// + /// Constructors are declared, and none of them has zero parameters. A constructor with optional or + /// parameters is not the explicit parameterless construction contract the generated + /// factory requires. + /// + MissingParameterlessConstructor = 1 << 7, - /// - /// A real zero-parameter constructor exists, but every such constructor is , - /// or . - /// - InaccessibleParameterlessConstructor = 1 << 8, + /// + /// A real zero-parameter constructor exists, but every such constructor is , + /// or . + /// + InaccessibleParameterlessConstructor = 1 << 8, - /// The display name given to the attribute is missing, , empty or white space. - InvalidName = 1 << 9, + /// The display name given to the attribute is missing, , empty or white space. + InvalidName = 1 << 9, - /// - /// The class or a base class declares members and the parameterless constructor is - /// not marked [SetsRequiredMembers]: new T() without an object initializer is CS9035. - /// - RequiredMembers = 1 << 10, + /// + /// The class or a base class declares members and the parameterless constructor is + /// not marked [SetsRequiredMembers]: new T() without an object initializer is CS9035. + /// + RequiredMembers = 1 << 10, - /// - /// The class, a type it is nested in, or its parameterless constructor is marked [Obsolete] with - /// error: true: naming it is CS0619, which no #pragma can silence. - /// - ObsoleteError = 1 << 11, + /// + /// The class, a type it is nested in, or its parameterless constructor is marked [Obsolete] with + /// error: true: naming it is CS0619, which no #pragma can silence. + /// + ObsoleteError = 1 << 11, - /// - /// The class is, or is nested in, the top-level type CESDK.CESDK: the name Cheat Engine dictates for the - /// generated entry point type, of which an assembly holds only one. - /// - ReservedEntryPointName = 1 << 12 + /// + /// The class is, or is nested in, the top-level type CESDK.CESDK: the name Cheat Engine dictates for the + /// generated entry point type, of which an assembly holds only one. + /// + ReservedEntryPointName = 1 << 12 } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/SourceWriter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/SourceWriter.cs index 295ed532..252b4cf5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/SourceWriter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/SourceWriter.cs @@ -1,5 +1,6 @@ using System; using System.Text; + using Microsoft.CodeAnalysis.Text; namespace CheatEngine.SDK.SourceGenerators.Shared; @@ -41,181 +42,196 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// internal sealed class SourceWriter { - /// Line terminator of all generated text. - public const char NewLine = '\n'; - - /// Spaces per indentation level. - public const int IndentSize = 4; - - private static readonly char[] LineBreakCharacters = ['\r', '\n']; - - private readonly StringBuilder _builder; - - // The last thing written was a line break caused by '\r': a '\n' that comes next completes the same CR LF pair - // and must not end a second line. Anything written in between (text, an explicit WriteLine) clears it. - private bool _afterCarriageReturn; - private bool _atLineStart = true; - - /// Creates a writer whose buffer starts at characters. - public SourceWriter(int capacity = 1024) - { - _builder = new StringBuilder(capacity); - } - - /// Current indentation level (0 = column 0). - public int IndentLevel { get; private set; } - - /// Number of characters written so far. - public int Length => _builder.Length; - - /// Increases the indentation of the lines that follow by one level. - public void Indent() - { - IndentLevel++; - } - - /// Decreases the indentation by one level. - /// The level is already 0: the emitter's blocks are unbalanced. - public void Unindent() - { - if (IndentLevel == 0) - throw new InvalidOperationException("Unbalanced indentation: Unindent() without a matching Indent()."); - - IndentLevel--; - } - - /// - /// Writes , indenting first when it starts a line. \n and \r end the line - /// instead (a \n directly after a \r belongs to the same line break). - /// - public void Write(char value) - { - if (value is '\r' or NewLine) - { - WriteLineBreakCharacter(value); - return; - } - - WriteIndentationIfNeeded(); - _builder.Append(value); - _afterCarriageReturn = false; - } - - /// - /// Writes . Embedded line breaks (\n, \r\n or a lone \r) end the - /// current line; each non-empty line is indented to the current level. - /// - public void Write(string text) - { - if (text is null) throw new ArgumentNullException(nameof(text)); - - var start = 0; - while (start < text.Length) - { - var lineBreak = text.IndexOfAny(LineBreakCharacters, start); - var end = lineBreak < 0 ? text.Length : lineBreak; - - if (end > start) - { - WriteIndentationIfNeeded(); - _builder.Append(text, start, end - start); - _afterCarriageReturn = false; - } - - if (lineBreak < 0) return; - - WriteLineBreakCharacter(text[lineBreak]); - start = lineBreak + 1; - } - } - - /// Ends the current line. On an empty line nothing but the terminator is written. - public void WriteLine() - { - _builder.Append(NewLine); - _atLineStart = true; - _afterCarriageReturn = false; - } - - /// Writes (see ) and ends the line. - public void WriteLine(string text) - { - Write(text); - WriteLine(); - } - - /// Writes { on its own line and indents what follows. - public void OpenBlock() - { - WriteLine("{"); - Indent(); - } - - /// Unindents and writes } on its own line. - public void CloseBlock() - { - Unindent(); - WriteLine("}"); - } - - /// - /// Unindents and writes } followed by (for example ; or );) on - /// its own line. - /// - public void CloseBlock(string suffix) - { - Unindent(); - Write('}'); - WriteLine(suffix); - } - - /// Empties the writer so that the buffer can be reused for another file. - public void Clear() - { - _builder.Clear(); - IndentLevel = 0; - _atLineStart = true; - _afterCarriageReturn = false; - } - - /// The text written so far. - public override string ToString() - { - return _builder.ToString(); - } - - /// - /// The text written so far as a UTF-8 , the form AddSource wants (an encoding is - /// required for the file to be embeddable in the PDB and to have a checksum). - /// - public SourceText ToSourceText() - { - return SourceText.From(_builder.ToString(), Encoding.UTF8); - } - - // '\r' ends the line and is remembered; '\n' ends the line unless it completes a CR LF pair that already did. - private void WriteLineBreakCharacter(char value) - { - if (value == '\r') - { - WriteLine(); - _afterCarriageReturn = true; - } - else if (_afterCarriageReturn) - { - _afterCarriageReturn = false; - } - else - { - WriteLine(); - } - } - - private void WriteIndentationIfNeeded() - { - if (!_atLineStart) return; - - _builder.Append(' ', IndentLevel * IndentSize); - _atLineStart = false; - } + /// Line terminator of all generated text. + public const char NewLine = '\n'; + + /// Spaces per indentation level. + public const int IndentSize = 4; + + private static readonly char[] LineBreakCharacters = ['\r', '\n']; + + private readonly StringBuilder _builder; + + // The last thing written was a line break caused by '\r': a '\n' that comes next completes the same CR LF pair + // and must not end a second line. Anything written in between (text, an explicit WriteLine) clears it. + private bool _afterCarriageReturn; + private bool _atLineStart = true; + + /// Creates a writer whose buffer starts at characters. + public SourceWriter(int capacity = 1024) + { + _builder = new StringBuilder(capacity); + } + + /// Current indentation level (0 = column 0). + public int IndentLevel + { + get; + private set; + } + + /// Number of characters written so far. + public int Length => _builder.Length; + + /// Increases the indentation of the lines that follow by one level. + public void Indent() + { + IndentLevel++; + } + + /// Decreases the indentation by one level. + /// The level is already 0: the emitter's blocks are unbalanced. + public void Unindent() + { + if (IndentLevel == 0) + { + throw new InvalidOperationException("Unbalanced indentation: Unindent() without a matching Indent()."); + } + + IndentLevel--; + } + + /// + /// Writes , indenting first when it starts a line. \n and \r end the line + /// instead (a \n directly after a \r belongs to the same line break). + /// + public void Write(char value) + { + if (value is '\r' or NewLine) + { + WriteLineBreakCharacter(value); + return; + } + + WriteIndentationIfNeeded(); + _builder.Append(value); + _afterCarriageReturn = false; + } + + /// + /// Writes . Embedded line breaks (\n, \r\n or a lone \r) end the + /// current line; each non-empty line is indented to the current level. + /// + public void Write(string text) + { + if (text is null) + { + throw new ArgumentNullException(nameof(text)); + } + + int start = 0; + while (start < text.Length) + { + int lineBreak = text.IndexOfAny(LineBreakCharacters, start); + int end = lineBreak < 0 ? text.Length : lineBreak; + + if (end > start) + { + WriteIndentationIfNeeded(); + _builder.Append(text, start, end - start); + _afterCarriageReturn = false; + } + + if (lineBreak < 0) + { + return; + } + + WriteLineBreakCharacter(text[lineBreak]); + start = lineBreak + 1; + } + } + + /// Ends the current line. On an empty line nothing but the terminator is written. + public void WriteLine() + { + _builder.Append(NewLine); + _atLineStart = true; + _afterCarriageReturn = false; + } + + /// Writes (see ) and ends the line. + public void WriteLine(string text) + { + Write(text); + WriteLine(); + } + + /// Writes { on its own line and indents what follows. + public void OpenBlock() + { + WriteLine("{"); + Indent(); + } + + /// Unindents and writes } on its own line. + public void CloseBlock() + { + Unindent(); + WriteLine("}"); + } + + /// + /// Unindents and writes } followed by (for example ; or );) on + /// its own line. + /// + public void CloseBlock(string suffix) + { + Unindent(); + Write('}'); + WriteLine(suffix); + } + + /// Empties the writer so that the buffer can be reused for another file. + public void Clear() + { + _builder.Clear(); + IndentLevel = 0; + _atLineStart = true; + _afterCarriageReturn = false; + } + + /// The text written so far. + public override string ToString() + { + return _builder.ToString(); + } + + /// + /// The text written so far as a UTF-8 , the form AddSource wants (an encoding is + /// required for the file to be embeddable in the PDB and to have a checksum). + /// + public SourceText ToSourceText() + { + return SourceText.From(_builder.ToString(), Encoding.UTF8); + } + + // '\r' ends the line and is remembered; '\n' ends the line unless it completes a CR LF pair that already did. + private void WriteLineBreakCharacter(char value) + { + if (value == '\r') + { + WriteLine(); + _afterCarriageReturn = true; + } + else if (_afterCarriageReturn) + { + _afterCarriageReturn = false; + } + else + { + WriteLine(); + } + } + + private void WriteIndentationIfNeeded() + { + if (!_atLineStart) + { + return; + } + + _builder.Append(' ', IndentLevel * IndentSize); + _atLineStart = false; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/TrackingNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/TrackingNames.cs index 4ebb1888..18502c98 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/TrackingNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/TrackingNames.cs @@ -26,18 +26,18 @@ namespace CheatEngine.SDK.SourceGenerators.Shared; /// /// [SuppressMessage( - "Meziantou.Analyzer", - "MA0182", - Justification = - "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] internal static class TrackingNames { - /// Prefix of every CheatEngine.SDK step name. - public const string Prefix = "CheatEngine.SDK."; + /// Prefix of every CheatEngine.SDK step name. + public const string Prefix = "CheatEngine.SDK."; - /// when follows the CheatEngine.SDK convention. - public static bool IsCheatEngineSdkStep(string? stepName) - { - return stepName is not null && stepName.StartsWith(Prefix, StringComparison.Ordinal); - } + /// when follows the CheatEngine.SDK convention. + public static bool IsCheatEngineSdkStep(string? stepName) + { + return stepName is not null && stepName.StartsWith(Prefix, StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/AbiArchitectureTests.cs b/tests/CheatEngine.SDK.Abi.Tests/AbiArchitectureTests.cs index 9486680c..35163ea4 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/AbiArchitectureTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/AbiArchitectureTests.cs @@ -4,45 +4,45 @@ namespace CheatEngine.SDK.Abi.Tests; public sealed class AbiArchitectureTests { - [Theory] - [InlineData(Architecture.X64, true)] - [InlineData(Architecture.X86, false)] - [InlineData(Architecture.Arm64, false)] - [InlineData(Architecture.Arm, false)] - public void IsSupportedArchitecture_is_true_for_x64_only(Architecture architecture, bool expected) - { - Assert.Equal(expected, AbiArchitecture.IsSupportedArchitecture(architecture)); - } - - [Fact] - public void IsSupported_reflects_the_architecture_of_the_current_process() - { - var expected = RuntimeInformation.ProcessArchitecture == Architecture.X64; - - Assert.Equal(expected, AbiArchitecture.IsSupported); - } - - [Fact] - public void ThrowIfUnsupported_for_x64_does_not_throw() - { - Assert.Null(Record.Exception(static () => AbiArchitecture.ThrowIfUnsupported(Architecture.X64))); - } - - [Fact] - public void ThrowIfUnsupported_for_another_architecture_throws_and_names_it() - { - var exception = - Assert.Throws(static () => - AbiArchitecture.ThrowIfUnsupported(Architecture.X86)); - - Assert.Contains("X86", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void ThrowIfUnsupported_for_the_current_process_agrees_with_IsSupported() - { - var exception = Record.Exception(AbiArchitecture.ThrowIfUnsupported); - - Assert.Equal(AbiArchitecture.IsSupported, exception is null); - } + [Theory] + [InlineData(Architecture.X64, true)] + [InlineData(Architecture.X86, false)] + [InlineData(Architecture.Arm64, false)] + [InlineData(Architecture.Arm, false)] + public void IsSupportedArchitecture_is_true_for_x64_only(Architecture architecture, bool expected) + { + Assert.Equal(expected, AbiArchitecture.IsSupportedArchitecture(architecture)); + } + + [Fact] + public void IsSupported_reflects_the_architecture_of_the_current_process() + { + bool expected = RuntimeInformation.ProcessArchitecture == Architecture.X64; + + Assert.Equal(expected, AbiArchitecture.IsSupported); + } + + [Fact] + public void ThrowIfUnsupported_for_x64_does_not_throw() + { + Assert.Null(Record.Exception(static () => AbiArchitecture.ThrowIfUnsupported(Architecture.X64))); + } + + [Fact] + public void ThrowIfUnsupported_for_another_architecture_throws_and_names_it() + { + PlatformNotSupportedException exception = + Assert.Throws(static () => + AbiArchitecture.ThrowIfUnsupported(Architecture.X86)); + + Assert.Contains("X86", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ThrowIfUnsupported_for_the_current_process_agrees_with_IsSupported() + { + Exception? exception = Record.Exception(AbiArchitecture.ThrowIfUnsupported); + + Assert.Equal(AbiArchitecture.IsSupported, exception is null); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/AbiConstantsTests.cs b/tests/CheatEngine.SDK.Abi.Tests/AbiConstantsTests.cs index 57aeacf6..65918f7a 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/AbiConstantsTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/AbiConstantsTests.cs @@ -5,22 +5,22 @@ namespace CheatEngine.SDK.Abi.Tests; public sealed class AbiConstantsTests { - [Fact] - public void SdkVersion_matches_upstream_sdk_is_6() - { - Assert.Equal(6, AbiConstants.SdkVersion); - } + [Fact] + public void SdkVersion_matches_upstream_sdk_is_6() + { + Assert.Equal(6, AbiConstants.SdkVersion); + } - [Fact] - public void SdkVersion_assigned_to_the_unsigned_version_fields_keeps_its_value() - { - PluginVersion version = default; - PluginInitRecord record = default; + [Fact] + public void SdkVersion_assigned_to_the_unsigned_version_fields_keeps_its_value() + { + PluginVersion version = default; + PluginInitRecord record = default; - version.Version = AbiConstants.SdkVersion; - record.Version = AbiConstants.SdkVersion; + version.Version = AbiConstants.SdkVersion; + record.Version = AbiConstants.SdkVersion; - Assert.Equal(6u, version.Version); - Assert.Equal(6u, record.Version); - } + Assert.Equal(6u, version.Version); + Assert.Equal(6u, record.Version); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs b/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs index a8415a43..46df847b 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -20,125 +21,133 @@ namespace CheatEngine.SDK.Abi.Tests; /// public sealed class AssemblyConformanceTests { - private static readonly Assembly AbiAssembly = typeof(PluginInitRecord).Assembly; - - /// Every public structure of the assembly with its 64-bit size. Adding a structure means adding a row. - private static readonly Dictionary ExpectedSizesOn64Bit = new(StringComparer.Ordinal) - { - [nameof(Bool32)] = 4, - [nameof(Bool8)] = 1, - [nameof(PluginInitRecord)] = 36, - [nameof(ManagedExportedFunctions)] = 48, - [nameof(PluginVersion)] = 16, - [nameof(AddressListPluginInit)] = 16, - [nameof(MemoryViewPluginInit)] = 24, - [nameof(DebugEventPluginInit)] = 8, - [nameof(DebugEventObservation)] = 24, - [nameof(ProcessWatcherPluginInit)] = 8, - [nameof(FunctionPointerChangePluginInit)] = 8, - [nameof(MainMenuPluginInit)] = 24, - [nameof(DisassemblerContextPluginInit)] = 32, - [nameof(DisassemblerRenderLinePluginInit)] = 8, - [nameof(AutoAssemblerPluginInit)] = 8 - }; - - [Fact] - public void Assembly_disables_runtime_marshalling() - { - Assert.NotNull(AbiAssembly.GetCustomAttribute()); - } - - [Fact] - public void Assembly_references_no_other_cheatengine_sdk_assembly() - { - var cheatEngineSdkReferences = AbiAssembly.GetReferencedAssemblies() - .Select(static name => name.Name ?? string.Empty) - .Where(static name => name.StartsWith("CheatEngine.SDK", StringComparison.Ordinal)) - .ToArray(); - - Assert.Empty(cheatEngineSdkReferences); - } - - [Fact] - public void Every_public_structure_is_listed_in_the_expected_size_table() - { - var actual = PublicStructures().Select(static type => type.Name).Order(StringComparer.Ordinal).ToArray(); - var expected = ExpectedSizesOn64Bit.Keys.Order(StringComparer.Ordinal).ToArray(); - - Assert.Equal(expected, actual); - } - - [Fact] - public void Every_public_structure_on_64_bit_has_the_expected_size() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - - Assert.All( - PublicStructures(), - static type => - { - Assert.True(ExpectedSizesOn64Bit.TryGetValue(type.Name, out var expected), - $"No expected size for {type.Name}."); - Assert.Equal(expected, RuntimeHelpers.SizeOf(type.TypeHandle)); - }); - } - - [Fact] - public void Every_structure_is_sequential_and_blittable_at_any_depth() - { - var structures = AllStructures().ToArray(); - - Assert.NotEmpty(structures); - Assert.All(structures, static type => Assert.Null(AbiShape.FindViolation(type, AbiAssembly))); - } - - [Fact] - public void Only_the_init_record_overrides_the_default_packing() - { - Assert.All( - AllStructures(), - static type => - { - // Reflection reports the default either as 0 or as the runtime's default of 8. - var pack = type.StructLayoutAttribute?.Pack ?? 0; - if (type == typeof(PluginInitRecord)) - Assert.Equal(1, pack); - else - Assert.True(pack is 0 or 8, - $"{type.Name} declares Pack = {pack.ToString(CultureInfo.InvariantCulture)}: only the init record is packed by the host."); - }); - } - - [Fact] - public void Every_public_enumeration_is_four_bytes_wide() - { - var enums = AbiAssembly.GetExportedTypes().Where(static type => type.IsEnum).ToArray(); - - Assert.NotEmpty(enums); - Assert.All(enums, static type => Assert.Equal(typeof(int), Enum.GetUnderlyingType(type))); - } - - private static IEnumerable PublicStructures() - { - return AbiAssembly.GetExportedTypes().Where(static type => type.IsValueType && !type.IsEnum); - } - - /// - /// Public and non-public structures alike: an internal helper structure is as much part of a layout as the - /// public structure that embeds it. Compiler-generated types (static data blobs) are not ours to judge. - /// - private static IEnumerable AllStructures() - { - return AbiAssembly.GetTypes() - .Where(static type => type.IsValueType && !type.IsEnum && !IsCompilerGenerated(type)); - } - - private static bool IsCompilerGenerated(Type type) - { - for (var current = type; current is not null; current = current.DeclaringType) - if (current.Name.StartsWith('<') || current.IsDefined(typeof(CompilerGeneratedAttribute), false)) - return true; - - return false; - } + private static readonly Assembly AbiAssembly = typeof(PluginInitRecord).Assembly; + + /// Every public structure of the assembly with its 64-bit size. Adding a structure means adding a row. + private static readonly Dictionary ExpectedSizesOn64Bit = new(StringComparer.Ordinal) + { + [nameof(Bool32)] = 4, + [nameof(Bool8)] = 1, + [nameof(PluginInitRecord)] = 36, + [nameof(ManagedExportedFunctions)] = 48, + [nameof(PluginVersion)] = 16, + [nameof(AddressListPluginInit)] = 16, + [nameof(MemoryViewPluginInit)] = 24, + [nameof(DebugEventPluginInit)] = 8, + [nameof(DebugEventObservation)] = 24, + [nameof(ProcessWatcherPluginInit)] = 8, + [nameof(FunctionPointerChangePluginInit)] = 8, + [nameof(MainMenuPluginInit)] = 24, + [nameof(DisassemblerContextPluginInit)] = 32, + [nameof(DisassemblerRenderLinePluginInit)] = 8, + [nameof(AutoAssemblerPluginInit)] = 8 + }; + + [Fact] + public void Assembly_disables_runtime_marshalling() + { + Assert.NotNull(AbiAssembly.GetCustomAttribute()); + } + + [Fact] + public void Assembly_references_no_other_cheatengine_sdk_assembly() + { + string[] cheatEngineSdkReferences = AbiAssembly.GetReferencedAssemblies() + .Select(static name => name.Name ?? string.Empty) + .Where(static name => name.StartsWith("CheatEngine.SDK", StringComparison.Ordinal)) + .ToArray(); + + Assert.Empty(cheatEngineSdkReferences); + } + + [Fact] + public void Every_public_structure_is_listed_in_the_expected_size_table() + { + string[] actual = PublicStructures().Select(static type => type.Name).Order(StringComparer.Ordinal).ToArray(); + string[] expected = ExpectedSizesOn64Bit.Keys.Order(StringComparer.Ordinal).ToArray(); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Every_public_structure_on_64_bit_has_the_expected_size() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.All( + PublicStructures(), + static type => + { + Assert.True(ExpectedSizesOn64Bit.TryGetValue(type.Name, out int expected), + $"No expected size for {type.Name}."); + Assert.Equal(expected, RuntimeHelpers.SizeOf(type.TypeHandle)); + }); + } + + [Fact] + public void Every_structure_is_sequential_and_blittable_at_any_depth() + { + Type[] structures = AllStructures().ToArray(); + + Assert.NotEmpty(structures); + Assert.All(structures, static type => Assert.Null(AbiShape.FindViolation(type, AbiAssembly))); + } + + [Fact] + public void Only_the_init_record_overrides_the_default_packing() + { + Assert.All( + AllStructures(), + static type => + { + // Reflection reports the default either as 0 or as the runtime's default of 8. + int pack = type.StructLayoutAttribute?.Pack ?? 0; + if (type == typeof(PluginInitRecord)) + { + Assert.Equal(1, pack); + } + else + { + Assert.True(pack is 0 or 8, + $"{type.Name} declares Pack = {pack.ToString(CultureInfo.InvariantCulture)}: only the init record is packed by the host."); + } + }); + } + + [Fact] + public void Every_public_enumeration_is_four_bytes_wide() + { + Type[] enums = AbiAssembly.GetExportedTypes().Where(static type => type.IsEnum).ToArray(); + + Assert.NotEmpty(enums); + Assert.All(enums, static type => Assert.Equal(typeof(int), Enum.GetUnderlyingType(type))); + } + + private static IEnumerable PublicStructures() + { + return AbiAssembly.GetExportedTypes().Where(static type => type.IsValueType && !type.IsEnum); + } + + /// + /// Public and non-public structures alike: an internal helper structure is as much part of a layout as the + /// public structure that embeds it. Compiler-generated types (static data blobs) are not ours to judge. + /// + private static IEnumerable AllStructures() + { + return AbiAssembly.GetTypes() + .Where(static type => type.IsValueType && !type.IsEnum && !IsCompilerGenerated(type)); + } + + private static bool IsCompilerGenerated(Type type) + { + for (Type? current = type; current is not null; current = current.DeclaringType) + { + if (current.Name.StartsWith('<') || current.IsDefined(typeof(CompilerGeneratedAttribute), false)) + { + return true; + } + } + + return false; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Bool32Tests.cs b/tests/CheatEngine.SDK.Abi.Tests/Bool32Tests.cs index 94854cbb..9d2a2d87 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Bool32Tests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Bool32Tests.cs @@ -4,79 +4,79 @@ namespace CheatEngine.SDK.Abi.Tests; public sealed class Bool32Tests { - [Fact] - public void Size_is_four_bytes_like_win32_BOOL() - { - Assert.Equal(4, Layout.SizeOf()); - } + [Fact] + public void Size_is_four_bytes_like_win32_BOOL() + { + Assert.Equal(4, Layout.SizeOf()); + } - [Fact] - public void True_and_False_have_the_canonical_raw_values() - { - Assert.Equal(1, Bool32.True.RawValue); - Assert.Equal(0, Bool32.False.RawValue); - Assert.Equal(0, default(Bool32).RawValue); - } + [Fact] + public void True_and_False_have_the_canonical_raw_values() + { + Assert.Equal(1, Bool32.True.RawValue); + Assert.Equal(0, Bool32.False.RawValue); + Assert.Equal(0, default(Bool32).RawValue); + } - [Theory] - [InlineData(0, false)] - [InlineData(1, true)] - [InlineData(-1, true)] - [InlineData(2, true)] - [InlineData(0x100, true)] - [InlineData(int.MinValue, true)] - public void Truthiness_of_a_raw_value_is_non_zero(int raw, bool expected) - { - Bool32 value = new(raw); + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(-1, true)] + [InlineData(2, true)] + [InlineData(0x100, true)] + [InlineData(int.MinValue, true)] + public void Truthiness_of_a_raw_value_is_non_zero(int raw, bool expected) + { + Bool32 value = new(raw); - Assert.Equal(expected, value.IsTrue); - Assert.Equal(expected, value.ToBoolean()); - Assert.Equal(expected, (bool)value); - Assert.Equal(expected, value ? true : false); - Assert.Equal(!expected, !value); - Assert.Equal(raw, value.RawValue); - } + Assert.Equal(expected, value.IsTrue); + Assert.Equal(expected, value.ToBoolean()); + Assert.Equal(expected, (bool) value); + Assert.Equal(expected, value ? true : false); + Assert.Equal(!expected, !value); + Assert.Equal(raw, value.RawValue); + } - [Fact] - public void Conversion_from_bool_writes_one_or_zero() - { - Bool32 fromTrue = true; - Bool32 fromFalse = false; + [Fact] + public void Conversion_from_bool_writes_one_or_zero() + { + Bool32 fromTrue = true; + Bool32 fromFalse = false; - Assert.Equal(1, fromTrue.RawValue); - Assert.Equal(0, fromFalse.RawValue); - Assert.Equal(1, Bool32.FromBoolean(true).RawValue); - Assert.Equal(0, Bool32.FromBoolean(false).RawValue); - } + Assert.Equal(1, fromTrue.RawValue); + Assert.Equal(0, fromFalse.RawValue); + Assert.Equal(1, Bool32.FromBoolean(true).RawValue); + Assert.Equal(0, Bool32.FromBoolean(false).RawValue); + } - [Fact] - public void Equality_compares_truthiness_not_raw_bits() - { - Bool32 one = new(1); - Bool32 allBitsSet = new(-1); + [Fact] + public void Equality_compares_truthiness_not_raw_bits() + { + Bool32 one = new(1); + Bool32 allBitsSet = new(-1); - Assert.True(one == allBitsSet); - Assert.False(one != allBitsSet); - Assert.True(one.Equals(allBitsSet)); - Assert.True(one.Equals((object)allBitsSet)); - Assert.Equal(one.GetHashCode(), allBitsSet.GetHashCode()); - Assert.True(one != Bool32.False); - Assert.False(one.Equals(1)); - } + Assert.True(one == allBitsSet); + Assert.False(one != allBitsSet); + Assert.True(one.Equals(allBitsSet)); + Assert.True(one.Equals((object) allBitsSet)); + Assert.Equal(one.GetHashCode(), allBitsSet.GetHashCode()); + Assert.True(one != Bool32.False); + Assert.False(one.Equals(1)); + } - [Fact] - public void Comparison_with_canonical_values_uses_truthiness() - { - Bool32 allBitsSet = new(-1); + [Fact] + public void Comparison_with_canonical_values_uses_truthiness() + { + Bool32 allBitsSet = new(-1); - Assert.True(allBitsSet == Bool32.True); - Assert.False(Bool32.False == Bool32.True); - } + Assert.True(allBitsSet == Bool32.True); + Assert.False(Bool32.False == Bool32.True); + } - [Fact] - public void ToString_returns_the_truthiness() - { - Assert.Equal("True", new Bool32(-1).ToString()); - Assert.Equal("False", Bool32.False.ToString()); - } + [Fact] + public void ToString_returns_the_truthiness() + { + Assert.Equal("True", new Bool32(-1).ToString()); + Assert.Equal("False", Bool32.False.ToString()); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Bool8Tests.cs b/tests/CheatEngine.SDK.Abi.Tests/Bool8Tests.cs index 6729f33a..e8bdf2ae 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Bool8Tests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Bool8Tests.cs @@ -4,69 +4,69 @@ namespace CheatEngine.SDK.Abi.Tests; public sealed class Bool8Tests { - [Fact] - public void Size_is_one_byte_like_a_pascal_boolean() - { - Assert.Equal(1, Layout.SizeOf()); - } + [Fact] + public void Size_is_one_byte_like_a_pascal_boolean() + { + Assert.Equal(1, Layout.SizeOf()); + } - [Fact] - public void True_and_False_have_the_canonical_raw_values() - { - Assert.Equal(1, Bool8.True.RawValue); - Assert.Equal(0, Bool8.False.RawValue); - Assert.Equal(0, default(Bool8).RawValue); - } + [Fact] + public void True_and_False_have_the_canonical_raw_values() + { + Assert.Equal(1, Bool8.True.RawValue); + Assert.Equal(0, Bool8.False.RawValue); + Assert.Equal(0, default(Bool8).RawValue); + } - [Theory] - [InlineData(0, false)] - [InlineData(1, true)] - [InlineData(2, true)] - [InlineData(0x80, true)] - [InlineData(0xFF, true)] - public void Truthiness_of_a_raw_value_is_non_zero(byte raw, bool expected) - { - Bool8 value = new(raw); + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(2, true)] + [InlineData(0x80, true)] + [InlineData(0xFF, true)] + public void Truthiness_of_a_raw_value_is_non_zero(byte raw, bool expected) + { + Bool8 value = new(raw); - Assert.Equal(expected, value.IsTrue); - Assert.Equal(expected, value.ToBoolean()); - Assert.Equal(expected, (bool)value); - Assert.Equal(expected, value ? true : false); - Assert.Equal(!expected, !value); - Assert.Equal(raw, value.RawValue); - } + Assert.Equal(expected, value.IsTrue); + Assert.Equal(expected, value.ToBoolean()); + Assert.Equal(expected, (bool) value); + Assert.Equal(expected, value ? true : false); + Assert.Equal(!expected, !value); + Assert.Equal(raw, value.RawValue); + } - [Fact] - public void Conversion_from_bool_writes_one_or_zero() - { - Bool8 fromTrue = true; - Bool8 fromFalse = false; + [Fact] + public void Conversion_from_bool_writes_one_or_zero() + { + Bool8 fromTrue = true; + Bool8 fromFalse = false; - Assert.Equal(1, fromTrue.RawValue); - Assert.Equal(0, fromFalse.RawValue); - Assert.Equal(1, Bool8.FromBoolean(true).RawValue); - Assert.Equal(0, Bool8.FromBoolean(false).RawValue); - } + Assert.Equal(1, fromTrue.RawValue); + Assert.Equal(0, fromFalse.RawValue); + Assert.Equal(1, Bool8.FromBoolean(true).RawValue); + Assert.Equal(0, Bool8.FromBoolean(false).RawValue); + } - [Fact] - public void Equality_compares_truthiness_not_raw_bits() - { - Bool8 one = new(1); - Bool8 allBitsSet = new(0xFF); + [Fact] + public void Equality_compares_truthiness_not_raw_bits() + { + Bool8 one = new(1); + Bool8 allBitsSet = new(0xFF); - Assert.True(one == allBitsSet); - Assert.False(one != allBitsSet); - Assert.True(one.Equals(allBitsSet)); - Assert.True(one.Equals((object)allBitsSet)); - Assert.Equal(one.GetHashCode(), allBitsSet.GetHashCode()); - Assert.True(one != Bool8.False); - Assert.False(one.Equals((byte)1)); - } + Assert.True(one == allBitsSet); + Assert.False(one != allBitsSet); + Assert.True(one.Equals(allBitsSet)); + Assert.True(one.Equals((object) allBitsSet)); + Assert.Equal(one.GetHashCode(), allBitsSet.GetHashCode()); + Assert.True(one != Bool8.False); + Assert.False(one.Equals((byte) 1)); + } - [Fact] - public void ToString_returns_the_truthiness() - { - Assert.Equal("True", new Bool8(0xFF).ToString()); - Assert.Equal("False", Bool8.False.ToString()); - } + [Fact] + public void ToString_returns_the_truthiness() + { + Assert.Equal("True", new Bool8(0xFF).ToString()); + Assert.Equal("False", Bool8.False.ToString()); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs b/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs index a97cab14..a1d0d6db 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs @@ -10,118 +10,119 @@ namespace CheatEngine.SDK.Abi.Tests; /// public sealed unsafe class BoolCallBoundaryTests { - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(-1)] - [InlineData(0x100)] - public void Bool32_result_of_a_callee_returning_int_carries_the_same_bits(int raw) - { - delegate* unmanaged[Stdcall] callee = &ReturnInt; - var asBool32 = (delegate* unmanaged[Stdcall])callee; - - var result = asBool32(raw); - - Assert.Equal(raw, result.RawValue); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(-1)] - [InlineData(0x100)] - public void Bool32_returned_by_a_callee_reads_as_the_same_int(int raw) - { - delegate* unmanaged[Stdcall] callee = &ReturnBool32; - var asInt = (delegate* unmanaged[Stdcall])callee; - - Assert.Equal(raw, asInt(raw)); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(-1)] - public void Bool32_argument_arrives_as_the_same_int(int raw) - { - delegate* unmanaged[Stdcall] callee = &ReturnMiddleBool32; - var asInt = (delegate* unmanaged[Stdcall])callee; - - Assert.Equal(raw, asInt(long.MinValue, raw, long.MaxValue)); - } - - [Fact] - public void Bool32_written_through_a_pointer_argument_is_a_four_byte_write() - { - delegate* unmanaged[Stdcall] callee = &WriteTrue; - var storage = 0xAAAA_AAAA_AAAA_AAAA; - - callee((Bool32*)&storage); - - var expected = BitConverter.IsLittleEndian ? 0xAAAA_AAAA_0000_0001 : 0x0000_0001_AAAA_AAAA; - Assert.Equal(expected, storage); - } - - [Theory] - [InlineData(0, false)] - [InlineData(1, true)] - [InlineData(0xFF, true)] - public void Bool8_result_ignores_garbage_above_the_low_byte(int lowByte, bool expected) - { - delegate* unmanaged[Stdcall] callee = &ReturnLowByteUnderGarbage; - var asBool8 = (delegate* unmanaged[Stdcall])callee; - - var result = asBool8(lowByte); - - Assert.Equal(expected, result.IsTrue); - Assert.Equal((byte)lowByte, result.RawValue); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(0xFF)] - public void Bool8_returned_by_a_callee_reads_as_the_same_byte(byte raw) - { - delegate* unmanaged[Stdcall] callee = &ReturnBool8; - var asByte = (delegate* unmanaged[Stdcall])callee; - - Assert.Equal(raw, asByte(raw)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int ReturnInt(int value) - { - return value; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 ReturnBool32(int raw) - { - return new Bool32(raw); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int ReturnMiddleBool32(long before, Bool32 value, long after) - { - return before == long.MinValue && after == long.MaxValue ? value.RawValue : 0x0BAD; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void WriteTrue(Bool32* target) - { - *target = Bool32.True; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int ReturnLowByteUnderGarbage(int lowByte) - { - return unchecked((int)0xABCDEF00) | (lowByte & 0xFF); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool8 ReturnBool8(byte raw) - { - return new Bool8(raw); - } + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(-1)] + [InlineData(0x100)] + public void Bool32_result_of_a_callee_returning_int_carries_the_same_bits(int raw) + { + delegate* unmanaged[Stdcall] callee = &ReturnInt; + delegate* unmanaged[Stdcall] asBool32 = (delegate* unmanaged[Stdcall]) callee; + + Bool32 result = asBool32(raw); + + Assert.Equal(raw, result.RawValue); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(-1)] + [InlineData(0x100)] + public void Bool32_returned_by_a_callee_reads_as_the_same_int(int raw) + { + delegate* unmanaged[Stdcall] callee = &ReturnBool32; + delegate* unmanaged[Stdcall] asInt = (delegate* unmanaged[Stdcall]) callee; + + Assert.Equal(raw, asInt(raw)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(-1)] + public void Bool32_argument_arrives_as_the_same_int(int raw) + { + delegate* unmanaged[Stdcall] callee = &ReturnMiddleBool32; + delegate* unmanaged[Stdcall] asInt = + (delegate* unmanaged[Stdcall]) callee; + + Assert.Equal(raw, asInt(long.MinValue, raw, long.MaxValue)); + } + + [Fact] + public void Bool32_written_through_a_pointer_argument_is_a_four_byte_write() + { + delegate* unmanaged[Stdcall] callee = &WriteTrue; + ulong storage = 0xAAAA_AAAA_AAAA_AAAA; + + callee((Bool32*) &storage); + + ulong expected = BitConverter.IsLittleEndian ? 0xAAAA_AAAA_0000_0001 : 0x0000_0001_AAAA_AAAA; + Assert.Equal(expected, storage); + } + + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(0xFF, true)] + public void Bool8_result_ignores_garbage_above_the_low_byte(int lowByte, bool expected) + { + delegate* unmanaged[Stdcall] callee = &ReturnLowByteUnderGarbage; + delegate* unmanaged[Stdcall] asBool8 = (delegate* unmanaged[Stdcall]) callee; + + Bool8 result = asBool8(lowByte); + + Assert.Equal(expected, result.IsTrue); + Assert.Equal((byte) lowByte, result.RawValue); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(0xFF)] + public void Bool8_returned_by_a_callee_reads_as_the_same_byte(byte raw) + { + delegate* unmanaged[Stdcall] callee = &ReturnBool8; + delegate* unmanaged[Stdcall] asByte = (delegate* unmanaged[Stdcall]) callee; + + Assert.Equal(raw, asByte(raw)); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int ReturnInt(int value) + { + return value; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 ReturnBool32(int raw) + { + return new Bool32(raw); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int ReturnMiddleBool32(long before, Bool32 value, long after) + { + return before == long.MinValue && after == long.MaxValue ? value.RawValue : 0x0BAD; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void WriteTrue(Bool32* target) + { + *target = Bool32.True; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int ReturnLowByteUnderGarbage(int lowByte) + { + return unchecked((int) 0xABCDEF00) | (lowByte & 0xFF); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool8 ReturnBool8(byte raw) + { + return new Bool8(raw); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs index 988064f0..5439e8b8 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; + using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -10,76 +12,76 @@ namespace CheatEngine.SDK.Abi.Tests.Fixture; /// public sealed class NativeAbiFixtureContractTests { - [Fact] - public void Header_derived_classic_records_have_the_fixture_x64_sizes() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + [Fact] + public void Header_derived_classic_records_have_the_fixture_x64_sizes() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - Assert.Equal(16, Layout.SizeOf()); - Assert.Equal(16, Layout.SizeOf()); - Assert.Equal(24, Layout.SizeOf()); - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(24, Layout.SizeOf()); - Assert.Equal(32, Layout.SizeOf()); - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(48, Layout.SizeOf()); - Assert.Equal(264, Layout.SizeOf()); - Assert.Equal(144, Layout.SizeOf()); - } + Assert.Equal(16, Layout.SizeOf()); + Assert.Equal(16, Layout.SizeOf()); + Assert.Equal(24, Layout.SizeOf()); + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(24, Layout.SizeOf()); + Assert.Equal(32, Layout.SizeOf()); + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(48, Layout.SizeOf()); + Assert.Equal(264, Layout.SizeOf()); + Assert.Equal(144, Layout.SizeOf()); + } - [Fact] - public void Header_derived_classic_records_have_the_fixture_x64_alignments() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + [Fact] + public void Header_derived_classic_records_have_the_fixture_x64_alignments() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - Assert.Equal(8, Layout.AlignmentOf()); - } + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + Assert.Equal(8, Layout.AlignmentOf()); + } - [Fact] - public void Header_and_pinned_Pascal_popup_contract_conflict_keeps_the_slot_opaque_until_a_live_canary() - { - var popup = typeof(DisassemblerContextPluginInit).GetField( - nameof(DisassemblerContextPluginInit.CallbackOnPopup)) - ?? throw new InvalidOperationException("The popup callback field was not found."); + [Fact] + public void Header_and_pinned_Pascal_popup_contract_conflict_keeps_the_slot_opaque_until_a_live_canary() + { + FieldInfo popup = typeof(DisassemblerContextPluginInit).GetField( + nameof(DisassemblerContextPluginInit.CallbackOnPopup)) + ?? throw new InvalidOperationException("The popup callback field was not found."); - var fieldType = popup.GetModifiedFieldType().UnderlyingSystemType; + Type fieldType = popup.GetModifiedFieldType().UnderlyingSystemType; - Assert.True(fieldType.IsPointer); - Assert.Equal("System.Void", fieldType.GetElementType()?.UnderlyingSystemType.FullName); - } + Assert.True(fieldType.IsPointer); + Assert.Equal("System.Void", fieldType.GetElementType()?.UnderlyingSystemType.FullName); + } - [Fact] - public void Fixture_dll_export_names_are_the_three_classic_header_exports_in_order() - { - string[] expected = - [ - "CEPlugin_GetVersion", - "CEPlugin_InitializePlugin", - "CEPlugin_DisablePlugin", - ]; + [Fact] + public void Fixture_dll_export_names_are_the_three_classic_header_exports_in_order() + { + string[] expected = + [ + "CEPlugin_GetVersion", + "CEPlugin_InitializePlugin", + "CEPlugin_DisablePlugin" + ]; - string[] actual = - [ - NativeExportNames.GetVersion, - NativeExportNames.InitializePlugin, - NativeExportNames.DisablePlugin, - ]; + string[] actual = + [ + NativeExportNames.GetVersion, + NativeExportNames.InitializePlugin, + NativeExportNames.DisablePlugin + ]; - Assert.Equal(expected, actual); - } + Assert.Equal(expected, actual); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedEntryPointTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedEntryPointTests.cs index 8a55cbcc..4d345d59 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedEntryPointTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedEntryPointTests.cs @@ -4,20 +4,20 @@ namespace CheatEngine.SDK.Abi.Tests.Managed; public sealed class ManagedEntryPointTests { - [Fact] - public void Names_match_what_cheat_engine_looks_up() - { - // Cheat Engine demands CESDK.CESDK, so these literals stay independent of the CheatEngine.SDK naming. - Assert.Equal("CESDK", ManagedEntryPoint.Namespace); - Assert.Equal("CESDK", ManagedEntryPoint.TypeName); - Assert.Equal("CESDK.CESDK", ManagedEntryPoint.FullTypeName); - Assert.Equal("CEPluginInitialize", ManagedEntryPoint.MethodName); - } + [Fact] + public void Names_match_what_cheat_engine_looks_up() + { + // Cheat Engine demands CESDK.CESDK, so these literals stay independent of the CheatEngine.SDK naming. + Assert.Equal("CESDK", ManagedEntryPoint.Namespace); + Assert.Equal("CESDK", ManagedEntryPoint.TypeName); + Assert.Equal("CESDK.CESDK", ManagedEntryPoint.FullTypeName); + Assert.Equal("CEPluginInitialize", ManagedEntryPoint.MethodName); + } - [Fact] - public void Result_codes_match_the_official_bootstrap() - { - Assert.Equal(1, ManagedEntryPoint.Success); - Assert.Equal(0, ManagedEntryPoint.Failure); - } + [Fact] + public void Result_codes_match_the_official_bootstrap() + { + Assert.Equal(1, ManagedEntryPoint.Success); + Assert.Equal(0, ManagedEntryPoint.Failure); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedExportedFunctionsTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedExportedFunctionsTests.cs index 94cf6013..7d299129 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedExportedFunctionsTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Managed/ManagedExportedFunctionsTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Abi.Tests.Support; @@ -7,109 +8,112 @@ namespace CheatEngine.SDK.Abi.Tests.Managed; public sealed unsafe class ManagedExportedFunctionsTests { - private static readonly void* FakeLuaState = (void*)0x5150; - - private static nint s_pushedState; - private static nint s_pushedObject; - private static int s_processMessagesCalls; - - [Fact] - public void Size_on_64_bit_is_48_bytes() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - - Assert.Equal(48, Layout.SizeOf()); - } - - [Fact] - public void Field_offsets_on_64_bit_match_the_host_record() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - ManagedExportedFunctions exports = default; - void* origin = &exports; - - Assert.Equal(0, Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); - Assert.Equal(8, Layout.OffsetOf(origin, &exports.GetLuaState)); - Assert.Equal(16, Layout.OffsetOf(origin, &exports.LuaRegister)); - Assert.Equal(24, Layout.OffsetOf(origin, &exports.LuaPushClassInstance)); - Assert.Equal(32, Layout.OffsetOf(origin, &exports.ProcessMessages)); - Assert.Equal(40, Layout.OffsetOf(origin, &exports.CheckSynchronize)); - } - - /// - /// Builds the record the way the host does (an integer followed by five pointer-sized slots, written as raw - /// bytes), then reads it through the structure. Independent of the address-of arithmetic used above. - /// - [Fact] - public void Overlay_on_a_raw_host_record_on_64_bit_reads_every_slot() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - var raw = stackalloc byte[48]; - new Span(raw, 48).Fill(0xCC); - Unsafe.WriteUnaligned(raw, 48); - for (var slot = 1; slot <= 5; slot++) Unsafe.WriteUnaligned(raw + slot * 8, (ulong)slot * 0x1000_0000_1000UL); - - var copy = *(ManagedExportedFunctions*)raw; - - Assert.Equal(48, copy.SizeOfExportedFunctions); - Assert.Equal(0x1000_0000_1000UL, (ulong)copy.GetLuaState); - Assert.Equal(0x2000_0000_2000UL, (ulong)copy.LuaRegister); - Assert.Equal(0x3000_0000_3000UL, (ulong)copy.LuaPushClassInstance); - Assert.Equal(0x4000_0000_4000UL, (ulong)copy.ProcessMessages); - Assert.Equal(0x5000_0000_5000UL, (ulong)copy.CheckSynchronize); - } - - /// - /// Plays the host: every typed slot points at a real stdcall function with the documented shape and is - /// invoked through the record. - /// - [Fact] - public void Typed_slots_are_callable_with_the_declared_shapes() - { - ManagedExportedFunctions exports = default; - exports.SizeOfExportedFunctions = Layout.SizeOf(); - exports.GetLuaState = &FakeGetLuaState; - exports.LuaPushClassInstance = &FakePushClassInstance; - exports.ProcessMessages = &FakeProcessMessages; - exports.CheckSynchronize = &FakeCheckSynchronize; - - var state = exports.GetLuaState(); - Assert.Equal((nint)FakeLuaState, (nint)state); - - exports.LuaPushClassInstance(state, (void*)0x7777); - Assert.Equal((nint)FakeLuaState, s_pushedState); - Assert.Equal(0x7777, s_pushedObject); - - var before = s_processMessagesCalls; - exports.ProcessMessages(); - Assert.Equal(before + 1, s_processMessagesCalls); - - Assert.True(exports.CheckSynchronize(42).IsTrue); - Assert.False(exports.CheckSynchronize(0).IsTrue); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* FakeGetLuaState() - { - return FakeLuaState; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void FakePushClassInstance(void* state, void* instance) - { - s_pushedState = (nint)state; - s_pushedObject = (nint)instance; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void FakeProcessMessages() - { - s_processMessagesCalls++; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool8 FakeCheckSynchronize(int timeout) - { - return timeout == 42; - } + private static readonly void* FakeLuaState = (void*) 0x5150; + + private static nint s_pushedState; + private static nint s_pushedObject; + private static int s_processMessagesCalls; + + [Fact] + public void Size_on_64_bit_is_48_bytes() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(48, Layout.SizeOf()); + } + + [Fact] + public void Field_offsets_on_64_bit_match_the_host_record() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ManagedExportedFunctions exports = default; + void* origin = &exports; + + Assert.Equal(0, Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); + Assert.Equal(8, Layout.OffsetOf(origin, &exports.GetLuaState)); + Assert.Equal(16, Layout.OffsetOf(origin, &exports.LuaRegister)); + Assert.Equal(24, Layout.OffsetOf(origin, &exports.LuaPushClassInstance)); + Assert.Equal(32, Layout.OffsetOf(origin, &exports.ProcessMessages)); + Assert.Equal(40, Layout.OffsetOf(origin, &exports.CheckSynchronize)); + } + + /// + /// Builds the record the way the host does (an integer followed by five pointer-sized slots, written as raw + /// bytes), then reads it through the structure. Independent of the address-of arithmetic used above. + /// + [Fact] + public void Overlay_on_a_raw_host_record_on_64_bit_reads_every_slot() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte* raw = stackalloc byte[48]; + new Span(raw, 48).Fill(0xCC); + Unsafe.WriteUnaligned(raw, 48); + for (int slot = 1; slot <= 5; slot++) + { + Unsafe.WriteUnaligned(raw + slot * 8, (ulong) slot * 0x1000_0000_1000UL); + } + + ManagedExportedFunctions copy = *(ManagedExportedFunctions*) raw; + + Assert.Equal(48, copy.SizeOfExportedFunctions); + Assert.Equal(0x1000_0000_1000UL, (ulong) copy.GetLuaState); + Assert.Equal(0x2000_0000_2000UL, (ulong) copy.LuaRegister); + Assert.Equal(0x3000_0000_3000UL, (ulong) copy.LuaPushClassInstance); + Assert.Equal(0x4000_0000_4000UL, (ulong) copy.ProcessMessages); + Assert.Equal(0x5000_0000_5000UL, (ulong) copy.CheckSynchronize); + } + + /// + /// Plays the host: every typed slot points at a real stdcall function with the documented shape and is + /// invoked through the record. + /// + [Fact] + public void Typed_slots_are_callable_with_the_declared_shapes() + { + ManagedExportedFunctions exports = default; + exports.SizeOfExportedFunctions = Layout.SizeOf(); + exports.GetLuaState = &FakeGetLuaState; + exports.LuaPushClassInstance = &FakePushClassInstance; + exports.ProcessMessages = &FakeProcessMessages; + exports.CheckSynchronize = &FakeCheckSynchronize; + + void* state = exports.GetLuaState(); + Assert.Equal((nint) FakeLuaState, (nint) state); + + exports.LuaPushClassInstance(state, (void*) 0x7777); + Assert.Equal((nint) FakeLuaState, s_pushedState); + Assert.Equal(0x7777, s_pushedObject); + + int before = s_processMessagesCalls; + exports.ProcessMessages(); + Assert.Equal(before + 1, s_processMessagesCalls); + + Assert.True(exports.CheckSynchronize(42).IsTrue); + Assert.False(exports.CheckSynchronize(0).IsTrue); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* FakeGetLuaState() + { + return FakeLuaState; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void FakePushClassInstance(void* state, void* instance) + { + s_pushedState = (nint) state; + s_pushedObject = (nint) instance; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void FakeProcessMessages() + { + s_processMessagesCalls++; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool8 FakeCheckSynchronize(int timeout) + { + return timeout == 42; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs index bc57e9fa..395d218b 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -8,118 +9,121 @@ namespace CheatEngine.SDK.Abi.Tests.Managed; public sealed unsafe class PluginInitRecordTests { - private const byte Guard = 0xCC; - - private static readonly byte* FakeName = (byte*)0x1111_2222_3333_4444; - - private static int s_disableCalls; - - [Fact] - public void Size_on_64_bit_is_36_bytes_packed() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - - Assert.Equal(36, Layout.SizeOf()); - } - - [Fact] - public void Field_offsets_on_64_bit_match_the_host_record() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - PluginInitRecord record = default; - void* origin = &record; - - Assert.Equal(0, Layout.OffsetOf(origin, &record.Name)); - Assert.Equal(8, Layout.OffsetOf(origin, &record.GetVersion)); - Assert.Equal(16, Layout.OffsetOf(origin, &record.EnablePlugin)); - Assert.Equal(24, Layout.OffsetOf(origin, &record.DisablePlugin)); - Assert.Equal(32, Layout.OffsetOf(origin, &record.Version)); - } - - /// - /// The defect this guards against: an unpacked mirror is 40 bytes and writes 4 bytes past the host's 36-byte - /// variable. - /// An odd start offset additionally proves that access through the packed type is unaligned-safe. - /// - [Theory] - [InlineData(16)] - [InlineData(13)] - public void Write_through_a_pointer_on_64_bit_touches_exactly_36_bytes(int start) - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - const int RecordSize = 36; - const int Total = 80; - var buffer = stackalloc byte[Total]; - Span bytes = new(buffer, Total); - bytes.Fill(Guard); - - var record = (PluginInitRecord*)(buffer + start); - *record = default; - record->Name = FakeName; - record->GetVersion = (delegate* unmanaged[Stdcall])0x0101_0101_0101_0101; - record->EnablePlugin = - (delegate* unmanaged[Stdcall])0x0202_0202_0202_0202; - record->DisablePlugin = (delegate* unmanaged[Stdcall])0x0303_0303_0303_0303; - record->Version = AbiConstants.SdkVersion; - - Assert.All(bytes[..start].ToArray(), static b => Assert.Equal(Guard, b)); - Assert.All(bytes[(start + RecordSize)..].ToArray(), static b => Assert.Equal(Guard, b)); - Assert.Equal(0x1111_2222_3333_4444UL, Unsafe.ReadUnaligned(buffer + start)); - Assert.Equal(0x0101_0101_0101_0101UL, Unsafe.ReadUnaligned(buffer + start + 8)); - Assert.Equal(0x0202_0202_0202_0202UL, Unsafe.ReadUnaligned(buffer + start + 16)); - Assert.Equal(0x0303_0303_0303_0303UL, Unsafe.ReadUnaligned(buffer + start + 24)); - Assert.Equal(6u, Unsafe.ReadUnaligned(buffer + start + 32)); - } - - /// - /// Plays the host: the callbacks are real [UnmanagedCallersOnly] stdcall functions whose addresses only - /// fit the fields if the declared signatures are exactly right, and they are invoked through the record. - /// - [Fact] - public void Callbacks_stored_in_the_record_are_callable_with_the_declared_shapes() - { - PluginInitRecord record = default; - record.GetVersion = &FakeGetVersion; - record.EnablePlugin = &FakeEnablePlugin; - record.DisablePlugin = &FakeDisablePlugin; - - PluginVersion version = default; - var versionResult = record.GetVersion(&version, Layout.SizeOf()); - Assert.True(versionResult.IsTrue); - Assert.Equal(6u, version.Version); - Assert.Equal((nint)FakeName, (nint)version.PluginName); - - ManagedExportedFunctions exports = default; - exports.SizeOfExportedFunctions = Layout.SizeOf(); - Assert.True(record.EnablePlugin(&exports, 0xFFFF_FFF0u).IsTrue); - Assert.False(record.EnablePlugin(&exports, 7u).IsTrue); - - var before = s_disableCalls; - Assert.True(record.DisablePlugin().IsTrue); - Assert.Equal(before + 1, s_disableCalls); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 FakeGetVersion(PluginVersion* version, int size) - { - if (version is null || size < sizeof(PluginVersion)) return Bool32.False; - - version->Version = AbiConstants.SdkVersion; - version->PluginName = FakeName; - return Bool32.True; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 FakeEnablePlugin(ManagedExportedFunctions* exports, uint pluginId) - { - return exports is not null && exports->SizeOfExportedFunctions == sizeof(ManagedExportedFunctions) && - pluginId == 0xFFFF_FFF0u; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 FakeDisablePlugin() - { - s_disableCalls++; - return Bool32.True; - } + private const byte Guard = 0xCC; + + private static readonly byte* FakeName = (byte*) 0x1111_2222_3333_4444; + + private static int s_disableCalls; + + [Fact] + public void Size_on_64_bit_is_36_bytes_packed() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(36, Layout.SizeOf()); + } + + [Fact] + public void Field_offsets_on_64_bit_match_the_host_record() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + PluginInitRecord record = default; + void* origin = &record; + + Assert.Equal(0, Layout.OffsetOf(origin, &record.Name)); + Assert.Equal(8, Layout.OffsetOf(origin, &record.GetVersion)); + Assert.Equal(16, Layout.OffsetOf(origin, &record.EnablePlugin)); + Assert.Equal(24, Layout.OffsetOf(origin, &record.DisablePlugin)); + Assert.Equal(32, Layout.OffsetOf(origin, &record.Version)); + } + + /// + /// The defect this guards against: an unpacked mirror is 40 bytes and writes 4 bytes past the host's 36-byte + /// variable. + /// An odd start offset additionally proves that access through the packed type is unaligned-safe. + /// + [Theory] + [InlineData(16)] + [InlineData(13)] + public void Write_through_a_pointer_on_64_bit_touches_exactly_36_bytes(int start) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + const int RecordSize = 36; + const int Total = 80; + byte* buffer = stackalloc byte[Total]; + Span bytes = new(buffer, Total); + bytes.Fill(Guard); + + PluginInitRecord* record = (PluginInitRecord*) (buffer + start); + *record = default; + record->Name = FakeName; + record->GetVersion = (delegate* unmanaged[Stdcall]) 0x0101_0101_0101_0101; + record->EnablePlugin = + (delegate* unmanaged[Stdcall]) 0x0202_0202_0202_0202; + record->DisablePlugin = (delegate* unmanaged[Stdcall]) 0x0303_0303_0303_0303; + record->Version = AbiConstants.SdkVersion; + + Assert.All(bytes[..start].ToArray(), static b => Assert.Equal(Guard, b)); + Assert.All(bytes[(start + RecordSize)..].ToArray(), static b => Assert.Equal(Guard, b)); + Assert.Equal(0x1111_2222_3333_4444UL, Unsafe.ReadUnaligned(buffer + start)); + Assert.Equal(0x0101_0101_0101_0101UL, Unsafe.ReadUnaligned(buffer + start + 8)); + Assert.Equal(0x0202_0202_0202_0202UL, Unsafe.ReadUnaligned(buffer + start + 16)); + Assert.Equal(0x0303_0303_0303_0303UL, Unsafe.ReadUnaligned(buffer + start + 24)); + Assert.Equal(6u, Unsafe.ReadUnaligned(buffer + start + 32)); + } + + /// + /// Plays the host: the callbacks are real [UnmanagedCallersOnly] stdcall functions whose addresses only + /// fit the fields if the declared signatures are exactly right, and they are invoked through the record. + /// + [Fact] + public void Callbacks_stored_in_the_record_are_callable_with_the_declared_shapes() + { + PluginInitRecord record = default; + record.GetVersion = &FakeGetVersion; + record.EnablePlugin = &FakeEnablePlugin; + record.DisablePlugin = &FakeDisablePlugin; + + PluginVersion version = default; + Bool32 versionResult = record.GetVersion(&version, Layout.SizeOf()); + Assert.True(versionResult.IsTrue); + Assert.Equal(6u, version.Version); + Assert.Equal((nint) FakeName, (nint) version.PluginName); + + ManagedExportedFunctions exports = default; + exports.SizeOfExportedFunctions = Layout.SizeOf(); + Assert.True(record.EnablePlugin(&exports, 0xFFFF_FFF0u).IsTrue); + Assert.False(record.EnablePlugin(&exports, 7u).IsTrue); + + int before = s_disableCalls; + Assert.True(record.DisablePlugin().IsTrue); + Assert.Equal(before + 1, s_disableCalls); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 FakeGetVersion(PluginVersion* version, int size) + { + if (version is null || size < sizeof(PluginVersion)) + { + return Bool32.False; + } + + version->Version = AbiConstants.SdkVersion; + version->PluginName = FakeName; + return Bool32.True; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 FakeEnablePlugin(ManagedExportedFunctions* exports, uint pluginId) + { + return exports is not null && exports->SizeOfExportedFunctions == sizeof(ManagedExportedFunctions) && + pluginId == 0xFFFF_FFF0u; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 FakeDisablePlugin() + { + s_disableCalls++; + return Bool32.True; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/AutoAssemblerPhaseTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/AutoAssemblerPhaseTests.cs index 23c8ff40..5151eeba 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/AutoAssemblerPhaseTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/AutoAssemblerPhaseTests.cs @@ -5,26 +5,26 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed class AutoAssemblerPhaseTests { - [Theory] - [InlineData(AutoAssemblerPhase.Initialize, 0)] - [InlineData(AutoAssemblerPhase.Phase1, 1)] - [InlineData(AutoAssemblerPhase.Phase2, 2)] - [InlineData(AutoAssemblerPhase.Finalize, 3)] - public void Member_has_the_upstream_numeric_value(AutoAssemblerPhase member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(AutoAssemblerPhase.Initialize, 0)] + [InlineData(AutoAssemblerPhase.Phase1, 1)] + [InlineData(AutoAssemblerPhase.Phase2, 2)] + [InlineData(AutoAssemblerPhase.Finalize, 3)] + public void Member_has_the_upstream_numeric_value(AutoAssemblerPhase member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Fact] - public void Enum_has_exactly_the_four_upstream_members() - { - Assert.Equal(4, Enum.GetValues().Length); - } + [Fact] + public void Enum_has_exactly_the_four_upstream_members() + { + Assert.Equal(4, Enum.GetValues().Length); + } - [Fact] - public void Enum_is_four_bytes_wide_like_the_c_enumeration() - { - Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(AutoAssemblerPhase))); - Assert.Equal(4, Layout.SizeOf()); - } + [Fact] + public void Enum_is_four_bytes_wide_like_the_c_enumeration() + { + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(AutoAssemblerPhase))); + Assert.Equal(4, Layout.SizeOf()); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs index 494e6c3a..46fe08b6 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -12,296 +13,299 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// public sealed unsafe class ClassicDebugEventDispatcherTests : IDisposable { - private ClassicDebugEventDispatcher? _dispatcher; - - [Fact] - public void Callback_runs_the_synchronous_handler_with_a_scalar_copy_and_leaves_continuation_to_CheatEngine() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - DebugEventObservation received = default; - var handlerCalls = 0; - var dispatcher = Register((in DebugEventObservation observation) => - { - handlerCalls++; - received = observation; - return DebugEventDecision.ContinueWithCheatEngine; - }); - NativeDebugEvent nativeEvent = new(0x0000_0006, 101, 202); - - var result = Invoke(&nativeEvent); - - Assert.Equal(0, result); - Assert.Equal(1, handlerCalls); - Assert.Equal(0x0000_0006u, received.EventCode); - Assert.Equal(101u, received.ProcessId); - Assert.Equal(202u, received.ThreadId); - Assert.True(received.SequenceNumber > 0); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - [Fact] - public void Callback_copies_observations_before_the_native_buffer_can_be_reused() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - BoundedDebugEventObservationBuffer observations = new(1, DebugEventObservationOverflowPolicy.DropNewest); - var dispatcher = Register(static (in DebugEventObservation _) => DebugEventDecision.ContinueWithCheatEngine, - observations); - NativeDebugEvent nativeEvent = new(3, 404, 505); - - Assert.Equal(0, Invoke(&nativeEvent)); - nativeEvent = new(99, 0, 0); - - Assert.True(observations.TryRead(out var copied)); - Assert.Equal(3u, copied.EventCode); - Assert.Equal(404u, copied.ProcessId); - Assert.Equal(505u, copied.ThreadId); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - [Fact] - public void Plugin_owned_continuation_request_is_rejected_to_the_current_CheatEngine_owned_fallback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - var dispatcher = Register(static (in DebugEventObservation _) => DebugEventDecision.PluginOwnsContinuation); - NativeDebugEvent nativeEvent = new(1, 2, 3); - - Assert.Equal(0, Invoke(&nativeEvent)); - Assert.Equal(1, dispatcher.UnsupportedContinuationRequestCount); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - [Fact] - public void Callback_exception_is_contained_and_returns_the_CheatEngine_owned_fallback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - var dispatcher = Register(static (in DebugEventObservation _) => throw new InvalidOperationException("boom")); - NativeDebugEvent nativeEvent = new(1, 2, 3); - - var result = 1; - var exception = Record.Exception(() => { result = InvokeValue(nativeEvent); }); - - Assert.Null(exception); - Assert.Equal(0, result); - Assert.Equal(1, dispatcher.CallbackFailureCount); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - [Fact] - public void Cancelled_registration_and_null_event_do_not_invoke_user_code() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - using CancellationTokenSource cancellation = new(); - var handlerCalls = 0; - var dispatcher = RegisterWithCancellation((in DebugEventObservation _) => - { - handlerCalls++; - return DebugEventDecision.ContinueWithCheatEngine; - }, observations: null, cancellationToken: cancellation.Token); - cancellation.Cancel(); - - Assert.Equal(0, Invoke(null)); - NativeDebugEvent nativeEvent = new(1, 2, 3); - Assert.Equal(0, Invoke(&nativeEvent)); - Assert.Equal(0, handlerCalls); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - [Fact] - public void Full_observation_buffer_drops_only_observation_and_never_changes_the_native_result() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - BoundedDebugEventObservationBuffer observations = new(1, DebugEventObservationOverflowPolicy.DropNewest); - var dispatcher = Register(static (in DebugEventObservation _) => DebugEventDecision.ContinueWithCheatEngine, - observations); - NativeDebugEvent first = new(1, 2, 3); - NativeDebugEvent second = new(2, 3, 4); - - Assert.Equal(0, Invoke(&first)); - Assert.Equal(0, Invoke(&second)); - Assert.Equal(1, observations.DroppedObservationCount); - Assert.True(observations.TryRead(out var retained)); - Assert.Equal(1u, retained.EventCode); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - [Fact] - public void Release_closes_admission_before_unregistration_failure_and_keeps_the_root_for_retry() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - var handlerCalls = 0; - var dispatcher = Register((in DebugEventObservation _) => - { - handlerCalls++; - return DebugEventDecision.ContinueWithCheatEngine; - }); - s_unregisterSucceeds = false; - - Assert.Equal(ClassicDebugEventReleaseStatus.UnregisterUnconfirmed, dispatcher.TryRelease()); - NativeDebugEvent lateEvent = new(1, 2, 3); - Assert.Equal(0, Invoke(&lateEvent)); - Assert.Equal(0, handlerCalls); - - s_unregisterSucceeds = true; - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - Assert.Equal(2, s_unregisterCalls); - } - - [Fact] - public void Release_waits_for_an_admitted_callback_and_rejects_a_late_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - using ManualResetEventSlim handlerEntered = new(); - using ManualResetEventSlim allowHandlerToReturn = new(); - using ManualResetEventSlim unregisterEntered = new(); - using ManualResetEventSlim callbackCompleted = new(); - using ManualResetEventSlim releaseCompleted = new(); - var cancellationToken = TestContext.Current.CancellationToken; - s_unregisterEntered = unregisterEntered; - var handlerCalls = 0; - var dispatcher = Register((in DebugEventObservation _) => - { - handlerCalls++; - handlerEntered.Set(); - allowHandlerToReturn.Wait(cancellationToken); - return DebugEventDecision.ContinueWithCheatEngine; - }); - var eventMemory = (NativeDebugEvent*)NativeMemory.Alloc((nuint)sizeof(NativeDebugEvent)); - *eventMemory = new NativeDebugEvent(1, 2, 3); - var callbackResult = 1; - ClassicDebugEventReleaseStatus releaseResult = default; - - try - { - var eventAddress = (nint)eventMemory; - _ = Task.Run(() => - { - callbackResult = Invoke((NativeDebugEvent*)eventAddress); - callbackCompleted.Set(); - }, cancellationToken); - handlerEntered.Wait(cancellationToken); - _ = Task.Run(() => - { - releaseResult = dispatcher.TryRelease(); - releaseCompleted.Set(); - }, cancellationToken); - unregisterEntered.Wait(cancellationToken); - - NativeDebugEvent lateEvent = new(4, 5, 6); - Assert.Equal(0, Invoke(&lateEvent)); - Assert.Equal(1, handlerCalls); - Assert.False(releaseCompleted.IsSet); - Assert.Equal(ClassicDebugEventReleaseStatus.ReleaseInProgress, dispatcher.TryRelease()); - - allowHandlerToReturn.Set(); - callbackCompleted.Wait(cancellationToken); - releaseCompleted.Wait(cancellationToken); - Assert.Equal(0, callbackResult); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, releaseResult); - } - finally - { - NativeMemory.Free(eventMemory); - } - } - - [Fact] - public void Release_reentered_from_a_handler_is_refused_without_deadlocking_or_freeing_the_target() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - ClassicDebugEventDispatcher? dispatcher = null; - ClassicDebugEventReleaseStatus status = default; - dispatcher = Register((in DebugEventObservation _) => - { - status = dispatcher!.TryRelease(); - return DebugEventDecision.ContinueWithCheatEngine; - }); - NativeDebugEvent nativeEvent = new(1, 2, 3); - - Assert.Equal(0, Invoke(&nativeEvent)); - Assert.Equal(ClassicDebugEventReleaseStatus.CallbackIsExecuting, status); - Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); - } - - /// - public void Dispose() - { - s_unregisterSucceeds = true; - s_unregisterEntered = null; - _dispatcher?.TryRelease(); - _dispatcher = null; - s_callback = null; - s_unregisterCalls = 0; - } - - private ClassicDebugEventDispatcher Register( - DebugEventDecisionHandler handler, - BoundedDebugEventObservationBuffer? observations = null) - { - return RegisterWithCancellation(handler, observations, TestContext.Current.CancellationToken); - } - - private ClassicDebugEventDispatcher RegisterWithCancellation( - DebugEventDecisionHandler handler, - BoundedDebugEventObservationBuffer? observations, - CancellationToken cancellationToken) - { - s_unregisterSucceeds = true; - s_unregisterCalls = 0; - s_callback = null; - ExportedFunctionsPrefix exports = default; - exports.RegisterFunction = &RegisterFunction; - exports.UnregisterFunction = &UnregisterFunction; - - var status = ClassicDebugEventDispatcher.TryRegister(in exports, 77, handler, observations, cancellationToken, - out var dispatcher); - - Assert.Equal(ClassicDebugEventRegistrationStatus.Registered, status); - Assert.NotNull(dispatcher); - Assert.NotEqual((nint)0, (nint)s_callback); - _dispatcher = dispatcher; - return dispatcher; - } - - private static int Invoke(NativeDebugEvent* nativeEvent) - { - return s_callback(nativeEvent); - } - - private static int InvokeValue(NativeDebugEvent nativeEvent) - { - return Invoke(&nativeEvent); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int RegisterFunction(int pluginId, PluginType functionType, void* initialization) - { - Assert.Equal(77, pluginId); - Assert.Equal(PluginType.OnDebugEvent, functionType); - Assert.NotEqual((nint)0, (nint)initialization); - s_callback = ((DebugEventPluginInit*)initialization)->Callback; - return 901; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 UnregisterFunction(int pluginId, int functionId) - { - Assert.Equal(77, pluginId); - Assert.Equal(901, functionId); - s_unregisterCalls++; - s_unregisterEntered?.Set(); - return s_unregisterSucceeds; - } - - private static delegate* unmanaged[Stdcall] s_callback; - private static int s_unregisterCalls; - private static bool s_unregisterSucceeds; - private static ManualResetEventSlim? s_unregisterEntered; - - [StructLayout(LayoutKind.Sequential)] - private readonly struct NativeDebugEvent(uint eventCode, uint processId, uint threadId) - { - public readonly uint EventCode = eventCode; - public readonly uint ProcessId = processId; - public readonly uint ThreadId = threadId; - } + private static delegate* unmanaged[Stdcall] s_callback; + private static int s_unregisterCalls; + private static bool s_unregisterSucceeds; + private static ManualResetEventSlim? s_unregisterEntered; + private ClassicDebugEventDispatcher? _dispatcher; + + /// + public void Dispose() + { + s_unregisterSucceeds = true; + s_unregisterEntered = null; + _dispatcher?.TryRelease(); + _dispatcher = null; + s_callback = null; + s_unregisterCalls = 0; + } + + [Fact] + public void Callback_runs_the_synchronous_handler_with_a_scalar_copy_and_leaves_continuation_to_CheatEngine() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + DebugEventObservation received = default; + int handlerCalls = 0; + ClassicDebugEventDispatcher dispatcher = Register((in observation) => + { + handlerCalls++; + received = observation; + return DebugEventDecision.ContinueWithCheatEngine; + }); + NativeDebugEvent nativeEvent = new(0x0000_0006, 101, 202); + + int result = Invoke(&nativeEvent); + + Assert.Equal(0, result); + Assert.Equal(1, handlerCalls); + Assert.Equal(0x0000_0006u, received.EventCode); + Assert.Equal(101u, received.ProcessId); + Assert.Equal(202u, received.ThreadId); + Assert.True(received.SequenceNumber > 0); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Callback_copies_observations_before_the_native_buffer_can_be_reused() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + BoundedDebugEventObservationBuffer observations = new(1, DebugEventObservationOverflowPolicy.DropNewest); + ClassicDebugEventDispatcher dispatcher = Register(static (in _) => DebugEventDecision.ContinueWithCheatEngine, + observations); + NativeDebugEvent nativeEvent = new(3, 404, 505); + + Assert.Equal(0, Invoke(&nativeEvent)); + nativeEvent = new NativeDebugEvent(99, 0, 0); + + Assert.True(observations.TryRead(out DebugEventObservation copied)); + Assert.Equal(3u, copied.EventCode); + Assert.Equal(404u, copied.ProcessId); + Assert.Equal(505u, copied.ThreadId); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Plugin_owned_continuation_request_is_rejected_to_the_current_CheatEngine_owned_fallback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ClassicDebugEventDispatcher dispatcher = Register(static (in _) => DebugEventDecision.PluginOwnsContinuation); + NativeDebugEvent nativeEvent = new(1, 2, 3); + + Assert.Equal(0, Invoke(&nativeEvent)); + Assert.Equal(1, dispatcher.UnsupportedContinuationRequestCount); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Callback_exception_is_contained_and_returns_the_CheatEngine_owned_fallback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ClassicDebugEventDispatcher dispatcher = Register(static (in _) => throw new InvalidOperationException("boom")); + NativeDebugEvent nativeEvent = new(1, 2, 3); + + int result = 1; + Exception? exception = Record.Exception(() => + { + result = InvokeValue(nativeEvent); + }); + + Assert.Null(exception); + Assert.Equal(0, result); + Assert.Equal(1, dispatcher.CallbackFailureCount); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Cancelled_registration_and_null_event_do_not_invoke_user_code() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + using CancellationTokenSource cancellation = new(); + int handlerCalls = 0; + ClassicDebugEventDispatcher dispatcher = RegisterWithCancellation((in _) => + { + handlerCalls++; + return DebugEventDecision.ContinueWithCheatEngine; + }, null, cancellation.Token); + cancellation.Cancel(); + + Assert.Equal(0, Invoke(null)); + NativeDebugEvent nativeEvent = new(1, 2, 3); + Assert.Equal(0, Invoke(&nativeEvent)); + Assert.Equal(0, handlerCalls); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Full_observation_buffer_drops_only_observation_and_never_changes_the_native_result() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + BoundedDebugEventObservationBuffer observations = new(1, DebugEventObservationOverflowPolicy.DropNewest); + ClassicDebugEventDispatcher dispatcher = Register(static (in _) => DebugEventDecision.ContinueWithCheatEngine, + observations); + NativeDebugEvent first = new(1, 2, 3); + NativeDebugEvent second = new(2, 3, 4); + + Assert.Equal(0, Invoke(&first)); + Assert.Equal(0, Invoke(&second)); + Assert.Equal(1, observations.DroppedObservationCount); + Assert.True(observations.TryRead(out DebugEventObservation retained)); + Assert.Equal(1u, retained.EventCode); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Release_closes_admission_before_unregistration_failure_and_keeps_the_root_for_retry() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + int handlerCalls = 0; + ClassicDebugEventDispatcher dispatcher = Register((in _) => + { + handlerCalls++; + return DebugEventDecision.ContinueWithCheatEngine; + }); + s_unregisterSucceeds = false; + + Assert.Equal(ClassicDebugEventReleaseStatus.UnregisterUnconfirmed, dispatcher.TryRelease()); + NativeDebugEvent lateEvent = new(1, 2, 3); + Assert.Equal(0, Invoke(&lateEvent)); + Assert.Equal(0, handlerCalls); + + s_unregisterSucceeds = true; + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + Assert.Equal(2, s_unregisterCalls); + } + + [Fact] + public void Release_waits_for_an_admitted_callback_and_rejects_a_late_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + using ManualResetEventSlim handlerEntered = new(); + using ManualResetEventSlim allowHandlerToReturn = new(); + using ManualResetEventSlim unregisterEntered = new(); + using ManualResetEventSlim callbackCompleted = new(); + using ManualResetEventSlim releaseCompleted = new(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + s_unregisterEntered = unregisterEntered; + int handlerCalls = 0; + ClassicDebugEventDispatcher dispatcher = Register((in _) => + { + handlerCalls++; + handlerEntered.Set(); + allowHandlerToReturn.Wait(cancellationToken); + return DebugEventDecision.ContinueWithCheatEngine; + }); + NativeDebugEvent* eventMemory = (NativeDebugEvent*) NativeMemory.Alloc((nuint) sizeof(NativeDebugEvent)); + *eventMemory = new NativeDebugEvent(1, 2, 3); + int callbackResult = 1; + ClassicDebugEventReleaseStatus releaseResult = default; + + try + { + IntPtr eventAddress = (nint) eventMemory; + _ = Task.Run(() => + { + callbackResult = Invoke((NativeDebugEvent*) eventAddress); + callbackCompleted.Set(); + }, cancellationToken); + handlerEntered.Wait(cancellationToken); + _ = Task.Run(() => + { + releaseResult = dispatcher.TryRelease(); + releaseCompleted.Set(); + }, cancellationToken); + unregisterEntered.Wait(cancellationToken); + + NativeDebugEvent lateEvent = new(4, 5, 6); + Assert.Equal(0, Invoke(&lateEvent)); + Assert.Equal(1, handlerCalls); + Assert.False(releaseCompleted.IsSet); + Assert.Equal(ClassicDebugEventReleaseStatus.ReleaseInProgress, dispatcher.TryRelease()); + + allowHandlerToReturn.Set(); + callbackCompleted.Wait(cancellationToken); + releaseCompleted.Wait(cancellationToken); + Assert.Equal(0, callbackResult); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, releaseResult); + } + finally + { + NativeMemory.Free(eventMemory); + } + } + + [Fact] + public void Release_reentered_from_a_handler_is_refused_without_deadlocking_or_freeing_the_target() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ClassicDebugEventDispatcher? dispatcher = null; + ClassicDebugEventReleaseStatus status = default; + dispatcher = Register((in _) => + { + status = dispatcher!.TryRelease(); + return DebugEventDecision.ContinueWithCheatEngine; + }); + NativeDebugEvent nativeEvent = new(1, 2, 3); + + Assert.Equal(0, Invoke(&nativeEvent)); + Assert.Equal(ClassicDebugEventReleaseStatus.CallbackIsExecuting, status); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + private ClassicDebugEventDispatcher Register( + DebugEventDecisionHandler handler, + BoundedDebugEventObservationBuffer? observations = null) + { + return RegisterWithCancellation(handler, observations, TestContext.Current.CancellationToken); + } + + private ClassicDebugEventDispatcher RegisterWithCancellation( + DebugEventDecisionHandler handler, + BoundedDebugEventObservationBuffer? observations, + CancellationToken cancellationToken) + { + s_unregisterSucceeds = true; + s_unregisterCalls = 0; + s_callback = null; + ExportedFunctionsPrefix exports = default; + exports.RegisterFunction = &RegisterFunction; + exports.UnregisterFunction = &UnregisterFunction; + + ClassicDebugEventRegistrationStatus status = ClassicDebugEventDispatcher.TryRegister(in exports, 77, handler, + observations, cancellationToken, + out ClassicDebugEventDispatcher? dispatcher); + + Assert.Equal(ClassicDebugEventRegistrationStatus.Registered, status); + Assert.NotNull(dispatcher); + Assert.NotEqual(0, (nint) s_callback); + _dispatcher = dispatcher; + return dispatcher; + } + + private static int Invoke(NativeDebugEvent* nativeEvent) + { + return s_callback(nativeEvent); + } + + private static int InvokeValue(NativeDebugEvent nativeEvent) + { + return Invoke(&nativeEvent); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int RegisterFunction(int pluginId, PluginType functionType, void* initialization) + { + Assert.Equal(77, pluginId); + Assert.Equal(PluginType.OnDebugEvent, functionType); + Assert.NotEqual(0, (nint) initialization); + s_callback = ((DebugEventPluginInit*) initialization)->Callback; + return 901; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 UnregisterFunction(int pluginId, int functionId) + { + Assert.Equal(77, pluginId); + Assert.Equal(901, functionId); + s_unregisterCalls++; + s_unregisterEntered?.Set(); + return s_unregisterSucceeds; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct NativeDebugEvent(uint eventCode, uint processId, uint threadId) + { + public readonly uint EventCode = eventCode; + public readonly uint ProcessId = processId; + public readonly uint ThreadId = threadId; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs index 5b74b12f..23427dc6 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs @@ -1,6 +1,7 @@ +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Reflection; + using CheatEngine.SDK.Abi.Native; namespace CheatEngine.SDK.Abi.Tests.Native; @@ -10,126 +11,129 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// public sealed unsafe class ClassicExportedFunctionsPrefixReaderTests { - [Fact] - public void TryCopy_rejects_an_empty_table_representation() - { - var copied = ClassicExportedFunctionsPrefixReader.TryCopy(ReadOnlySpan.Empty, out var prefix); - - Assert.False(copied); - Assert.Equal(default, prefix); - } - - [Fact] - public void TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field() - { - Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DeclaredSizeByteCount - 1]; - - var copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out var prefix); - - Assert.False(copied); - Assert.Equal(default, prefix); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1)] - public void TryCopy_rejects_a_truncated_declared_table(int declaredSize) - { - Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; - WriteDeclaredSize(table, declaredSize); - - var copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out var prefix); - - Assert.False(copied); - Assert.Equal(default, prefix); - } - - [Fact] - public void TryCopy_rejects_a_physically_truncated_table_even_when_its_size_claim_is_sufficient() - { - Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1]; - WriteDeclaredSize(table, ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount); - - var copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out var prefix); - - Assert.False(copied); - Assert.Equal(default, prefix); - } - - [Theory] - [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount)] - [InlineData(int.MaxValue)] - public void TryCopy_copies_exactly_the_qualified_prefix_without_overflow(int declaredSize) - { - var processId = 0x2468u; - void* processHandle = (void*)0x1234_5678; - ExportedFunctionsPrefix expected = default; - expected.SizeOfExportedFunctions = declaredSize; - expected.ShowMessage = &FakeShowMessage; - expected.OpenedProcessId = &processId; - expected.OpenedProcessHandle = &processHandle; - expected.FixMemory = null; - expected.GetAddressFromPointer = (void*)0x55AA; - - Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; - MemoryMarshal.Write(table, in expected); - - var copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out var actual); - - Assert.True(copied); - Assert.Equal(declaredSize, actual.SizeOfExportedFunctions); - Assert.Equal((nint)expected.ShowMessage, (nint)actual.ShowMessage); - Assert.Equal(processId, *actual.OpenedProcessId); - Assert.Equal((nint)processHandle, (nint)(*actual.OpenedProcessHandle)); - Assert.Equal((nint)0, (nint)actual.FixMemory); - Assert.Equal((nint)0x55AA, (nint)actual.GetAddressFromPointer); - } - - [Fact] - public void Prefix_distinguishes_direct_function_slots_value_cells_and_opaque_null_slots_without_invocation() - { - var showMessage = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.ShowMessage)) - ?? throw new InvalidOperationException("The ShowMessage field was not found."); - var processId = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessId)) - ?? throw new InvalidOperationException("The OpenedProcessId field was not found."); - var processHandle = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessHandle)) - ?? throw new InvalidOperationException("The OpenedProcessHandle field was not found."); - var fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory)) - ?? throw new InvalidOperationException("The FixMemory field was not found."); - var getAddress = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.GetAddressFromPointer)) - ?? throw new InvalidOperationException("The GetAddressFromPointer field was not found."); - - var showMessageType = showMessage.GetModifiedFieldType().UnderlyingSystemType; - var processIdType = processId.GetModifiedFieldType().UnderlyingSystemType; - var processHandleType = processHandle.GetModifiedFieldType().UnderlyingSystemType; - var fixMemoryType = fixMemory.GetModifiedFieldType().UnderlyingSystemType; - var getAddressType = getAddress.GetModifiedFieldType().UnderlyingSystemType; - - Assert.True(showMessageType.IsFunctionPointer); - Assert.True(processIdType.IsPointer); - Assert.True(processHandleType.IsPointer); - Assert.Equal(typeof(uint), processIdType.GetElementType()); - Assert.True(processHandleType.GetElementType()?.IsPointer); - Assert.Equal(typeof(void), fixMemoryType.GetElementType()); - Assert.Equal(typeof(void), getAddressType.GetElementType()); - } - - [Fact] - public void Prefix_stops_before_the_hookable_pointer_cell_suffix() - { - Assert.Null(typeof(ExportedFunctionsPrefix).GetField("ReadProcessMemory", - BindingFlags.Instance | BindingFlags.Public)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void FakeShowMessage(byte* message) - { - _ = message; - } - - private static void WriteDeclaredSize(Span table, int value) - { - MemoryMarshal.Write(table, in value); - } + [Fact] + public void TryCopy_rejects_an_empty_table_representation() + { + bool copied = + ClassicExportedFunctionsPrefixReader.TryCopy(ReadOnlySpan.Empty, out ExportedFunctionsPrefix prefix); + + Assert.False(copied); + Assert.Equal(default, prefix); + } + + [Fact] + public void TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field() + { + Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DeclaredSizeByteCount - 1]; + + bool copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out ExportedFunctionsPrefix prefix); + + Assert.False(copied); + Assert.Equal(default, prefix); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1)] + public void TryCopy_rejects_a_truncated_declared_table(int declaredSize) + { + Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; + WriteDeclaredSize(table, declaredSize); + + bool copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out ExportedFunctionsPrefix prefix); + + Assert.False(copied); + Assert.Equal(default, prefix); + } + + [Fact] + public void TryCopy_rejects_a_physically_truncated_table_even_when_its_size_claim_is_sufficient() + { + Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1]; + WriteDeclaredSize(table, ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount); + + bool copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out ExportedFunctionsPrefix prefix); + + Assert.False(copied); + Assert.Equal(default, prefix); + } + + [Theory] + [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount)] + [InlineData(int.MaxValue)] + public void TryCopy_copies_exactly_the_qualified_prefix_without_overflow(int declaredSize) + { + uint processId = 0x2468u; + void* processHandle = (void*) 0x1234_5678; + ExportedFunctionsPrefix expected = default; + expected.SizeOfExportedFunctions = declaredSize; + expected.ShowMessage = &FakeShowMessage; + expected.OpenedProcessId = &processId; + expected.OpenedProcessHandle = &processHandle; + expected.FixMemory = null; + expected.GetAddressFromPointer = (void*) 0x55AA; + + Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; + MemoryMarshal.Write(table, in expected); + + bool copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out ExportedFunctionsPrefix actual); + + Assert.True(copied); + Assert.Equal(declaredSize, actual.SizeOfExportedFunctions); + Assert.Equal((nint) expected.ShowMessage, (nint) actual.ShowMessage); + Assert.Equal(processId, *actual.OpenedProcessId); + Assert.Equal((nint) processHandle, (nint) (*actual.OpenedProcessHandle)); + Assert.Equal(0, (nint) actual.FixMemory); + Assert.Equal(0x55AA, (nint) actual.GetAddressFromPointer); + } + + [Fact] + public void Prefix_distinguishes_direct_function_slots_value_cells_and_opaque_null_slots_without_invocation() + { + FieldInfo showMessage = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.ShowMessage)) + ?? throw new InvalidOperationException("The ShowMessage field was not found."); + FieldInfo processId = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessId)) + ?? throw new InvalidOperationException("The OpenedProcessId field was not found."); + FieldInfo processHandle = + typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessHandle)) + ?? throw new InvalidOperationException("The OpenedProcessHandle field was not found."); + FieldInfo fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory)) + ?? throw new InvalidOperationException("The FixMemory field was not found."); + FieldInfo getAddress = + typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.GetAddressFromPointer)) + ?? throw new InvalidOperationException("The GetAddressFromPointer field was not found."); + + Type showMessageType = showMessage.GetModifiedFieldType().UnderlyingSystemType; + Type processIdType = processId.GetModifiedFieldType().UnderlyingSystemType; + Type processHandleType = processHandle.GetModifiedFieldType().UnderlyingSystemType; + Type fixMemoryType = fixMemory.GetModifiedFieldType().UnderlyingSystemType; + Type getAddressType = getAddress.GetModifiedFieldType().UnderlyingSystemType; + + Assert.True(showMessageType.IsFunctionPointer); + Assert.True(processIdType.IsPointer); + Assert.True(processHandleType.IsPointer); + Assert.Equal(typeof(uint), processIdType.GetElementType()); + Assert.True(processHandleType.GetElementType()?.IsPointer); + Assert.Equal(typeof(void), fixMemoryType.GetElementType()); + Assert.Equal(typeof(void), getAddressType.GetElementType()); + } + + [Fact] + public void Prefix_stops_before_the_hookable_pointer_cell_suffix() + { + Assert.Null(typeof(ExportedFunctionsPrefix).GetField("ReadProcessMemory", + BindingFlags.Instance | BindingFlags.Public)); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void FakeShowMessage(byte* message) + { + _ = message; + } + + private static void WriteDeclaredSize(Span table, int value) + { + MemoryMarshal.Write(table, in value); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/DebugEventObservationBufferTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/DebugEventObservationBufferTests.cs index 86384a59..a82801a3 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/DebugEventObservationBufferTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/DebugEventObservationBufferTests.cs @@ -6,62 +6,62 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// Tests the copied observation layout and the intentionally lossy telemetry handoff. public sealed unsafe class DebugEventObservationBufferTests { - [Fact] - public void Observation_on_64_bit_has_the_documented_scalar_layout() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - DebugEventObservation observation = default; - void* origin = &observation; + [Fact] + public void Observation_on_64_bit_has_the_documented_scalar_layout() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + DebugEventObservation observation = default; + void* origin = &observation; - Assert.Equal(24, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &observation.SequenceNumber)); - Assert.Equal(8, Layout.OffsetOf(origin, &observation.EventCode)); - Assert.Equal(12, Layout.OffsetOf(origin, &observation.ProcessId)); - Assert.Equal(16, Layout.OffsetOf(origin, &observation.ThreadId)); - } + Assert.Equal(24, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &observation.SequenceNumber)); + Assert.Equal(8, Layout.OffsetOf(origin, &observation.EventCode)); + Assert.Equal(12, Layout.OffsetOf(origin, &observation.ProcessId)); + Assert.Equal(16, Layout.OffsetOf(origin, &observation.ThreadId)); + } - [Fact] - public void Drop_newest_keeps_the_existing_copy_and_reports_the_loss() - { - BoundedDebugEventObservationBuffer buffer = new(1, DebugEventObservationOverflowPolicy.DropNewest); - DebugEventObservation first = new(1, 10, 20, 30); - DebugEventObservation second = new(2, 11, 21, 31); + [Fact] + public void Drop_newest_keeps_the_existing_copy_and_reports_the_loss() + { + BoundedDebugEventObservationBuffer buffer = new(1, DebugEventObservationOverflowPolicy.DropNewest); + DebugEventObservation first = new(1, 10, 20, 30); + DebugEventObservation second = new(2, 11, 21, 31); - Assert.True(buffer.TryPublish(in first)); - Assert.False(buffer.TryPublish(in second)); - Assert.Equal(1, buffer.DroppedObservationCount); - Assert.True(buffer.TryRead(out var retained)); - Assert.Equal(first.SequenceNumber, retained.SequenceNumber); - Assert.False(buffer.TryRead(out _)); - } + Assert.True(buffer.TryPublish(in first)); + Assert.False(buffer.TryPublish(in second)); + Assert.Equal(1, buffer.DroppedObservationCount); + Assert.True(buffer.TryRead(out DebugEventObservation retained)); + Assert.Equal(first.SequenceNumber, retained.SequenceNumber); + Assert.False(buffer.TryRead(out _)); + } - [Fact] - public void Drop_oldest_keeps_the_newest_copy_and_reports_the_loss() - { - BoundedDebugEventObservationBuffer buffer = new(1, DebugEventObservationOverflowPolicy.DropOldest); - DebugEventObservation first = new(1, 10, 20, 30); - DebugEventObservation second = new(2, 11, 21, 31); + [Fact] + public void Drop_oldest_keeps_the_newest_copy_and_reports_the_loss() + { + BoundedDebugEventObservationBuffer buffer = new(1, DebugEventObservationOverflowPolicy.DropOldest); + DebugEventObservation first = new(1, 10, 20, 30); + DebugEventObservation second = new(2, 11, 21, 31); - Assert.True(buffer.TryPublish(in first)); - Assert.True(buffer.TryPublish(in second)); - Assert.Equal(1, buffer.DroppedObservationCount); - Assert.True(buffer.TryRead(out var retained)); - Assert.Equal(second.SequenceNumber, retained.SequenceNumber); - } + Assert.True(buffer.TryPublish(in first)); + Assert.True(buffer.TryPublish(in second)); + Assert.Equal(1, buffer.DroppedObservationCount); + Assert.True(buffer.TryRead(out DebugEventObservation retained)); + Assert.Equal(second.SequenceNumber, retained.SequenceNumber); + } - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Buffer_rejects_a_non_positive_capacity(int capacity) - { - Assert.Throws(() => - _ = new BoundedDebugEventObservationBuffer(capacity, DebugEventObservationOverflowPolicy.DropNewest)); - } + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Buffer_rejects_a_non_positive_capacity(int capacity) + { + Assert.Throws(() => + _ = new BoundedDebugEventObservationBuffer(capacity, DebugEventObservationOverflowPolicy.DropNewest)); + } - [Fact] - public void Buffer_rejects_an_unknown_overflow_policy() - { - Assert.Throws(() => - _ = new BoundedDebugEventObservationBuffer(1, (DebugEventObservationOverflowPolicy)99)); - } + [Fact] + public void Buffer_rejects_an_unknown_overflow_policy() + { + Assert.Throws(() => + _ = new BoundedDebugEventObservationBuffer(1, (DebugEventObservationOverflowPolicy) 99)); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs index d0500d66..56f29eea 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -12,109 +13,116 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// public sealed unsafe class ExportedFunctionsPrefixTests { - [Fact] - public void ExportedFunctionsPrefix_on_64_bit_matches_the_installed_C_header_layout() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - ExportedFunctionsPrefix exports = default; - void* origin = &exports; - - Assert.Equal(144, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); - Assert.Equal(8, Layout.OffsetOf(origin, &exports.ShowMessage)); - Assert.Equal(16, Layout.OffsetOf(origin, &exports.RegisterFunction)); - Assert.Equal(24, Layout.OffsetOf(origin, &exports.UnregisterFunction)); - Assert.Equal(32, Layout.OffsetOf(origin, &exports.OpenedProcessId)); - Assert.Equal(40, Layout.OffsetOf(origin, &exports.OpenedProcessHandle)); - Assert.Equal(48, Layout.OffsetOf(origin, &exports.GetMainWindowHandle)); - Assert.Equal(56, Layout.OffsetOf(origin, &exports.AutoAssemble)); - Assert.Equal(64, Layout.OffsetOf(origin, &exports.Assembler)); - Assert.Equal(72, Layout.OffsetOf(origin, &exports.Disassembler)); - Assert.Equal(80, Layout.OffsetOf(origin, &exports.ChangeRegistersAtAddress)); - Assert.Equal(88, Layout.OffsetOf(origin, &exports.InjectDll)); - Assert.Equal(96, Layout.OffsetOf(origin, &exports.FreezeMemory)); - Assert.Equal(104, Layout.OffsetOf(origin, &exports.UnfreezeMemory)); - Assert.Equal(112, Layout.OffsetOf(origin, &exports.FixMemory)); - Assert.Equal(120, Layout.OffsetOf(origin, &exports.ProcessList)); - Assert.Equal(128, Layout.OffsetOf(origin, &exports.ReloadSettings)); - Assert.Equal(136, Layout.OffsetOf(origin, &exports.GetAddressFromPointer)); - } - - [Fact] - public void Direct_function_slots_are_explicitly_stdcall() - { - var fields = typeof(ExportedFunctionsPrefix).GetFields(BindingFlags.Instance | BindingFlags.Public); - var functionPointerCount = 0; - - foreach (var field in fields) - { - var fieldType = field.GetModifiedFieldType(); - if (!fieldType.UnderlyingSystemType.IsFunctionPointer) continue; - - functionPointerCount++; - var conventions = fieldType.GetFunctionPointerCallingConventions(); - var convention = Assert.Single(conventions); - Assert.Equal(typeof(CallConvStdcall), convention); - } - - Assert.Equal(13, functionPointerCount); - } - - [Fact] - public void RegisterFunction_and_ChangeRegistersAtAddress_accept_the_declared_stdcall_shapes() - { - ExportedFunctionsPrefix exports = default; - exports.RegisterFunction = &FakeRegisterFunction; - exports.ChangeRegistersAtAddress = &FakeChangeRegistersAtAddress; - RegisterModificationInfo request = default; - request.ChangeR15 = Bool32.True; - - Assert.Equal(56, exports.RegisterFunction(42, PluginType.AutoAssembler, (void*)6)); - Assert.True(exports.ChangeRegistersAtAddress(0x1234, &request).IsTrue); - Assert.Equal((nuint)0x123C, request.NewR15); - } - - [Fact] - public void Prefix_stops_before_the_pointer_to_pointer_hook_suffix() - { - var fields = typeof(ExportedFunctionsPrefix).GetFields(BindingFlags.Instance | BindingFlags.Public); - - Assert.Null(typeof(ExportedFunctionsPrefix).GetField("ReadProcessMemory", - BindingFlags.Instance | BindingFlags.Public)); - } - - [Fact] - public void Historically_null_and_conflicting_slots_stay_opaque() - { - var fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory), - BindingFlags.Instance | BindingFlags.Public) - ?? throw new InvalidOperationException("The FixMemory field was not found."); - var getAddressFromPointer = typeof(ExportedFunctionsPrefix).GetField( - nameof(ExportedFunctionsPrefix.GetAddressFromPointer), - BindingFlags.Instance | BindingFlags.Public) - ?? throw new InvalidOperationException("The GetAddressFromPointer field was not found."); - - var fixMemoryType = fixMemory.GetModifiedFieldType().UnderlyingSystemType; - var getAddressFromPointerType = getAddressFromPointer.GetModifiedFieldType().UnderlyingSystemType; - - Assert.True(fixMemoryType.IsPointer); - Assert.Equal(typeof(void), fixMemoryType.GetElementType()); - Assert.True(getAddressFromPointerType.IsPointer); - Assert.Equal(typeof(void), getAddressFromPointerType.GetElementType()); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int FakeRegisterFunction(int pluginId, PluginType functionType, void* initializationRecord) - { - return pluginId + (int)functionType + (int)(nint)initializationRecord; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 FakeChangeRegistersAtAddress(nuint address, RegisterModificationInfo* changes) - { - if (changes is null || !changes->ChangeR15.IsTrue) return Bool32.False; - - changes->NewR15 = address + 8; - return Bool32.True; - } + [Fact] + public void ExportedFunctionsPrefix_on_64_bit_matches_the_installed_C_header_layout() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ExportedFunctionsPrefix exports = default; + void* origin = &exports; + + Assert.Equal(144, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); + Assert.Equal(8, Layout.OffsetOf(origin, &exports.ShowMessage)); + Assert.Equal(16, Layout.OffsetOf(origin, &exports.RegisterFunction)); + Assert.Equal(24, Layout.OffsetOf(origin, &exports.UnregisterFunction)); + Assert.Equal(32, Layout.OffsetOf(origin, &exports.OpenedProcessId)); + Assert.Equal(40, Layout.OffsetOf(origin, &exports.OpenedProcessHandle)); + Assert.Equal(48, Layout.OffsetOf(origin, &exports.GetMainWindowHandle)); + Assert.Equal(56, Layout.OffsetOf(origin, &exports.AutoAssemble)); + Assert.Equal(64, Layout.OffsetOf(origin, &exports.Assembler)); + Assert.Equal(72, Layout.OffsetOf(origin, &exports.Disassembler)); + Assert.Equal(80, Layout.OffsetOf(origin, &exports.ChangeRegistersAtAddress)); + Assert.Equal(88, Layout.OffsetOf(origin, &exports.InjectDll)); + Assert.Equal(96, Layout.OffsetOf(origin, &exports.FreezeMemory)); + Assert.Equal(104, Layout.OffsetOf(origin, &exports.UnfreezeMemory)); + Assert.Equal(112, Layout.OffsetOf(origin, &exports.FixMemory)); + Assert.Equal(120, Layout.OffsetOf(origin, &exports.ProcessList)); + Assert.Equal(128, Layout.OffsetOf(origin, &exports.ReloadSettings)); + Assert.Equal(136, Layout.OffsetOf(origin, &exports.GetAddressFromPointer)); + } + + [Fact] + public void Direct_function_slots_are_explicitly_stdcall() + { + FieldInfo[] fields = typeof(ExportedFunctionsPrefix).GetFields(BindingFlags.Instance | BindingFlags.Public); + int functionPointerCount = 0; + + foreach (FieldInfo field in fields) + { + Type fieldType = field.GetModifiedFieldType(); + if (!fieldType.UnderlyingSystemType.IsFunctionPointer) + { + continue; + } + + functionPointerCount++; + Type[] conventions = fieldType.GetFunctionPointerCallingConventions(); + Type convention = Assert.Single(conventions); + Assert.Equal(typeof(CallConvStdcall), convention); + } + + Assert.Equal(13, functionPointerCount); + } + + [Fact] + public void RegisterFunction_and_ChangeRegistersAtAddress_accept_the_declared_stdcall_shapes() + { + ExportedFunctionsPrefix exports = default; + exports.RegisterFunction = &FakeRegisterFunction; + exports.ChangeRegistersAtAddress = &FakeChangeRegistersAtAddress; + RegisterModificationInfo request = default; + request.ChangeR15 = Bool32.True; + + Assert.Equal(56, exports.RegisterFunction(42, PluginType.AutoAssembler, (void*) 6)); + Assert.True(exports.ChangeRegistersAtAddress(0x1234, &request).IsTrue); + Assert.Equal((nuint) 0x123C, request.NewR15); + } + + [Fact] + public void Prefix_stops_before_the_pointer_to_pointer_hook_suffix() + { + FieldInfo[] fields = typeof(ExportedFunctionsPrefix).GetFields(BindingFlags.Instance | BindingFlags.Public); + + Assert.Null(typeof(ExportedFunctionsPrefix).GetField("ReadProcessMemory", + BindingFlags.Instance | BindingFlags.Public)); + } + + [Fact] + public void Historically_null_and_conflicting_slots_stay_opaque() + { + FieldInfo fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory), + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("The FixMemory field was not found."); + FieldInfo getAddressFromPointer = typeof(ExportedFunctionsPrefix).GetField( + nameof(ExportedFunctionsPrefix.GetAddressFromPointer), + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException( + "The GetAddressFromPointer field was not found."); + + Type fixMemoryType = fixMemory.GetModifiedFieldType().UnderlyingSystemType; + Type getAddressFromPointerType = getAddressFromPointer.GetModifiedFieldType().UnderlyingSystemType; + + Assert.True(fixMemoryType.IsPointer); + Assert.Equal(typeof(void), fixMemoryType.GetElementType()); + Assert.True(getAddressFromPointerType.IsPointer); + Assert.Equal(typeof(void), getAddressFromPointerType.GetElementType()); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int FakeRegisterFunction(int pluginId, PluginType functionType, void* initializationRecord) + { + return pluginId + (int) functionType + (int) (nint) initializationRecord; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 FakeChangeRegistersAtAddress(nuint address, RegisterModificationInfo* changes) + { + if (changes is null || !changes->ChangeR15.IsTrue) + { + return Bool32.False; + } + + changes->NewR15 = address + 8; + return Bool32.True; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/NativeExportNamesTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/NativeExportNamesTests.cs index adf59ef0..682591e3 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/NativeExportNamesTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/NativeExportNamesTests.cs @@ -4,11 +4,11 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed class NativeExportNamesTests { - [Fact] - public void Names_match_the_exports_cheat_engine_resolves() - { - Assert.Equal("CEPlugin_GetVersion", NativeExportNames.GetVersion); - Assert.Equal("CEPlugin_InitializePlugin", NativeExportNames.InitializePlugin); - Assert.Equal("CEPlugin_DisablePlugin", NativeExportNames.DisablePlugin); - } + [Fact] + public void Names_match_the_exports_cheat_engine_resolves() + { + Assert.Equal("CEPlugin_GetVersion", NativeExportNames.GetVersion); + Assert.Equal("CEPlugin_InitializePlugin", NativeExportNames.InitializePlugin); + Assert.Equal("CEPlugin_DisablePlugin", NativeExportNames.DisablePlugin); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginCallbackShapeTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginCallbackShapeTests.cs index 2de75d68..42cdcd12 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginCallbackShapeTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginCallbackShapeTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Native; namespace CheatEngine.SDK.Abi.Tests.Native; @@ -11,162 +12,162 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// public sealed unsafe class PluginCallbackShapeTests { - private static int s_mainMenuCalls; - - [Fact] - public void AddressList_callback_slot_stays_opaque_until_a_live_canary_qualifies_the_selection_record() - { - AddressListPluginInit init = default; - delegate* unmanaged[Stdcall] function = &FakeMainMenu; - init.Callback = function; - - Assert.Equal((nint)function, (nint)init.Callback); - } - - [Fact] - public void MemoryView_callback_takes_three_in_out_addresses_and_returns_Bool32() - { - MemoryViewPluginInit init = default; - init.Callback = &FakeMemoryView; - nuint disassembler = 1; - nuint selected = 2; - nuint hexView = 0x7FFF_0000; - - var result = init.Callback(&disassembler, &selected, &hexView); - - Assert.True(result.IsTrue); - Assert.Equal(hexView, disassembler); - Assert.Equal((nuint)2, selected); - } - - [Fact] - public void DebugEvent_callback_takes_an_event_pointer_and_returns_int() - { - DebugEventPluginInit init = default; - init.Callback = &FakeDebugEvent; - - Assert.Equal(1, init.Callback((void*)0x20)); - Assert.Equal(0, init.Callback(null)); - } - - [Fact] - public void MainMenu_callback_takes_nothing_and_returns_nothing() - { - MainMenuPluginInit init = default; - init.Callback = &FakeMainMenu; - var before = s_mainMenuCalls; - - init.Callback(); - - Assert.Equal(before + 1, s_mainMenuCalls); - } - - [Fact] - public void DisassemblerContext_click_slot_stays_opaque_until_a_live_canary_qualifies_its_boolean_width() - { - DisassemblerContextPluginInit init = default; - delegate* unmanaged[Stdcall] function = &FakeMainMenu; - init.Callback = function; - - Assert.Equal((nint)function, (nint)init.Callback); - } - - [Fact] - public void DisassemblerContext_popup_slot_stays_opaque_until_a_live_canary_establishes_its_shape() - { - DisassemblerContextPluginInit init = default; - delegate* unmanaged[Stdcall] function = &FakeMainMenu; - init.CallbackOnPopup = function; - - Assert.Equal((nint)function, (nint)init.CallbackOnPopup); - } - - [Fact] - public void DisassemblerRenderLine_callback_takes_address_four_texts_and_a_colour() - { - DisassemblerRenderLinePluginInit init = default; - init.Callback = &FakeRenderLine; - byte* addressText = null; - byte* bytesText = null; - byte* opcodeText = null; - byte* specialText = null; - uint colour = 0; - - init.Callback(0x40_0000, &addressText, &bytesText, &opcodeText, &specialText, &colour); - - Assert.Equal(1, (nint)addressText); - Assert.Equal(2, (nint)bytesText); - Assert.Equal(3, (nint)opcodeText); - Assert.Equal(4, (nint)specialText); - Assert.Equal(0x00FF_00FFu, colour); - } - - [Fact] - public void AutoAssembler_callback_takes_line_phase_and_id() - { - AutoAssemblerPluginInit init = default; - init.Callback = &FakeAutoAssembler; - byte* line = null; - - init.Callback(&line, AutoAssemblerPhase.Phase2, 77); - - Assert.Equal(((nint)AutoAssemblerPhase.Phase2 << 16) | 77, (nint)line); - } - - /// - /// The two disputed callbacks stay untyped: the slot accepts any function address, nothing is implied about its - /// shape. - /// - [Fact] - public void Untyped_callbacks_round_trip_a_function_address() - { - delegate* unmanaged[Stdcall] function = &FakeMainMenu; - ProcessWatcherPluginInit processWatcher = default; - FunctionPointerChangePluginInit pointerChange = default; - - processWatcher.Callback = function; - pointerChange.Callback = function; - - Assert.Equal((nint)function, (nint)processWatcher.Callback); - Assert.Equal((nint)function, Unsafe.ReadUnaligned(&pointerChange)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool32 FakeMemoryView(nuint* disassemblerAddress, nuint* selectedDisassemblerAddress, - nuint* hexViewAddress) - { - _ = selectedDisassemblerAddress; - *disassemblerAddress = *hexViewAddress; - return Bool32.True; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static int FakeDebugEvent(void* debugEvent) - { - return debugEvent is null ? 0 : 1; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void FakeMainMenu() - { - s_mainMenuCalls++; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void FakeRenderLine(nuint address, byte** addressText, byte** bytesText, byte** opcodeText, - byte** specialText, uint* textColour) - { - _ = address; - *addressText = (byte*)1; - *bytesText = (byte*)2; - *opcodeText = (byte*)3; - *specialText = (byte*)4; - *textColour = 0x00FF_00FF; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void FakeAutoAssembler(byte** line, AutoAssemblerPhase phase, int id) - { - *line = (byte*)(((nint)phase << 16) | id); - } + private static int s_mainMenuCalls; + + [Fact] + public void AddressList_callback_slot_stays_opaque_until_a_live_canary_qualifies_the_selection_record() + { + AddressListPluginInit init = default; + delegate* unmanaged[Stdcall] function = &FakeMainMenu; + init.Callback = function; + + Assert.Equal((nint) function, (nint) init.Callback); + } + + [Fact] + public void MemoryView_callback_takes_three_in_out_addresses_and_returns_Bool32() + { + MemoryViewPluginInit init = default; + init.Callback = &FakeMemoryView; + nuint disassembler = 1; + nuint selected = 2; + nuint hexView = 0x7FFF_0000; + + Bool32 result = init.Callback(&disassembler, &selected, &hexView); + + Assert.True(result.IsTrue); + Assert.Equal(hexView, disassembler); + Assert.Equal((nuint) 2, selected); + } + + [Fact] + public void DebugEvent_callback_takes_an_event_pointer_and_returns_int() + { + DebugEventPluginInit init = default; + init.Callback = &FakeDebugEvent; + + Assert.Equal(1, init.Callback((void*) 0x20)); + Assert.Equal(0, init.Callback(null)); + } + + [Fact] + public void MainMenu_callback_takes_nothing_and_returns_nothing() + { + MainMenuPluginInit init = default; + init.Callback = &FakeMainMenu; + int before = s_mainMenuCalls; + + init.Callback(); + + Assert.Equal(before + 1, s_mainMenuCalls); + } + + [Fact] + public void DisassemblerContext_click_slot_stays_opaque_until_a_live_canary_qualifies_its_boolean_width() + { + DisassemblerContextPluginInit init = default; + delegate* unmanaged[Stdcall] function = &FakeMainMenu; + init.Callback = function; + + Assert.Equal((nint) function, (nint) init.Callback); + } + + [Fact] + public void DisassemblerContext_popup_slot_stays_opaque_until_a_live_canary_establishes_its_shape() + { + DisassemblerContextPluginInit init = default; + delegate* unmanaged[Stdcall] function = &FakeMainMenu; + init.CallbackOnPopup = function; + + Assert.Equal((nint) function, (nint) init.CallbackOnPopup); + } + + [Fact] + public void DisassemblerRenderLine_callback_takes_address_four_texts_and_a_colour() + { + DisassemblerRenderLinePluginInit init = default; + init.Callback = &FakeRenderLine; + byte* addressText = null; + byte* bytesText = null; + byte* opcodeText = null; + byte* specialText = null; + uint colour = 0; + + init.Callback(0x40_0000, &addressText, &bytesText, &opcodeText, &specialText, &colour); + + Assert.Equal(1, (nint) addressText); + Assert.Equal(2, (nint) bytesText); + Assert.Equal(3, (nint) opcodeText); + Assert.Equal(4, (nint) specialText); + Assert.Equal(0x00FF_00FFu, colour); + } + + [Fact] + public void AutoAssembler_callback_takes_line_phase_and_id() + { + AutoAssemblerPluginInit init = default; + init.Callback = &FakeAutoAssembler; + byte* line = null; + + init.Callback(&line, AutoAssemblerPhase.Phase2, 77); + + Assert.Equal(((nint) AutoAssemblerPhase.Phase2 << 16) | 77, (nint) line); + } + + /// + /// The two disputed callbacks stay untyped: the slot accepts any function address, nothing is implied about its + /// shape. + /// + [Fact] + public void Untyped_callbacks_round_trip_a_function_address() + { + delegate* unmanaged[Stdcall] function = &FakeMainMenu; + ProcessWatcherPluginInit processWatcher = default; + FunctionPointerChangePluginInit pointerChange = default; + + processWatcher.Callback = function; + pointerChange.Callback = function; + + Assert.Equal((nint) function, (nint) processWatcher.Callback); + Assert.Equal((nint) function, Unsafe.ReadUnaligned(&pointerChange)); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 FakeMemoryView(nuint* disassemblerAddress, nuint* selectedDisassemblerAddress, + nuint* hexViewAddress) + { + _ = selectedDisassemblerAddress; + *disassemblerAddress = *hexViewAddress; + return Bool32.True; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int FakeDebugEvent(void* debugEvent) + { + return debugEvent is null ? 0 : 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void FakeMainMenu() + { + s_mainMenuCalls++; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void FakeRenderLine(nuint address, byte** addressText, byte** bytesText, byte** opcodeText, + byte** specialText, uint* textColour) + { + _ = address; + *addressText = (byte*) 1; + *bytesText = (byte*) 2; + *opcodeText = (byte*) 3; + *specialText = (byte*) 4; + *textColour = 0x00FF_00FF; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void FakeAutoAssembler(byte** line, AutoAssemblerPhase phase, int id) + { + *line = (byte*) (((nint) phase << 16) | id); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginInitStructsTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginInitStructsTests.cs index f37fd597..65731ed0 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginInitStructsTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginInitStructsTests.cs @@ -6,105 +6,105 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// Layout of the nine registration records of the classic (native) path. public sealed unsafe class PluginInitStructsTests { - [Fact] - public void AddressListPluginInit_on_64_bit_is_name_then_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - AddressListPluginInit init = default; - void* origin = &init; - - Assert.Equal(16, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); - Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); - } - - [Fact] - public void MemoryViewPluginInit_on_64_bit_is_name_callback_shortcut() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - MemoryViewPluginInit init = default; - void* origin = &init; - - Assert.Equal(24, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); - Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); - Assert.Equal(16, Layout.OffsetOf(origin, &init.Shortcut)); - } - - [Fact] - public void DebugEventPluginInit_on_64_bit_is_a_single_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - DebugEventPluginInit init = default; - - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); - } - - [Fact] - public void ProcessWatcherPluginInit_on_64_bit_is_a_single_untyped_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - ProcessWatcherPluginInit init = default; - - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); - } - - [Fact] - public void FunctionPointerChangePluginInit_on_64_bit_is_a_single_untyped_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - FunctionPointerChangePluginInit init = default; - - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); - } - - [Fact] - public void MainMenuPluginInit_on_64_bit_is_name_callback_shortcut() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - MainMenuPluginInit init = default; - void* origin = &init; - - Assert.Equal(24, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); - Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); - Assert.Equal(16, Layout.OffsetOf(origin, &init.Shortcut)); - } - - [Fact] - public void DisassemblerContextPluginInit_on_64_bit_is_name_callback_popup_shortcut() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - DisassemblerContextPluginInit init = default; - void* origin = &init; - - Assert.Equal(32, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); - Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); - Assert.Equal(16, Layout.OffsetOf(origin, &init.CallbackOnPopup)); - Assert.Equal(24, Layout.OffsetOf(origin, &init.Shortcut)); - } - - [Fact] - public void DisassemblerRenderLinePluginInit_on_64_bit_is_a_single_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - DisassemblerRenderLinePluginInit init = default; - - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); - } - - [Fact] - public void AutoAssemblerPluginInit_on_64_bit_is_a_single_callback() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - AutoAssemblerPluginInit init = default; - - Assert.Equal(8, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); - } + [Fact] + public void AddressListPluginInit_on_64_bit_is_name_then_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + AddressListPluginInit init = default; + void* origin = &init; + + Assert.Equal(16, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); + Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); + } + + [Fact] + public void MemoryViewPluginInit_on_64_bit_is_name_callback_shortcut() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + MemoryViewPluginInit init = default; + void* origin = &init; + + Assert.Equal(24, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); + Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); + Assert.Equal(16, Layout.OffsetOf(origin, &init.Shortcut)); + } + + [Fact] + public void DebugEventPluginInit_on_64_bit_is_a_single_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + DebugEventPluginInit init = default; + + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); + } + + [Fact] + public void ProcessWatcherPluginInit_on_64_bit_is_a_single_untyped_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ProcessWatcherPluginInit init = default; + + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); + } + + [Fact] + public void FunctionPointerChangePluginInit_on_64_bit_is_a_single_untyped_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + FunctionPointerChangePluginInit init = default; + + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); + } + + [Fact] + public void MainMenuPluginInit_on_64_bit_is_name_callback_shortcut() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + MainMenuPluginInit init = default; + void* origin = &init; + + Assert.Equal(24, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); + Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); + Assert.Equal(16, Layout.OffsetOf(origin, &init.Shortcut)); + } + + [Fact] + public void DisassemblerContextPluginInit_on_64_bit_is_name_callback_popup_shortcut() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + DisassemblerContextPluginInit init = default; + void* origin = &init; + + Assert.Equal(32, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &init.Name)); + Assert.Equal(8, Layout.OffsetOf(origin, &init.Callback)); + Assert.Equal(16, Layout.OffsetOf(origin, &init.CallbackOnPopup)); + Assert.Equal(24, Layout.OffsetOf(origin, &init.Shortcut)); + } + + [Fact] + public void DisassemblerRenderLinePluginInit_on_64_bit_is_a_single_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + DisassemblerRenderLinePluginInit init = default; + + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); + } + + [Fact] + public void AutoAssemblerPluginInit_on_64_bit_is_a_single_callback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + AutoAssemblerPluginInit init = default; + + Assert.Equal(8, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(&init, &init.Callback)); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs index fb3bcbe9..4bd2b909 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs @@ -9,28 +9,28 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// public sealed unsafe class PluginType0RecordTests { - [Fact] - public void PluginType0Record_on_64_bit_matches_the_installed_C_header_layout() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - PluginType0Record record = default; - void* origin = &record; + [Fact] + public void PluginType0Record_on_64_bit_matches_the_installed_C_header_layout() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + PluginType0Record record = default; + void* origin = &record; - Assert.Equal(48, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &record.InterpretedAddress)); - Assert.Equal(8, Layout.OffsetOf(origin, &record.Address)); - Assert.Equal(16, Layout.OffsetOf(origin, &record.IsPointer)); - Assert.Equal(20, Layout.OffsetOf(origin, &record.CountOffsets)); - Assert.Equal(24, Layout.OffsetOf(origin, &record.Offsets)); - Assert.Equal(32, Layout.OffsetOf(origin, &record.Description)); - Assert.Equal(40, Layout.OffsetOf(origin, &record.ValueType)); - Assert.Equal(41, Layout.OffsetOf(origin, &record.Size)); - } + Assert.Equal(48, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &record.InterpretedAddress)); + Assert.Equal(8, Layout.OffsetOf(origin, &record.Address)); + Assert.Equal(16, Layout.OffsetOf(origin, &record.IsPointer)); + Assert.Equal(20, Layout.OffsetOf(origin, &record.CountOffsets)); + Assert.Equal(24, Layout.OffsetOf(origin, &record.Offsets)); + Assert.Equal(32, Layout.OffsetOf(origin, &record.Description)); + Assert.Equal(40, Layout.OffsetOf(origin, &record.ValueType)); + Assert.Equal(41, Layout.OffsetOf(origin, &record.Size)); + } - [Fact] - public void PluginType0Record_preserves_the_header_boolean_width_and_32_bit_offset_element_width() - { - Assert.Equal(4, Layout.SizeOf()); - Assert.Equal(4, sizeof(uint)); - } + [Fact] + public void PluginType0Record_preserves_the_header_boolean_width_and_32_bit_offset_element_width() + { + Assert.Equal(4, Layout.SizeOf()); + Assert.Equal(4, sizeof(uint)); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginTypeTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginTypeTests.cs index 0ff5e896..b3e3c8c3 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginTypeTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginTypeTests.cs @@ -5,31 +5,31 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed class PluginTypeTests { - [Theory] - [InlineData(PluginType.AddressList, 0)] - [InlineData(PluginType.MemoryView, 1)] - [InlineData(PluginType.OnDebugEvent, 2)] - [InlineData(PluginType.ProcessWatcherEvent, 3)] - [InlineData(PluginType.FunctionPointerChange, 4)] - [InlineData(PluginType.MainMenu, 5)] - [InlineData(PluginType.DisassemblerContext, 6)] - [InlineData(PluginType.DisassemblerRenderLine, 7)] - [InlineData(PluginType.AutoAssembler, 8)] - public void Member_has_the_upstream_numeric_value(PluginType member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(PluginType.AddressList, 0)] + [InlineData(PluginType.MemoryView, 1)] + [InlineData(PluginType.OnDebugEvent, 2)] + [InlineData(PluginType.ProcessWatcherEvent, 3)] + [InlineData(PluginType.FunctionPointerChange, 4)] + [InlineData(PluginType.MainMenu, 5)] + [InlineData(PluginType.DisassemblerContext, 6)] + [InlineData(PluginType.DisassemblerRenderLine, 7)] + [InlineData(PluginType.AutoAssembler, 8)] + public void Member_has_the_upstream_numeric_value(PluginType member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Fact] - public void Enum_has_exactly_the_nine_upstream_members() - { - Assert.Equal(9, Enum.GetValues().Length); - } + [Fact] + public void Enum_has_exactly_the_nine_upstream_members() + { + Assert.Equal(9, Enum.GetValues().Length); + } - [Fact] - public void Enum_is_four_bytes_wide_like_the_c_enumeration() - { - Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(PluginType))); - Assert.Equal(4, Layout.SizeOf()); - } + [Fact] + public void Enum_is_four_bytes_wide_like_the_c_enumeration() + { + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(PluginType))); + Assert.Equal(4, Layout.SizeOf()); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs index 864b8e19..2814d030 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; + using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -6,38 +7,38 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed unsafe class PluginVersionTests { - [Fact] - public void Size_on_64_bit_is_16_bytes() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - - Assert.Equal(16, Layout.SizeOf()); - } - - [Fact] - public void Field_offsets_on_64_bit_match_the_c_structure() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - PluginVersion version = default; - void* origin = &version; - - Assert.Equal(0, Layout.OffsetOf(origin, &version.Version)); - Assert.Equal(8, Layout.OffsetOf(origin, &version.PluginName)); - } - - [Fact] - public void Write_through_a_pointer_on_64_bit_leaves_the_padding_alone_and_places_the_name_at_8() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - var raw = stackalloc byte[16]; - new Span(raw, 16).Fill(0xCC); - - var version = (PluginVersion*)raw; - version->Version = AbiConstants.SdkVersion; - version->PluginName = (byte*)0x0A0B_0C0D_0E0F_1011; - - Assert.Equal(6u, Unsafe.ReadUnaligned(raw)); - Assert.Equal(0xCCCC_CCCCu, Unsafe.ReadUnaligned(raw + 4)); - Assert.Equal(0x0A0B_0C0D_0E0F_1011UL, Unsafe.ReadUnaligned(raw + 8)); - } + [Fact] + public void Size_on_64_bit_is_16_bytes() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(16, Layout.SizeOf()); + } + + [Fact] + public void Field_offsets_on_64_bit_match_the_c_structure() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + PluginVersion version = default; + void* origin = &version; + + Assert.Equal(0, Layout.OffsetOf(origin, &version.Version)); + Assert.Equal(8, Layout.OffsetOf(origin, &version.PluginName)); + } + + [Fact] + public void Write_through_a_pointer_on_64_bit_leaves_the_padding_alone_and_places_the_name_at_8() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte* raw = stackalloc byte[16]; + new Span(raw, 16).Fill(0xCC); + + PluginVersion* version = (PluginVersion*) raw; + version->Version = AbiConstants.SdkVersion; + version->PluginName = (byte*) 0x0A0B_0C0D_0E0F_1011; + + Assert.Equal(6u, Unsafe.ReadUnaligned(raw)); + Assert.Equal(0xCCCC_CCCCu, Unsafe.ReadUnaligned(raw + 4)); + Assert.Equal(0x0A0B_0C0D_0E0F_1011UL, Unsafe.ReadUnaligned(raw + 8)); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/RegisterModificationInfoTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/RegisterModificationInfoTests.cs index 03076f4d..76b64e9d 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/RegisterModificationInfoTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/RegisterModificationInfoTests.cs @@ -9,71 +9,71 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// public sealed unsafe class RegisterModificationInfoTests { - [Fact] - public void RegisterModificationInfo_change_flags_on_64_bit_match_the_installed_C_header_layout() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - RegisterModificationInfo info = default; - void* origin = &info; + [Fact] + public void RegisterModificationInfo_change_flags_on_64_bit_match_the_installed_C_header_layout() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + RegisterModificationInfo info = default; + void* origin = &info; - Assert.Equal(264, Layout.SizeOf()); - Assert.Equal(0, Layout.OffsetOf(origin, &info.Address)); + Assert.Equal(264, Layout.SizeOf()); + Assert.Equal(0, Layout.OffsetOf(origin, &info.Address)); - Assert.Equal(8, Layout.OffsetOf(origin, &info.ChangeEax)); - Assert.Equal(12, Layout.OffsetOf(origin, &info.ChangeEbx)); - Assert.Equal(16, Layout.OffsetOf(origin, &info.ChangeEcx)); - Assert.Equal(20, Layout.OffsetOf(origin, &info.ChangeEdx)); - Assert.Equal(24, Layout.OffsetOf(origin, &info.ChangeEsi)); - Assert.Equal(28, Layout.OffsetOf(origin, &info.ChangeEdi)); - Assert.Equal(32, Layout.OffsetOf(origin, &info.ChangeEbp)); - Assert.Equal(36, Layout.OffsetOf(origin, &info.ChangeEsp)); - Assert.Equal(40, Layout.OffsetOf(origin, &info.ChangeEip)); - Assert.Equal(44, Layout.OffsetOf(origin, &info.ChangeR8)); - Assert.Equal(48, Layout.OffsetOf(origin, &info.ChangeR9)); - Assert.Equal(52, Layout.OffsetOf(origin, &info.ChangeR10)); - Assert.Equal(56, Layout.OffsetOf(origin, &info.ChangeR11)); - Assert.Equal(60, Layout.OffsetOf(origin, &info.ChangeR12)); - Assert.Equal(64, Layout.OffsetOf(origin, &info.ChangeR13)); - Assert.Equal(68, Layout.OffsetOf(origin, &info.ChangeR14)); - Assert.Equal(72, Layout.OffsetOf(origin, &info.ChangeR15)); - Assert.Equal(76, Layout.OffsetOf(origin, &info.ChangeCf)); - Assert.Equal(80, Layout.OffsetOf(origin, &info.ChangePf)); - Assert.Equal(84, Layout.OffsetOf(origin, &info.ChangeAf)); - Assert.Equal(88, Layout.OffsetOf(origin, &info.ChangeZf)); - Assert.Equal(92, Layout.OffsetOf(origin, &info.ChangeSf)); - Assert.Equal(96, Layout.OffsetOf(origin, &info.ChangeOf)); - } + Assert.Equal(8, Layout.OffsetOf(origin, &info.ChangeEax)); + Assert.Equal(12, Layout.OffsetOf(origin, &info.ChangeEbx)); + Assert.Equal(16, Layout.OffsetOf(origin, &info.ChangeEcx)); + Assert.Equal(20, Layout.OffsetOf(origin, &info.ChangeEdx)); + Assert.Equal(24, Layout.OffsetOf(origin, &info.ChangeEsi)); + Assert.Equal(28, Layout.OffsetOf(origin, &info.ChangeEdi)); + Assert.Equal(32, Layout.OffsetOf(origin, &info.ChangeEbp)); + Assert.Equal(36, Layout.OffsetOf(origin, &info.ChangeEsp)); + Assert.Equal(40, Layout.OffsetOf(origin, &info.ChangeEip)); + Assert.Equal(44, Layout.OffsetOf(origin, &info.ChangeR8)); + Assert.Equal(48, Layout.OffsetOf(origin, &info.ChangeR9)); + Assert.Equal(52, Layout.OffsetOf(origin, &info.ChangeR10)); + Assert.Equal(56, Layout.OffsetOf(origin, &info.ChangeR11)); + Assert.Equal(60, Layout.OffsetOf(origin, &info.ChangeR12)); + Assert.Equal(64, Layout.OffsetOf(origin, &info.ChangeR13)); + Assert.Equal(68, Layout.OffsetOf(origin, &info.ChangeR14)); + Assert.Equal(72, Layout.OffsetOf(origin, &info.ChangeR15)); + Assert.Equal(76, Layout.OffsetOf(origin, &info.ChangeCf)); + Assert.Equal(80, Layout.OffsetOf(origin, &info.ChangePf)); + Assert.Equal(84, Layout.OffsetOf(origin, &info.ChangeAf)); + Assert.Equal(88, Layout.OffsetOf(origin, &info.ChangeZf)); + Assert.Equal(92, Layout.OffsetOf(origin, &info.ChangeSf)); + Assert.Equal(96, Layout.OffsetOf(origin, &info.ChangeOf)); + } - [Fact] - public void RegisterModificationInfo_replacement_values_on_64_bit_match_the_installed_C_header_layout() - { - Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); - RegisterModificationInfo info = default; - void* origin = &info; + [Fact] + public void RegisterModificationInfo_replacement_values_on_64_bit_match_the_installed_C_header_layout() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + RegisterModificationInfo info = default; + void* origin = &info; - Assert.Equal(104, Layout.OffsetOf(origin, &info.NewEax)); - Assert.Equal(112, Layout.OffsetOf(origin, &info.NewEbx)); - Assert.Equal(120, Layout.OffsetOf(origin, &info.NewEcx)); - Assert.Equal(128, Layout.OffsetOf(origin, &info.NewEdx)); - Assert.Equal(136, Layout.OffsetOf(origin, &info.NewEsi)); - Assert.Equal(144, Layout.OffsetOf(origin, &info.NewEdi)); - Assert.Equal(152, Layout.OffsetOf(origin, &info.NewEbp)); - Assert.Equal(160, Layout.OffsetOf(origin, &info.NewEsp)); - Assert.Equal(168, Layout.OffsetOf(origin, &info.NewEip)); - Assert.Equal(176, Layout.OffsetOf(origin, &info.NewR8)); - Assert.Equal(184, Layout.OffsetOf(origin, &info.NewR9)); - Assert.Equal(192, Layout.OffsetOf(origin, &info.NewR10)); - Assert.Equal(200, Layout.OffsetOf(origin, &info.NewR11)); - Assert.Equal(208, Layout.OffsetOf(origin, &info.NewR12)); - Assert.Equal(216, Layout.OffsetOf(origin, &info.NewR13)); - Assert.Equal(224, Layout.OffsetOf(origin, &info.NewR14)); - Assert.Equal(232, Layout.OffsetOf(origin, &info.NewR15)); + Assert.Equal(104, Layout.OffsetOf(origin, &info.NewEax)); + Assert.Equal(112, Layout.OffsetOf(origin, &info.NewEbx)); + Assert.Equal(120, Layout.OffsetOf(origin, &info.NewEcx)); + Assert.Equal(128, Layout.OffsetOf(origin, &info.NewEdx)); + Assert.Equal(136, Layout.OffsetOf(origin, &info.NewEsi)); + Assert.Equal(144, Layout.OffsetOf(origin, &info.NewEdi)); + Assert.Equal(152, Layout.OffsetOf(origin, &info.NewEbp)); + Assert.Equal(160, Layout.OffsetOf(origin, &info.NewEsp)); + Assert.Equal(168, Layout.OffsetOf(origin, &info.NewEip)); + Assert.Equal(176, Layout.OffsetOf(origin, &info.NewR8)); + Assert.Equal(184, Layout.OffsetOf(origin, &info.NewR9)); + Assert.Equal(192, Layout.OffsetOf(origin, &info.NewR10)); + Assert.Equal(200, Layout.OffsetOf(origin, &info.NewR11)); + Assert.Equal(208, Layout.OffsetOf(origin, &info.NewR12)); + Assert.Equal(216, Layout.OffsetOf(origin, &info.NewR13)); + Assert.Equal(224, Layout.OffsetOf(origin, &info.NewR14)); + Assert.Equal(232, Layout.OffsetOf(origin, &info.NewR15)); - Assert.Equal(240, Layout.OffsetOf(origin, &info.NewCf)); - Assert.Equal(244, Layout.OffsetOf(origin, &info.NewPf)); - Assert.Equal(248, Layout.OffsetOf(origin, &info.NewAf)); - Assert.Equal(252, Layout.OffsetOf(origin, &info.NewZf)); - Assert.Equal(256, Layout.OffsetOf(origin, &info.NewSf)); - Assert.Equal(260, Layout.OffsetOf(origin, &info.NewOf)); - } + Assert.Equal(240, Layout.OffsetOf(origin, &info.NewCf)); + Assert.Equal(244, Layout.OffsetOf(origin, &info.NewPf)); + Assert.Equal(248, Layout.OffsetOf(origin, &info.NewAf)); + Assert.Equal(252, Layout.OffsetOf(origin, &info.NewZf)); + Assert.Equal(256, Layout.OffsetOf(origin, &info.NewSf)); + Assert.Equal(260, Layout.OffsetOf(origin, &info.NewOf)); + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/README.md b/tests/CheatEngine.SDK.Abi.Tests/README.md index 07aabca1..fbdba0e1 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/README.md +++ b/tests/CheatEngine.SDK.Abi.Tests/README.md @@ -21,11 +21,11 @@ Three independent techniques check the layout, so one mistake cannot hide behind mirror the `CheatEngine.SDK.Abi` namespaces. `Support/` holds `Layout`, the `AbiShape` gate and its tests. Expected numbers are literals next to the assertion, never derived from the code under test. -| Technique | What it does | -|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Address-of arithmetic | `Layout.SizeOf()` and `Layout.OffsetOf` measure the size and every field offset. | -| Raw bytes | The packed 36-byte init record is written into a guard-filled buffer at an aligned and an odd address. The 48-byte exports record is built as raw bytes, then read through the struct. | -| Host simulation | `&Method` of a real `[UnmanagedCallersOnly]` `Stdcall` function is stored in every typed function-pointer slot, then called through the field. | +| Technique | What it does | +|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Address-of arithmetic | `Layout.SizeOf()` and `Layout.OffsetOf` measure the size and every field offset. | +| Raw bytes | The packed 36-byte init record is written into a guard-filled buffer at an aligned and an odd address. The 48-byte exports record is built as raw bytes, then read through the struct. | +| Host simulation | `&Method` of a real `[UnmanagedCallersOnly]` `Stdcall` function is stored in every typed function-pointer slot, then called through the field. | | Native-fact comparison | The native CI job provides the checked `ce77-native-abi-facts.txt` and sets its required gate; a compiled managed test measures every fixture-covered layout and compares its size, alignment, and offsets to that output. | The test assembly applies `[assembly: DisableRuntimeMarshalling]`, so calls take the path a plugin takes. diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShape.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShape.cs index 1ebf4ff9..8d71accd 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShape.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShape.cs @@ -36,101 +36,133 @@ namespace CheatEngine.SDK.Abi.Tests.Support; /// internal static class AbiShape { - private const BindingFlags InstanceFields = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - /// Returns the first rule breaks, or . - /// The structure to inspect. - /// - /// Assemblies whose structures may appear by value or behind a pointer. Every other non-primitive value type is - /// foreign: the mapping references nothing, so such a type is spelled void* instead. - /// - public static string? FindViolation(Type structure, params ReadOnlySpan trustedAssemblies) - { - return CheckStructure(structure, structure.Name, trustedAssemblies, []); - } - - private static string? CheckStructure(Type structure, string path, ReadOnlySpan trusted, - HashSet visited) - { - // Already being inspected further up (a pointer cycle) or already found clean: nothing new to learn. - if (!visited.Add(structure)) return null; - - if (!structure.IsLayoutSequential) - return $"{path}: {structure.Name} must use sequential layout, not Auto or Explicit."; - - foreach (var field in structure.GetFields(InstanceFields)) - { - // The MODIFIED type: the only reflection view that still carries the calling convention of a function pointer. - var violation = CheckType(field.GetModifiedFieldType(), $"{path}.{field.Name}", false, trusted, visited); - if (violation is not null) return violation; - } - - return null; - } - - private static string? CheckType(Type type, string path, bool allowVoid, ReadOnlySpan trusted, - HashSet visited) - { - // Identity and classification questions go to the unmodified type; navigation (element type, signature) stays - // on the modified one so that nested function pointers keep their conventions. - var plain = type.UnderlyingSystemType; - - if (plain == typeof(void)) return allowVoid ? null : $"{path}: void is only valid as a result or as a pointee."; - - if (plain.IsFunctionPointer) return CheckFunctionPointer(type, path, trusted, visited); - - if (plain.IsPointer) - { - var element = type.GetElementType(); - return element is null - ? $"{path}: pointer without an element type." - : CheckType(element, path + "*", true, trusted, visited); - } - - if (plain.IsByRef) return $"{path}: passed by reference (ref/in/out); the ABI uses pointers."; - - if (plain.IsEnum) - return Enum.GetUnderlyingType(plain) == typeof(int) - ? null - : $"{path}: enumeration {plain.Name} is not 4 bytes wide."; - - if (plain.IsPrimitive) - return plain == typeof(bool) || plain == typeof(char) - ? $"{path}: {plain.Name} has no fixed ABI width here; use Bool32/Bool8 or a fixed-width integer." - : null; - - if (plain.IsValueType && !plain.IsGenericType && trusted.Contains(plain.Assembly)) - return CheckStructure(plain, path, trusted, visited); - - return $"{path}: {plain} is a reference, a generic or a foreign value type: not allowed in an ABI layout."; - } - - private static string? CheckFunctionPointer(Type type, string path, ReadOnlySpan trusted, - HashSet visited) - { - if (!type.UnderlyingSystemType.IsUnmanagedFunctionPointer) - return $"{path}: managed function pointer; the host can only call unmanaged ones."; - - var conventions = type.GetFunctionPointerCallingConventions(); - if (conventions.Length != 1 || conventions[0] != typeof(CallConvStdcall)) - { - var stated = conventions.Length == 0 - ? "none" - : string.Join(", ", conventions.Select(static convention => convention.Name)); - return $"{path}: calling convention must be exactly Stdcall (stated: {stated})."; - } - - var violation = CheckType(type.GetFunctionPointerReturnType(), path + "(result)", true, trusted, visited); - if (violation is not null) return violation; - - var parameters = type.GetFunctionPointerParameterTypes(); - for (var index = 0; index < parameters.Length; index++) - { - violation = CheckType(parameters[index], - $"{path}(parameter {index.ToString(CultureInfo.InvariantCulture)})", allowVoid: false, trusted, visited); - if (violation is not null) return violation; - } - - return null; - } + private const BindingFlags InstanceFields = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + /// Returns the first rule breaks, or . + /// The structure to inspect. + /// + /// Assemblies whose structures may appear by value or behind a pointer. Every other non-primitive value type is + /// foreign: the mapping references nothing, so such a type is spelled void* instead. + /// + public static string? FindViolation(Type structure, params ReadOnlySpan trustedAssemblies) + { + return CheckStructure(structure, structure.Name, trustedAssemblies, []); + } + + private static string? CheckStructure(Type structure, string path, ReadOnlySpan trusted, + HashSet visited) + { + // Already being inspected further up (a pointer cycle) or already found clean: nothing new to learn. + if (!visited.Add(structure)) + { + return null; + } + + if (!structure.IsLayoutSequential) + { + return $"{path}: {structure.Name} must use sequential layout, not Auto or Explicit."; + } + + foreach (FieldInfo field in structure.GetFields(InstanceFields)) + { + // The MODIFIED type: the only reflection view that still carries the calling convention of a function pointer. + string? violation = CheckType(field.GetModifiedFieldType(), $"{path}.{field.Name}", false, trusted, + visited); + if (violation is not null) + { + return violation; + } + } + + return null; + } + + private static string? CheckType(Type type, string path, bool allowVoid, ReadOnlySpan trusted, + HashSet visited) + { + // Identity and classification questions go to the unmodified type; navigation (element type, signature) stays + // on the modified one so that nested function pointers keep their conventions. + Type plain = type.UnderlyingSystemType; + + if (plain == typeof(void)) + { + return allowVoid ? null : $"{path}: void is only valid as a result or as a pointee."; + } + + if (plain.IsFunctionPointer) + { + return CheckFunctionPointer(type, path, trusted, visited); + } + + if (plain.IsPointer) + { + Type? element = type.GetElementType(); + return element is null + ? $"{path}: pointer without an element type." + : CheckType(element, path + "*", true, trusted, visited); + } + + if (plain.IsByRef) + { + return $"{path}: passed by reference (ref/in/out); the ABI uses pointers."; + } + + if (plain.IsEnum) + { + return Enum.GetUnderlyingType(plain) == typeof(int) + ? null + : $"{path}: enumeration {plain.Name} is not 4 bytes wide."; + } + + if (plain.IsPrimitive) + { + return plain == typeof(bool) || plain == typeof(char) + ? $"{path}: {plain.Name} has no fixed ABI width here; use Bool32/Bool8 or a fixed-width integer." + : null; + } + + if (plain.IsValueType && !plain.IsGenericType && trusted.Contains(plain.Assembly)) + { + return CheckStructure(plain, path, trusted, visited); + } + + return $"{path}: {plain} is a reference, a generic or a foreign value type: not allowed in an ABI layout."; + } + + private static string? CheckFunctionPointer(Type type, string path, ReadOnlySpan trusted, + HashSet visited) + { + if (!type.UnderlyingSystemType.IsUnmanagedFunctionPointer) + { + return $"{path}: managed function pointer; the host can only call unmanaged ones."; + } + + Type[] conventions = type.GetFunctionPointerCallingConventions(); + if (conventions.Length != 1 || conventions[0] != typeof(CallConvStdcall)) + { + string stated = conventions.Length == 0 + ? "none" + : string.Join(", ", conventions.Select(static convention => convention.Name)); + return $"{path}: calling convention must be exactly Stdcall (stated: {stated})."; + } + + string? violation = CheckType(type.GetFunctionPointerReturnType(), path + "(result)", true, trusted, visited); + if (violation is not null) + { + return violation; + } + + Type[] parameters = type.GetFunctionPointerParameterTypes(); + for (int index = 0; index < parameters.Length; index++) + { + violation = CheckType(parameters[index], + $"{path}(parameter {index.ToString(CultureInfo.InvariantCulture)})", false, trusted, visited); + if (violation is not null) + { + return violation; + } + } + + return null; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShapeTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShapeTests.cs index 33814a23..ba76a752 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShapeTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/AbiShapeTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi.Managed; namespace CheatEngine.SDK.Abi.Tests.Support; @@ -11,216 +12,216 @@ namespace CheatEngine.SDK.Abi.Tests.Support; /// public sealed unsafe class AbiShapeTests { - /// Byte-wide on purpose: an enumeration of the interface is 4 bytes. - public enum ByteKind : byte - { - /// Only member. - None - } - - /// Four bytes wide, like every enumeration of the interface. - public enum IntKind - { - /// Only member. - None - } - - /// The fixtures live here and borrow the two boolean types of the real assembly. - private static readonly Assembly[] Trusted = [typeof(AbiShapeTests).Assembly, typeof(PluginInitRecord).Assembly]; - - [Theory] - [InlineData(typeof(Conforming))] - [InlineData(typeof(ConformingInner))] - [InlineData(typeof(SelfReferencing))] - public void FindViolation_conforming_structure_returns_null(Type structure) - { - Assert.Null(AbiShape.FindViolation(structure, Trusted)); - } - - [Theory] - [InlineData(typeof(BoolField), nameof(BoolField.Flag))] - [InlineData(typeof(CharField), nameof(CharField.Letter))] - [InlineData(typeof(ReferenceField), nameof(ReferenceField.Text))] - [InlineData(typeof(ForeignValueTypeField), nameof(ForeignValueTypeField.Id))] - [InlineData(typeof(ByteKindField), nameof(ByteKindField.Kind))] - [InlineData(typeof(BoolInsideNestedStructure), nameof(BoolField.Flag))] - [InlineData(typeof(BoolBehindStructurePointer), nameof(BoolField.Flag))] - [InlineData(typeof(BoolBehindPointer), nameof(BoolBehindPointer.Flag))] - [InlineData(typeof(ManagedFunctionPointer), nameof(ManagedFunctionPointer.Callback))] - [InlineData(typeof(CdeclFunctionPointer), nameof(CdeclFunctionPointer.Callback))] - [InlineData(typeof(UnspecifiedConventionFunctionPointer), nameof(UnspecifiedConventionFunctionPointer.Callback))] - [InlineData(typeof(BoolParameter), nameof(BoolParameter.Callback))] - [InlineData(typeof(CharResult), nameof(CharResult.Callback))] - [InlineData(typeof(ByReferenceParameter), nameof(ByReferenceParameter.Callback))] - [InlineData(typeof(BoolBehindPointerParameter), nameof(BoolBehindPointerParameter.Callback))] - [InlineData(typeof(NestedFunctionPointerWithoutStdcall), nameof(NestedFunctionPointerWithoutStdcall.Callback))] - public void FindViolation_forbidden_shape_names_the_offending_field(Type structure, string offendingField) - { - var violation = AbiShape.FindViolation(structure, Trusted); - - Assert.NotNull(violation); - Assert.Contains(offendingField, violation, StringComparison.Ordinal); - } - - [Fact] - public void FindViolation_explicit_layout_is_rejected() - { - var violation = AbiShape.FindViolation(typeof(ExplicitLayout), Trusted); - - Assert.NotNull(violation); - Assert.Contains("sequential", violation, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void FindViolation_structure_of_another_assembly_is_foreign() - { - // Same fixture, other home: a structure is only trusted when it belongs to the assembly under test. - var violation = AbiShape.FindViolation(typeof(Conforming), typeof(PluginInitRecord).Assembly); - - Assert.NotNull(violation); - Assert.Contains(nameof(Conforming.Inner), violation, StringComparison.Ordinal); - } - - /// Every shape the interface really uses, in one record. - [StructLayout(LayoutKind.Sequential)] - public struct Conforming - { - public byte* Text; - public void* Opaque; - public nint Handle; - public nuint Address; - public int Size; - public uint Version; - public double Number; - public IntKind Kind; - public ConformingInner Inner; - public ConformingInner* InnerPointer; - public delegate* unmanaged[Stdcall] Notify; - public delegate* unmanaged[Stdcall] GetState; - public delegate* unmanaged[Stdcall] Query; - public delegate* unmanaged[Stdcall] Popup; - public delegate* unmanaged[Stdcall], void> Register; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ConformingInner - { - public long Value; - public Bool32 Flag; - } - - /// A pointer cycle must not send the gate into infinite recursion. - [StructLayout(LayoutKind.Sequential)] - public struct SelfReferencing - { - public SelfReferencing* Next; - public int Value; - } - - [StructLayout(LayoutKind.Sequential)] - public struct BoolField - { - public bool Flag; - } - - [StructLayout(LayoutKind.Sequential)] - public struct CharField - { - public char Letter; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ReferenceField - { - public string? Text; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ForeignValueTypeField - { - public Guid Id; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ByteKindField - { - public ByteKind Kind; - } - - /// A forbidden field one level down is found, not accepted unseen. - [StructLayout(LayoutKind.Sequential)] - public struct BoolInsideNestedStructure - { - public nint Handle; - public BoolField Inner; - } - - [StructLayout(LayoutKind.Sequential)] - public struct BoolBehindStructurePointer - { - public BoolField* Inner; - } - - [StructLayout(LayoutKind.Sequential)] - public struct BoolBehindPointer - { - public bool* Flag; - } - - /// A function-pointer field is judged by its signature, not accepted without looking. - [StructLayout(LayoutKind.Sequential)] - public struct ManagedFunctionPointer - { - public delegate* Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct CdeclFunctionPointer - { - public delegate* unmanaged[Cdecl] Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct UnspecifiedConventionFunctionPointer - { - public delegate* unmanaged Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct BoolParameter - { - public delegate* unmanaged[Stdcall] Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct CharResult - { - public delegate* unmanaged[Stdcall] Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ByReferenceParameter - { - public delegate* unmanaged[Stdcall] Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct BoolBehindPointerParameter - { - public delegate* unmanaged[Stdcall] Callback; - } - - [StructLayout(LayoutKind.Sequential)] - public struct NestedFunctionPointerWithoutStdcall - { - public delegate* unmanaged[Stdcall], void> Callback; - } - - [StructLayout(LayoutKind.Explicit)] - public struct ExplicitLayout - { - [FieldOffset(0)] public int Low; - - [FieldOffset(4)] public int High; - } + /// Byte-wide on purpose: an enumeration of the interface is 4 bytes. + public enum ByteKind : byte + { + /// Only member. + None + } + + /// Four bytes wide, like every enumeration of the interface. + public enum IntKind + { + /// Only member. + None + } + + /// The fixtures live here and borrow the two boolean types of the real assembly. + private static readonly Assembly[] Trusted = [typeof(AbiShapeTests).Assembly, typeof(PluginInitRecord).Assembly]; + + [Theory] + [InlineData(typeof(Conforming))] + [InlineData(typeof(ConformingInner))] + [InlineData(typeof(SelfReferencing))] + public void FindViolation_conforming_structure_returns_null(Type structure) + { + Assert.Null(AbiShape.FindViolation(structure, Trusted)); + } + + [Theory] + [InlineData(typeof(BoolField), nameof(BoolField.Flag))] + [InlineData(typeof(CharField), nameof(CharField.Letter))] + [InlineData(typeof(ReferenceField), nameof(ReferenceField.Text))] + [InlineData(typeof(ForeignValueTypeField), nameof(ForeignValueTypeField.Id))] + [InlineData(typeof(ByteKindField), nameof(ByteKindField.Kind))] + [InlineData(typeof(BoolInsideNestedStructure), nameof(BoolField.Flag))] + [InlineData(typeof(BoolBehindStructurePointer), nameof(BoolField.Flag))] + [InlineData(typeof(BoolBehindPointer), nameof(BoolBehindPointer.Flag))] + [InlineData(typeof(ManagedFunctionPointer), nameof(ManagedFunctionPointer.Callback))] + [InlineData(typeof(CdeclFunctionPointer), nameof(CdeclFunctionPointer.Callback))] + [InlineData(typeof(UnspecifiedConventionFunctionPointer), nameof(UnspecifiedConventionFunctionPointer.Callback))] + [InlineData(typeof(BoolParameter), nameof(BoolParameter.Callback))] + [InlineData(typeof(CharResult), nameof(CharResult.Callback))] + [InlineData(typeof(ByReferenceParameter), nameof(ByReferenceParameter.Callback))] + [InlineData(typeof(BoolBehindPointerParameter), nameof(BoolBehindPointerParameter.Callback))] + [InlineData(typeof(NestedFunctionPointerWithoutStdcall), nameof(NestedFunctionPointerWithoutStdcall.Callback))] + public void FindViolation_forbidden_shape_names_the_offending_field(Type structure, string offendingField) + { + string? violation = AbiShape.FindViolation(structure, Trusted); + + Assert.NotNull(violation); + Assert.Contains(offendingField, violation, StringComparison.Ordinal); + } + + [Fact] + public void FindViolation_explicit_layout_is_rejected() + { + string? violation = AbiShape.FindViolation(typeof(ExplicitLayout), Trusted); + + Assert.NotNull(violation); + Assert.Contains("sequential", violation, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FindViolation_structure_of_another_assembly_is_foreign() + { + // Same fixture, other home: a structure is only trusted when it belongs to the assembly under test. + string? violation = AbiShape.FindViolation(typeof(Conforming), typeof(PluginInitRecord).Assembly); + + Assert.NotNull(violation); + Assert.Contains(nameof(Conforming.Inner), violation, StringComparison.Ordinal); + } + + /// Every shape the interface really uses, in one record. + [StructLayout(LayoutKind.Sequential)] + public struct Conforming + { + public byte* Text; + public void* Opaque; + public nint Handle; + public nuint Address; + public int Size; + public uint Version; + public double Number; + public IntKind Kind; + public ConformingInner Inner; + public ConformingInner* InnerPointer; + public delegate* unmanaged[Stdcall] Notify; + public delegate* unmanaged[Stdcall] GetState; + public delegate* unmanaged[Stdcall] Query; + public delegate* unmanaged[Stdcall] Popup; + public delegate* unmanaged[Stdcall], void> Register; + } + + [StructLayout(LayoutKind.Sequential)] + public struct ConformingInner + { + public long Value; + public Bool32 Flag; + } + + /// A pointer cycle must not send the gate into infinite recursion. + [StructLayout(LayoutKind.Sequential)] + public struct SelfReferencing + { + public SelfReferencing* Next; + public int Value; + } + + [StructLayout(LayoutKind.Sequential)] + public struct BoolField + { + public bool Flag; + } + + [StructLayout(LayoutKind.Sequential)] + public struct CharField + { + public char Letter; + } + + [StructLayout(LayoutKind.Sequential)] + public struct ReferenceField + { + public string? Text; + } + + [StructLayout(LayoutKind.Sequential)] + public struct ForeignValueTypeField + { + public Guid Id; + } + + [StructLayout(LayoutKind.Sequential)] + public struct ByteKindField + { + public ByteKind Kind; + } + + /// A forbidden field one level down is found, not accepted unseen. + [StructLayout(LayoutKind.Sequential)] + public struct BoolInsideNestedStructure + { + public nint Handle; + public BoolField Inner; + } + + [StructLayout(LayoutKind.Sequential)] + public struct BoolBehindStructurePointer + { + public BoolField* Inner; + } + + [StructLayout(LayoutKind.Sequential)] + public struct BoolBehindPointer + { + public bool* Flag; + } + + /// A function-pointer field is judged by its signature, not accepted without looking. + [StructLayout(LayoutKind.Sequential)] + public struct ManagedFunctionPointer + { + public delegate* Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct CdeclFunctionPointer + { + public delegate* unmanaged[Cdecl] Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct UnspecifiedConventionFunctionPointer + { + public delegate* unmanaged Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct BoolParameter + { + public delegate* unmanaged[Stdcall] Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct CharResult + { + public delegate* unmanaged[Stdcall] Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct ByReferenceParameter + { + public delegate* unmanaged[Stdcall] Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct BoolBehindPointerParameter + { + public delegate* unmanaged[Stdcall] Callback; + } + + [StructLayout(LayoutKind.Sequential)] + public struct NestedFunctionPointerWithoutStdcall + { + public delegate* unmanaged[Stdcall], void> Callback; + } + + [StructLayout(LayoutKind.Explicit)] + public struct ExplicitLayout + { + [FieldOffset(0)] public int Low; + + [FieldOffset(4)] public int High; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/Layout.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/Layout.cs index 938dac9c..9bea073b 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Support/Layout.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/Layout.cs @@ -3,41 +3,41 @@ namespace CheatEngine.SDK.Abi.Tests.Support; /// Helpers shared by the layout tests. internal static unsafe class Layout { - /// Skip reason for the tests whose expected numbers are the 64-bit ones. - public const string Requires64BitProcess = - "The expected sizes and offsets are the 64-bit ones (Cheat Engine x64); this test process is 32-bit."; + /// Skip reason for the tests whose expected numbers are the 64-bit ones. + public const string Requires64BitProcess = + "The expected sizes and offsets are the 64-bit ones (Cheat Engine x64); this test process is 32-bit."; - /// Gets a value indicating whether the size/offset expectations apply to this process. - public static bool Is64BitProcess => Environment.Is64BitProcess; + /// Gets a value indicating whether the size/offset expectations apply to this process. + public static bool Is64BitProcess => Environment.Is64BitProcess; - /// - /// Managed size of . The constraint is part of the assertion: a structure that - /// contained a reference would not compile here. - /// - public static int SizeOf() - where T : unmanaged - { - return sizeof(T); - } + /// + /// Managed size of . The constraint is part of the assertion: a structure that + /// contained a reference would not compile here. + /// + public static int SizeOf() + where T : unmanaged + { + return sizeof(T); + } - /// Byte distance between the start of a structure and one of its fields. - public static int OffsetOf(void* origin, void* field) - { - return checked((int)((byte*)field - (byte*)origin)); - } + /// Byte distance between the start of a structure and one of its fields. + public static int OffsetOf(void* origin, void* field) + { + return checked((int) ((byte*) field - (byte*) origin)); + } - /// Managed alignment of an unmanaged value, measured as the offset after a leading byte. - public static int AlignmentOf() - where T : unmanaged - { - AlignmentProbe probe = default; - return OffsetOf(&probe, &probe.Value); - } + /// Managed alignment of an unmanaged value, measured as the offset after a leading byte. + public static int AlignmentOf() + where T : unmanaged + { + AlignmentProbe probe = default; + return OffsetOf(&probe, &probe.Value); + } - private struct AlignmentProbe - where T : unmanaged - { - public byte Prefix; - public T Value; - } + private struct AlignmentProbe + where T : unmanaged + { + public byte Prefix; + public T Value; + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs index 9e8e3237..5bee2180 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Text.Json; + using CheatEngine.SDK.Analyzers.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -14,652 +16,701 @@ namespace CheatEngine.SDK.Analyzers.Tests.Architecture; /// public sealed class LuaDirectApiBoundaryGuardTests { - private const string LuaApiQualifiedName = "CheatEngine.SDK.Lua.Interop.Api.LuaApi"; - private const string CataloguePath = "eng/lua-bridge/protected-operations.json"; - private const string LibrariesPath = "libs"; - private const string RawApiPath = "libs/CheatEngine.SDK.Lua.Interop/Api/"; - private const string LightCFunctionFastPathSourcePath = "libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs"; - - [Fact] - public void Production_layers_route_catalogued_risky_LuaApi_operations_through_the_native_boundary() - { - var policy = LoadPolicy(); - var violations = InspectProductionSources(policy); - - Assert.True(violations.Count == 0, FormatViolations(violations)); - } - - [Fact] - public void Catalogue_risky_direct_operations_have_a_deterministic_bridge_decision() - { - var policy = LoadPolicy(); - var bridgeOperations = LoadBridgeOperations(); - var entries = policy.Entries; - var riskyCount = 0; - - for (var index = 0; index < entries.Count; index++) - { - var entry = entries[index]; - if (string.Equals(entry.Raises, "never", StringComparison.Ordinal)) - { - Assert.True(entry.AllowedDirectly, entry.ManagedSymbol); - Assert.False(entry.RequiresBridge, entry.ManagedSymbol); - continue; - } - - riskyCount++; - Assert.False(entry.AllowedDirectly, entry.ManagedSymbol); - Assert.True(entry.RequiresBridge, entry.ManagedSymbol); - Assert.False(string.IsNullOrWhiteSpace(entry.Reason), entry.ManagedSymbol); - Assert.NotEmpty(entry.Provenance); - - if (entry.ConditionalDirectUse) - { - Assert.Equal("lua_pushcclosure", entry.MemberName); - Assert.True(entry.ConditionalAllowed, entry.ManagedSymbol); - Assert.False(string.IsNullOrWhiteSpace(entry.ConditionalWhen), entry.ManagedSymbol); - Assert.False(string.IsNullOrWhiteSpace(entry.ConditionalProof), entry.ManagedSymbol); - continue; - } - - if (string.IsNullOrWhiteSpace(entry.BridgeOperation)) - { - // lua_error is deliberately used only inside a bridge operation's native failure path. It has no - // public managed wrapper to name here, but the direct-use decision remains unambiguously "bridge". - Assert.Equal("lua_error", entry.MemberName); - continue; - } - - Assert.Contains(entry.BridgeOperation, bridgeOperations, StringComparer.Ordinal); - } - - Assert.True(riskyCount > 0, "The catalogue contains no direct Lua APIs classified memory or any."); - } - - [Fact] - public void Guard_reports_a_catalogued_risky_member_access() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using CheatEngine.SDK.Lua.Interop.Api; - - unsafe class C - { - void M(lua_State* state, byte* bytes) - { - _ = LuaApi.lua_pushlstring(state, bytes, 1); - } - } - """, "MemberAccess.cs"); - - var violation = Assert.Single(violations); - Assert.Equal("lua_pushlstring", violation.MemberName); - Assert.Equal(7, violation.Line); - } - - [Fact] - public void Guard_reports_a_catalogued_risky_static_import() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using CheatEngine.SDK.Lua.Interop.Api; - using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; - - unsafe class C - { - void M(lua_State* state, lua_CFunction function) - { - lua_pushcclosure(state, function, 1); - } - } - """, "StaticImport.cs"); - - var violation = Assert.Single(violations); - Assert.Equal("lua_pushcclosure", violation.MemberName); - } - - [Fact] - public void Guard_reports_a_catalogued_risky_exact_LuaApi_alias() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using CheatEngine.SDK.Lua.Interop.Api; - using Api = CheatEngine.SDK.Lua.Interop.Api.LuaApi; - - unsafe class C - { - void M(lua_State* state) - { - Api.lua_createtable(state, 0, 0); - } - } - """, "Alias.cs"); - - var violation = Assert.Single(violations); - Assert.Equal("lua_createtable", violation.MemberName); - } - - [Fact] - public void Guard_reports_a_zero_upvalue_closure_outside_the_audited_fast_path() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using CheatEngine.SDK.Lua.Interop.Api; - - unsafe class C - { - void M(lua_State* state, lua_CFunction function) - { - LuaApi.lua_pushcclosure(state, function, 0); - } - } - """, "LightCFunction.cs"); - - var violation = Assert.Single(violations); - Assert.Equal("lua_pushcclosure", violation.MemberName); - } - - [Fact] - public void Guard_allows_only_the_audited_light_C_function_fast_path_after_its_immediate_one_slot_reservation() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; - - unsafe struct C - { - private lua_State* Pointer; - - void PushUncheckedFunction(lua_CFunction thunk) - { - if (lua_checkstack(Pointer, 1) == 0) - throw new InvalidOperationException(); - - lua_pushcclosure(Pointer, thunk, 0); - } - } - """, LightCFunctionFastPathSourcePath); - - Assert.Empty(violations); - } - - [Fact] - public void Guard_reports_the_audited_path_when_a_Lua_call_interrupts_the_reservation_and_push() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; - - unsafe struct C - { - private lua_State* Pointer; - - void PushUncheckedFunction(lua_CFunction thunk) - { - if (lua_checkstack(Pointer, 1) == 0) - throw new InvalidOperationException(); - - lua_pushinteger(Pointer, 42); - lua_pushcclosure(Pointer, thunk, 0); - } - } - """, LightCFunctionFastPathSourcePath); - - var violation = Assert.Single(violations); - Assert.Equal("lua_pushcclosure", violation.MemberName); - } - - [Fact] - public void Guard_does_not_match_an_unrelated_method_with_a_Lua_shaped_name() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - class C - { - void lua_pushlstring(int value) { } - - void M() - { - lua_pushlstring(42); - } - } - """, "Unrelated.cs"); - - Assert.Empty(violations); - } - - [Fact] - public void Guard_does_not_treat_a_member_that_shadows_a_static_import_as_the_raw_LuaApi_member() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; - - class C - { - static void lua_rawset(int value) { } - - void M() - { - lua_rawset(42); - } - } - """, "ShadowedStaticImport.cs"); - - Assert.Empty(violations); - } - - [Fact] - public void Guard_does_not_match_an_unrelated_LuaApi_type_without_the_exact_SDK_import() - { - var policy = LoadPolicy(); - var violations = InspectSource(policy, """ - unsafe class LuaApi - { - public static void lua_rawset(int value) { } - } - - class C - { - void M() - { - LuaApi.lua_rawset(42); - } - } - """, "UnrelatedType.cs"); - - Assert.Empty(violations); - } - - private static LuaDirectApiPolicy LoadPolicy() - { - using var catalogue = LoadCatalogue(); - var directApiPolicy = catalogue.RootElement.GetProperty("directApiPolicy"); - List entries = []; - - foreach (var entry in directApiPolicy.EnumerateArray()) - { - var managedSymbol = RequiredString(entry, "managedSymbol"); - const string prefix = LuaApiQualifiedName + "."; - Assert.StartsWith(prefix, managedSymbol, StringComparison.Ordinal); - var memberName = managedSymbol[prefix.Length..]; - Assert.DoesNotContain(".", memberName, StringComparison.Ordinal); - - var conditionalDirectUse = entry.TryGetProperty("conditionalDirectUse", out var conditional); - entries.Add(new LuaDirectApiPolicyEntry( - managedSymbol, - memberName, - RequiredString(entry, "raises"), - entry.GetProperty("allowedDirectly").GetBoolean(), - entry.GetProperty("requiresBridge").GetBoolean(), - RequiredString(entry, "reason"), - ReadProvenance(entry), - entry.TryGetProperty("bridgeOperation", out var bridgeOperation) - ? bridgeOperation.GetString() - : null, - conditionalDirectUse, - conditionalDirectUse && conditional.GetProperty("allowed").GetBoolean(), - conditionalDirectUse ? RequiredString(conditional, "when") : null, - conditionalDirectUse ? RequiredString(conditional, "proof") : null)); - } - - Assert.NotEmpty(entries); - return new LuaDirectApiPolicy(entries); - } - - private static HashSet LoadBridgeOperations() - { - using var catalogue = LoadCatalogue(); - HashSet operations = new(StringComparer.Ordinal); - - foreach (var operation in catalogue.RootElement.GetProperty("operations").EnumerateArray()) - Assert.True(operations.Add(RequiredString(operation, "id")), - "Bridge operation identifiers must be unique."); - - return operations; - } - - private static JsonDocument LoadCatalogue() - { - var path = RepositoryLayout.PathOf(CataloguePath); - Assert.True(File.Exists(path), $"The direct Lua API policy catalogue is missing: '{path}'."); - - var catalogue = JsonDocument.Parse(File.ReadAllText(path)); - Assert.Equal(1, catalogue.RootElement.GetProperty("schemaVersion").GetInt32()); - Assert.Equal("cheatengine-sdk-lua-protected-operations", - RequiredString(catalogue.RootElement, "catalogId")); - return catalogue; - } - - private static List InspectProductionSources(LuaDirectApiPolicy policy) - { - var root = RepositoryLayout.PathOf(LibrariesPath); - List violations = []; - - foreach (var sourcePath in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) - { - var repositoryPath = GetRepositoryPath(sourcePath); - if (repositoryPath.StartsWith(RawApiPath, StringComparison.Ordinal) || IsGeneratedPath(repositoryPath)) - continue; - - AddViolations(violations, policy, File.ReadAllText(sourcePath), repositoryPath); - } - - return violations; - } - - private static List InspectSource(LuaDirectApiPolicy policy, string source, string path) - { - List violations = []; - AddViolations(violations, policy, source, path); - return violations; - } - - private static void AddViolations(List violations, LuaDirectApiPolicy policy, string source, - string path) - { - var cancellationToken = TestContext.Current.CancellationToken; - var tree = CSharpSyntaxTree.ParseText(source, path: path, cancellationToken: cancellationToken); - var root = tree.GetCompilationUnitRoot(cancellationToken); - var aliases = CollectLuaApiAliases(root); - var hasStaticLuaApiImport = HasStaticLuaApiImport(root); - var hasLuaApiNamespaceImport = HasLuaApiNamespaceImport(root); - var shadowedNames = CollectPotentialSourceDeclarations(root); - - foreach (var node in root.DescendantNodes()) - { - if (node is not InvocationExpressionSyntax invocation || - !TryGetLuaApiMemberName(invocation, aliases, hasStaticLuaApiImport, hasLuaApiNamespaceImport, - shadowedNames, out var memberName) || - !policy.TryGet(memberName, out var entry) || - !entry.RequiresBridge || - entry.AllowedDirectly || - IsConditionallyAllowed(entry, invocation, path)) - continue; - - var line = invocation.GetLocation().GetLineSpan().StartLinePosition.Line + 1; - violations.Add(new GuardViolation(path, line, memberName, entry.Reason)); - } - } - - private static HashSet CollectLuaApiAliases(CompilationUnitSyntax root) - { - HashSet aliases = new(StringComparer.Ordinal); - foreach (var node in root.DescendantNodesAndSelf()) - { - if (node is not UsingDirectiveSyntax directive || directive.Alias is null || directive.Name is null || - !IsExactLuaApiTypeName(directive.Name.ToString())) - continue; - - aliases.Add(directive.Alias.Name.Identifier.ValueText); - } - - return aliases; - } - - private static bool HasStaticLuaApiImport(CompilationUnitSyntax root) - { - foreach (var node in root.DescendantNodesAndSelf()) - if (node is UsingDirectiveSyntax { Name: not null } directive && - directive.StaticKeyword.RawKind != 0 && - IsExactLuaApiTypeName(directive.Name.ToString())) - return true; - - return false; - } - - private static bool HasLuaApiNamespaceImport(CompilationUnitSyntax root) - { - foreach (var node in root.DescendantNodesAndSelf()) - if (node is UsingDirectiveSyntax { Alias: null, Name: not null } directive && - directive.StaticKeyword.RawKind == 0 && - IsLuaApiNamespaceName(directive.Name.ToString())) - return true; - - return false; - } - - private static HashSet CollectPotentialSourceDeclarations(CompilationUnitSyntax root) - { - HashSet names = new(StringComparer.Ordinal); - foreach (var node in root.DescendantNodes()) - switch (node) - { - case MethodDeclarationSyntax method: - names.Add(method.Identifier.ValueText); - break; - - case LocalFunctionStatementSyntax localFunction: - names.Add(localFunction.Identifier.ValueText); - break; - - case VariableDeclaratorSyntax variable: - names.Add(variable.Identifier.ValueText); - break; - - case ParameterSyntax parameter: - names.Add(parameter.Identifier.ValueText); - break; - } - - return names; - } - - private static bool TryGetLuaApiMemberName(InvocationExpressionSyntax invocation, HashSet aliases, - bool hasStaticLuaApiImport, bool hasLuaApiNamespaceImport, HashSet shadowedNames, - out string memberName) - { - memberName = string.Empty; - switch (invocation.Expression) - { - case IdentifierNameSyntax identifier when hasStaticLuaApiImport && - !shadowedNames.Contains(identifier.Identifier.ValueText): - memberName = identifier.Identifier.ValueText; - return true; - - case MemberAccessExpressionSyntax { Name: IdentifierNameSyntax name } memberAccess: - var typeName = memberAccess.Expression.ToString(); - if (IsExactLuaApiTypeName(typeName) || aliases.Contains(typeName) || - (hasLuaApiNamespaceImport && string.Equals(typeName, "LuaApi", StringComparison.Ordinal))) - { - memberName = name.Identifier.ValueText; - return true; - } - - return false; - - default: - return false; - } - } - - private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, InvocationExpressionSyntax invocation, - string path) - { - if (!entry.ConditionalDirectUse || !entry.ConditionalAllowed || - !string.Equals(entry.MemberName, "lua_pushcclosure", StringComparison.Ordinal) || - !string.Equals(path, LightCFunctionFastPathSourcePath, StringComparison.Ordinal)) - return false; - - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count != 3 || !IsIdentifier(arguments[0].Expression, "Pointer") || - !IsIntegerZero(arguments[2].Expression)) - return false; - - var pushStatement = invocation.FirstAncestorOrSelf(); - var method = invocation.FirstAncestorOrSelf(); - if (pushStatement is null || method is null || - !string.Equals(method.Identifier.ValueText, "PushUncheckedFunction", StringComparison.Ordinal) || - method.Body is null) - return false; - - var statements = method.Body.Statements; - var statementIndex = -1; - for (var index = 0; index < statements.Count; index++) - if (statements[index] == pushStatement) - { - statementIndex = index; - break; - } - - return statementIndex > 0 && IsImmediateOneSlotCheckStackGuard(statements[statementIndex - 1]); - } - - private static bool IsImmediateOneSlotCheckStackGuard(StatementSyntax statement) - { - if (statement is not IfStatementSyntax { Statement: ThrowStatementSyntax } condition || - condition.Condition is not BinaryExpressionSyntax equals || - !equals.IsKind(SyntaxKind.EqualsExpression) || - !IsIntegerZero(equals.Right)) - return false; - - if (equals.Left is not InvocationExpressionSyntax - { - Expression: IdentifierNameSyntax { Identifier.ValueText: "lua_checkstack" }, - ArgumentList.Arguments: var arguments, - } || arguments.Count != 2 || !IsIdentifier(arguments[0].Expression, "Pointer") || - !IsIntegerOne(arguments[1].Expression)) - return false; - - return true; - } - - private static bool IsIntegerZero(ExpressionSyntax expression) - { - if (expression is not LiteralExpressionSyntax literal || !literal.IsKind(SyntaxKind.NumericLiteralExpression)) - return false; - - return literal.Token.Value switch - { - byte value => value == 0, - sbyte value => value == 0, - short value => value == 0, - ushort value => value == 0, - int value => value == 0, - uint value => value == 0, - long value => value == 0, - ulong value => value == 0, - _ => false, - }; - } - - private static bool IsIntegerOne(ExpressionSyntax expression) - { - if (expression is not LiteralExpressionSyntax literal || !literal.IsKind(SyntaxKind.NumericLiteralExpression)) - return false; - - return literal.Token.Value switch - { - byte value => value == 1, - sbyte value => value == 1, - short value => value == 1, - ushort value => value == 1, - int value => value == 1, - uint value => value == 1, - long value => value == 1, - ulong value => value == 1, - _ => false, - }; - } - - private static bool IsIdentifier(ExpressionSyntax expression, string identifier) - { - return expression is IdentifierNameSyntax name && - string.Equals(name.Identifier.ValueText, identifier, StringComparison.Ordinal); - } - - private static string[] ReadProvenance(JsonElement entry) - { - List provenance = []; - foreach (var item in entry.GetProperty("provenance").EnumerateArray()) - provenance.Add(RequiredString(item, "source")); - - return [.. provenance]; - } - - private static string RequiredString(JsonElement element, string propertyName) - { - var value = element.GetProperty(propertyName).GetString(); - Assert.False(string.IsNullOrWhiteSpace(value), $"'{propertyName}' must be a non-empty string."); - return value!; - } - - private static string GetRepositoryPath(string sourcePath) - { - return Path.GetRelativePath(RepositoryLayout.Root, sourcePath).Replace('\\', '/'); - } - - private static bool IsGeneratedPath(string repositoryPath) - { - return repositoryPath.Contains("/bin/", StringComparison.Ordinal) || - repositoryPath.Contains("/obj/", StringComparison.Ordinal); - } - - private static bool IsExactLuaApiTypeName(string typeName) - { - var normalized = typeName.Replace("global::", string.Empty, StringComparison.Ordinal); - return string.Equals(normalized, LuaApiQualifiedName, StringComparison.Ordinal); - } - - private static bool IsLuaApiNamespaceName(string namespaceName) - { - var normalized = namespaceName.Replace("global::", string.Empty, StringComparison.Ordinal); - return string.Equals(normalized, "CheatEngine.SDK.Lua.Interop.Api", StringComparison.Ordinal); - } - - private static string FormatViolations(List violations) - { - if (violations.Count == 0) - return string.Empty; - - List lines = ["Catalogued Lua APIs that can allocate or raise must run through the C11 bridge:"]; - for (var index = 0; index < violations.Count; index++) - { - var violation = violations[index]; - lines.Add($" - {violation.Path}:{violation.Line.ToString(CultureInfo.InvariantCulture)}: {violation.MemberName}: {violation.Reason}"); - } - - return string.Join(Environment.NewLine, lines); - } - - private sealed class LuaDirectApiPolicy(List entries) - { - private readonly Dictionary _byMember = CreateIndex(entries); - - internal List Entries { get; } = entries; - - internal bool TryGet(string memberName, out LuaDirectApiPolicyEntry entry) - { - return _byMember.TryGetValue(memberName, out entry!); - } - - private static Dictionary CreateIndex(List entries) - { - Dictionary index = new(StringComparer.Ordinal); - for (var entryIndex = 0; entryIndex < entries.Count; entryIndex++) - { - var entry = entries[entryIndex]; - Assert.True(index.TryAdd(entry.MemberName, entry), - $"The direct Lua API policy has a duplicate member '{entry.MemberName}'."); - } - - return index; - } - } - - private sealed record LuaDirectApiPolicyEntry( - string ManagedSymbol, - string MemberName, - string Raises, - bool AllowedDirectly, - bool RequiresBridge, - string Reason, - string[] Provenance, - string? BridgeOperation, - bool ConditionalDirectUse, - bool ConditionalAllowed, - string? ConditionalWhen, - string? ConditionalProof); - - private readonly record struct GuardViolation(string Path, int Line, string MemberName, string Reason); + private const string LuaApiQualifiedName = "CheatEngine.SDK.Lua.Interop.Api.LuaApi"; + private const string CataloguePath = "eng/lua-bridge/protected-operations.json"; + private const string LibrariesPath = "libs"; + private const string RawApiPath = "libs/CheatEngine.SDK.Lua.Interop/Api/"; + private const string LightCFunctionFastPathSourcePath = "libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs"; + + [Fact] + public void Production_layers_route_catalogued_risky_LuaApi_operations_through_the_native_boundary() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectProductionSources(policy); + + Assert.True(violations.Count == 0, FormatViolations(violations)); + } + + [Fact] + public void Catalogue_risky_direct_operations_have_a_deterministic_bridge_decision() + { + LuaDirectApiPolicy policy = LoadPolicy(); + HashSet bridgeOperations = LoadBridgeOperations(); + List entries = policy.Entries; + int riskyCount = 0; + + for (int index = 0; index < entries.Count; index++) + { + LuaDirectApiPolicyEntry entry = entries[index]; + if (string.Equals(entry.Raises, "never", StringComparison.Ordinal)) + { + Assert.True(entry.AllowedDirectly, entry.ManagedSymbol); + Assert.False(entry.RequiresBridge, entry.ManagedSymbol); + continue; + } + + riskyCount++; + Assert.False(entry.AllowedDirectly, entry.ManagedSymbol); + Assert.True(entry.RequiresBridge, entry.ManagedSymbol); + Assert.False(string.IsNullOrWhiteSpace(entry.Reason), entry.ManagedSymbol); + Assert.NotEmpty(entry.Provenance); + + if (entry.ConditionalDirectUse) + { + Assert.Equal("lua_pushcclosure", entry.MemberName); + Assert.True(entry.ConditionalAllowed, entry.ManagedSymbol); + Assert.False(string.IsNullOrWhiteSpace(entry.ConditionalWhen), entry.ManagedSymbol); + Assert.False(string.IsNullOrWhiteSpace(entry.ConditionalProof), entry.ManagedSymbol); + continue; + } + + if (string.IsNullOrWhiteSpace(entry.BridgeOperation)) + { + // lua_error is deliberately used only inside a bridge operation's native failure path. It has no + // public managed wrapper to name here, but the direct-use decision remains unambiguously "bridge". + Assert.Equal("lua_error", entry.MemberName); + continue; + } + + Assert.Contains(entry.BridgeOperation, bridgeOperations, StringComparer.Ordinal); + } + + Assert.True(riskyCount > 0, "The catalogue contains no direct Lua APIs classified memory or any."); + } + + [Fact] + public void Guard_reports_a_catalogued_risky_member_access() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using CheatEngine.SDK.Lua.Interop.Api; + + unsafe class C + { + void M(lua_State* state, byte* bytes) + { + _ = LuaApi.lua_pushlstring(state, bytes, 1); + } + } + """, "MemberAccess.cs"); + + GuardViolation violation = Assert.Single(violations); + Assert.Equal("lua_pushlstring", violation.MemberName); + Assert.Equal(7, violation.Line); + } + + [Fact] + public void Guard_reports_a_catalogued_risky_static_import() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using CheatEngine.SDK.Lua.Interop.Api; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + unsafe class C + { + void M(lua_State* state, lua_CFunction function) + { + lua_pushcclosure(state, function, 1); + } + } + """, "StaticImport.cs"); + + GuardViolation violation = Assert.Single(violations); + Assert.Equal("lua_pushcclosure", violation.MemberName); + } + + [Fact] + public void Guard_reports_a_catalogued_risky_exact_LuaApi_alias() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using CheatEngine.SDK.Lua.Interop.Api; + using Api = CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + unsafe class C + { + void M(lua_State* state) + { + Api.lua_createtable(state, 0, 0); + } + } + """, "Alias.cs"); + + GuardViolation violation = Assert.Single(violations); + Assert.Equal("lua_createtable", violation.MemberName); + } + + [Fact] + public void Guard_reports_a_zero_upvalue_closure_outside_the_audited_fast_path() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using CheatEngine.SDK.Lua.Interop.Api; + + unsafe class C + { + void M(lua_State* state, lua_CFunction function) + { + LuaApi.lua_pushcclosure(state, function, 0); + } + } + """, "LightCFunction.cs"); + + GuardViolation violation = Assert.Single(violations); + Assert.Equal("lua_pushcclosure", violation.MemberName); + } + + [Fact] + public void Guard_allows_only_the_audited_light_C_function_fast_path_after_its_immediate_one_slot_reservation() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + unsafe struct C + { + private lua_State* Pointer; + + void PushUncheckedFunction(lua_CFunction thunk) + { + if (lua_checkstack(Pointer, 1) == 0) + throw new InvalidOperationException(); + + lua_pushcclosure(Pointer, thunk, 0); + } + } + """, LightCFunctionFastPathSourcePath); + + Assert.Empty(violations); + } + + [Fact] + public void Guard_reports_the_audited_path_when_a_Lua_call_interrupts_the_reservation_and_push() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + unsafe struct C + { + private lua_State* Pointer; + + void PushUncheckedFunction(lua_CFunction thunk) + { + if (lua_checkstack(Pointer, 1) == 0) + throw new InvalidOperationException(); + + lua_pushinteger(Pointer, 42); + lua_pushcclosure(Pointer, thunk, 0); + } + } + """, LightCFunctionFastPathSourcePath); + + GuardViolation violation = Assert.Single(violations); + Assert.Equal("lua_pushcclosure", violation.MemberName); + } + + [Fact] + public void Guard_does_not_match_an_unrelated_method_with_a_Lua_shaped_name() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + class C + { + void lua_pushlstring(int value) { } + + void M() + { + lua_pushlstring(42); + } + } + """, "Unrelated.cs"); + + Assert.Empty(violations); + } + + [Fact] + public void Guard_does_not_treat_a_member_that_shadows_a_static_import_as_the_raw_LuaApi_member() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + class C + { + static void lua_rawset(int value) { } + + void M() + { + lua_rawset(42); + } + } + """, "ShadowedStaticImport.cs"); + + Assert.Empty(violations); + } + + [Fact] + public void Guard_does_not_match_an_unrelated_LuaApi_type_without_the_exact_SDK_import() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + unsafe class LuaApi + { + public static void lua_rawset(int value) { } + } + + class C + { + void M() + { + LuaApi.lua_rawset(42); + } + } + """, "UnrelatedType.cs"); + + Assert.Empty(violations); + } + + private static LuaDirectApiPolicy LoadPolicy() + { + using JsonDocument catalogue = LoadCatalogue(); + JsonElement directApiPolicy = catalogue.RootElement.GetProperty("directApiPolicy"); + List entries = []; + + foreach (JsonElement entry in directApiPolicy.EnumerateArray()) + { + string managedSymbol = RequiredString(entry, "managedSymbol"); + const string prefix = LuaApiQualifiedName + "."; + Assert.StartsWith(prefix, managedSymbol, StringComparison.Ordinal); + string memberName = managedSymbol[prefix.Length..]; + Assert.DoesNotContain(".", memberName, StringComparison.Ordinal); + + bool conditionalDirectUse = entry.TryGetProperty("conditionalDirectUse", out JsonElement conditional); + entries.Add(new LuaDirectApiPolicyEntry( + managedSymbol, + memberName, + RequiredString(entry, "raises"), + entry.GetProperty("allowedDirectly").GetBoolean(), + entry.GetProperty("requiresBridge").GetBoolean(), + RequiredString(entry, "reason"), + ReadProvenance(entry), + entry.TryGetProperty("bridgeOperation", out JsonElement bridgeOperation) + ? bridgeOperation.GetString() + : null, + conditionalDirectUse, + conditionalDirectUse && conditional.GetProperty("allowed").GetBoolean(), + conditionalDirectUse ? RequiredString(conditional, "when") : null, + conditionalDirectUse ? RequiredString(conditional, "proof") : null)); + } + + Assert.NotEmpty(entries); + return new LuaDirectApiPolicy(entries); + } + + private static HashSet LoadBridgeOperations() + { + using JsonDocument catalogue = LoadCatalogue(); + HashSet operations = new(StringComparer.Ordinal); + + foreach (JsonElement operation in catalogue.RootElement.GetProperty("operations").EnumerateArray()) + { + Assert.True(operations.Add(RequiredString(operation, "id")), + "Bridge operation identifiers must be unique."); + } + + return operations; + } + + private static JsonDocument LoadCatalogue() + { + string path = RepositoryLayout.PathOf(CataloguePath); + Assert.True(File.Exists(path), $"The direct Lua API policy catalogue is missing: '{path}'."); + + JsonDocument catalogue = JsonDocument.Parse(File.ReadAllText(path)); + Assert.Equal(1, catalogue.RootElement.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("cheatengine-sdk-lua-protected-operations", + RequiredString(catalogue.RootElement, "catalogId")); + return catalogue; + } + + private static List InspectProductionSources(LuaDirectApiPolicy policy) + { + string root = RepositoryLayout.PathOf(LibrariesPath); + List violations = []; + + foreach (string sourcePath in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) + { + string repositoryPath = GetRepositoryPath(sourcePath); + if (repositoryPath.StartsWith(RawApiPath, StringComparison.Ordinal) || IsGeneratedPath(repositoryPath)) + { + continue; + } + + AddViolations(violations, policy, File.ReadAllText(sourcePath), repositoryPath); + } + + return violations; + } + + private static List InspectSource(LuaDirectApiPolicy policy, string source, string path) + { + List violations = []; + AddViolations(violations, policy, source, path); + return violations; + } + + private static void AddViolations(List violations, LuaDirectApiPolicy policy, string source, + string path) + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + SyntaxTree tree = CSharpSyntaxTree.ParseText(source, path: path, cancellationToken: cancellationToken); + CompilationUnitSyntax root = tree.GetCompilationUnitRoot(cancellationToken); + HashSet aliases = CollectLuaApiAliases(root); + bool hasStaticLuaApiImport = HasStaticLuaApiImport(root); + bool hasLuaApiNamespaceImport = HasLuaApiNamespaceImport(root); + HashSet shadowedNames = CollectPotentialSourceDeclarations(root); + + foreach (SyntaxNode node in root.DescendantNodes()) + { + if (node is not InvocationExpressionSyntax invocation || + !TryGetLuaApiMemberName(invocation, aliases, hasStaticLuaApiImport, hasLuaApiNamespaceImport, + shadowedNames, out string memberName) || + !policy.TryGet(memberName, out LuaDirectApiPolicyEntry entry) || + !entry.RequiresBridge || + entry.AllowedDirectly || + IsConditionallyAllowed(entry, invocation, path)) + { + continue; + } + + int line = invocation.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + violations.Add(new GuardViolation(path, line, memberName, entry.Reason)); + } + } + + private static HashSet CollectLuaApiAliases(CompilationUnitSyntax root) + { + HashSet aliases = new(StringComparer.Ordinal); + foreach (SyntaxNode node in root.DescendantNodesAndSelf()) + { + if (node is not UsingDirectiveSyntax directive || directive.Alias is null || directive.Name is null || + !IsExactLuaApiTypeName(directive.Name.ToString())) + { + continue; + } + + aliases.Add(directive.Alias.Name.Identifier.ValueText); + } + + return aliases; + } + + private static bool HasStaticLuaApiImport(CompilationUnitSyntax root) + { + foreach (SyntaxNode node in root.DescendantNodesAndSelf()) + { + if (node is UsingDirectiveSyntax { Name: not null } directive && + directive.StaticKeyword.RawKind != 0 && + IsExactLuaApiTypeName(directive.Name.ToString())) + { + return true; + } + } + + return false; + } + + private static bool HasLuaApiNamespaceImport(CompilationUnitSyntax root) + { + foreach (SyntaxNode node in root.DescendantNodesAndSelf()) + { + if (node is UsingDirectiveSyntax { Alias: null, Name: not null } directive && + directive.StaticKeyword.RawKind == 0 && + IsLuaApiNamespaceName(directive.Name.ToString())) + { + return true; + } + } + + return false; + } + + private static HashSet CollectPotentialSourceDeclarations(CompilationUnitSyntax root) + { + HashSet names = new(StringComparer.Ordinal); + foreach (SyntaxNode node in root.DescendantNodes()) + { + switch (node) + { + case MethodDeclarationSyntax method: + names.Add(method.Identifier.ValueText); + break; + + case LocalFunctionStatementSyntax localFunction: + names.Add(localFunction.Identifier.ValueText); + break; + + case VariableDeclaratorSyntax variable: + names.Add(variable.Identifier.ValueText); + break; + + case ParameterSyntax parameter: + names.Add(parameter.Identifier.ValueText); + break; + } + } + + return names; + } + + private static bool TryGetLuaApiMemberName(InvocationExpressionSyntax invocation, HashSet aliases, + bool hasStaticLuaApiImport, bool hasLuaApiNamespaceImport, HashSet shadowedNames, + out string memberName) + { + memberName = string.Empty; + switch (invocation.Expression) + { + case IdentifierNameSyntax identifier when hasStaticLuaApiImport && + !shadowedNames.Contains(identifier.Identifier.ValueText): + memberName = identifier.Identifier.ValueText; + return true; + + case MemberAccessExpressionSyntax { Name: IdentifierNameSyntax name } memberAccess: + string typeName = memberAccess.Expression.ToString(); + if (IsExactLuaApiTypeName(typeName) || aliases.Contains(typeName) || + (hasLuaApiNamespaceImport && string.Equals(typeName, "LuaApi", StringComparison.Ordinal))) + { + memberName = name.Identifier.ValueText; + return true; + } + + return false; + + default: + return false; + } + } + + private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, InvocationExpressionSyntax invocation, + string path) + { + if (!entry.ConditionalDirectUse || !entry.ConditionalAllowed || + !string.Equals(entry.MemberName, "lua_pushcclosure", StringComparison.Ordinal) || + !string.Equals(path, LightCFunctionFastPathSourcePath, StringComparison.Ordinal)) + { + return false; + } + + SeparatedSyntaxList arguments = invocation.ArgumentList.Arguments; + if (arguments.Count != 3 || !IsIdentifier(arguments[0].Expression, "Pointer") || + !IsIntegerZero(arguments[2].Expression)) + { + return false; + } + + ExpressionStatementSyntax? pushStatement = invocation.FirstAncestorOrSelf(); + MethodDeclarationSyntax? method = invocation.FirstAncestorOrSelf(); + if (pushStatement is null || method is null || + !string.Equals(method.Identifier.ValueText, "PushUncheckedFunction", StringComparison.Ordinal) || + method.Body is null) + { + return false; + } + + SyntaxList statements = method.Body.Statements; + int statementIndex = -1; + for (int index = 0; index < statements.Count; index++) + { + if (statements[index] == pushStatement) + { + statementIndex = index; + break; + } + } + + return statementIndex > 0 && IsImmediateOneSlotCheckStackGuard(statements[statementIndex - 1]); + } + + private static bool IsImmediateOneSlotCheckStackGuard(StatementSyntax statement) + { + if (statement is not IfStatementSyntax condition || + !ContainsOnlyThrow(condition.Statement) || + condition.Condition is not BinaryExpressionSyntax equals || + !equals.IsKind(SyntaxKind.EqualsExpression) || + !IsIntegerZero(equals.Right)) + { + return false; + } + + if (equals.Left is not InvocationExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.ValueText: "lua_checkstack" }, + ArgumentList.Arguments: var arguments + } || arguments.Count != 2 || !IsIdentifier(arguments[0].Expression, "Pointer") || + !IsIntegerOne(arguments[1].Expression)) + { + return false; + } + + return true; + } + + private static bool ContainsOnlyThrow(StatementSyntax statement) + { + return statement is ThrowStatementSyntax || + (statement is BlockSyntax { Statements.Count: 1 } block && block.Statements[0] is ThrowStatementSyntax); + } + + private static bool IsIntegerZero(ExpressionSyntax expression) + { + if (expression is not LiteralExpressionSyntax literal || !literal.IsKind(SyntaxKind.NumericLiteralExpression)) + { + return false; + } + + return literal.Token.Value switch + { + byte value => value == 0, + sbyte value => value == 0, + short value => value == 0, + ushort value => value == 0, + int value => value == 0, + uint value => value == 0, + long value => value == 0, + ulong value => value == 0, + _ => false + }; + } + + private static bool IsIntegerOne(ExpressionSyntax expression) + { + if (expression is not LiteralExpressionSyntax literal || !literal.IsKind(SyntaxKind.NumericLiteralExpression)) + { + return false; + } + + return literal.Token.Value switch + { + byte value => value == 1, + sbyte value => value == 1, + short value => value == 1, + ushort value => value == 1, + int value => value == 1, + uint value => value == 1, + long value => value == 1, + ulong value => value == 1, + _ => false + }; + } + + private static bool IsIdentifier(ExpressionSyntax expression, string identifier) + { + return expression is IdentifierNameSyntax name && + string.Equals(name.Identifier.ValueText, identifier, StringComparison.Ordinal); + } + + private static string[] ReadProvenance(JsonElement entry) + { + List provenance = []; + foreach (JsonElement item in entry.GetProperty("provenance").EnumerateArray()) + { + provenance.Add(RequiredString(item, "source")); + } + + return [.. provenance]; + } + + private static string RequiredString(JsonElement element, string propertyName) + { + string? value = element.GetProperty(propertyName).GetString(); + Assert.False(string.IsNullOrWhiteSpace(value), $"'{propertyName}' must be a non-empty string."); + return value!; + } + + private static string GetRepositoryPath(string sourcePath) + { + return Path.GetRelativePath(RepositoryLayout.Root, sourcePath).Replace('\\', '/'); + } + + private static bool IsGeneratedPath(string repositoryPath) + { + return repositoryPath.Contains("/bin/", StringComparison.Ordinal) || + repositoryPath.Contains("/obj/", StringComparison.Ordinal); + } + + private static bool IsExactLuaApiTypeName(string typeName) + { + string normalized = typeName.Replace("global::", string.Empty, StringComparison.Ordinal); + return string.Equals(normalized, LuaApiQualifiedName, StringComparison.Ordinal); + } + + private static bool IsLuaApiNamespaceName(string namespaceName) + { + string normalized = namespaceName.Replace("global::", string.Empty, StringComparison.Ordinal); + return string.Equals(normalized, "CheatEngine.SDK.Lua.Interop.Api", StringComparison.Ordinal); + } + + private static string FormatViolations(List violations) + { + if (violations.Count == 0) + { + return string.Empty; + } + + List lines = ["Catalogued Lua APIs that can allocate or raise must run through the C11 bridge:"]; + for (int index = 0; index < violations.Count; index++) + { + GuardViolation violation = violations[index]; + lines.Add( + $" - {violation.Path}:{violation.Line.ToString(CultureInfo.InvariantCulture)}: {violation.MemberName}: {violation.Reason}"); + } + + return string.Join(Environment.NewLine, lines); + } + + private sealed class LuaDirectApiPolicy(List entries) + { + private readonly Dictionary _byMember = CreateIndex(entries); + + internal List Entries + { + get; + } = entries; + + internal bool TryGet(string memberName, out LuaDirectApiPolicyEntry entry) + { + return _byMember.TryGetValue(memberName, out entry!); + } + + private static Dictionary CreateIndex(List entries) + { + Dictionary index = new(StringComparer.Ordinal); + for (int entryIndex = 0; entryIndex < entries.Count; entryIndex++) + { + LuaDirectApiPolicyEntry entry = entries[entryIndex]; + Assert.True(index.TryAdd(entry.MemberName, entry), + $"The direct Lua API policy has a duplicate member '{entry.MemberName}'."); + } + + return index; + } + } + + private sealed record LuaDirectApiPolicyEntry( + string ManagedSymbol, + string MemberName, + string Raises, + bool AllowedDirectly, + bool RequiresBridge, + string Reason, + string[] Provenance, + string? BridgeOperation, + bool ConditionalDirectUse, + bool ConditionalAllowed, + string? ConditionalWhen, + string? ConditionalProof); + + private readonly record struct GuardViolation(string Path, int Line, string MemberName, string Reason); } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs index 4d95025a..e490baa6 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Reflection; + using CheatEngine.SDK.Analyzers.CodeFixes.Plugin; using CheatEngine.SDK.Analyzers.CodeFixes.Usage; using CheatEngine.SDK.Analyzers.Diagnostics; @@ -9,6 +10,7 @@ using CheatEngine.SDK.Analyzers.Usage; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.Diagnostics; @@ -22,256 +24,264 @@ namespace CheatEngine.SDK.Analyzers.Tests.Diagnostics; /// public sealed class DiagnosticCatalogTests { - private const string HelpLinkBase = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/"; - - public static TheoryData DescriptorIds => [.. SortedIds(AllDescriptors())]; - - [Fact] - public void Catalog_contains_exactly_the_documented_identifiers() - { - string[] expected = - [ - DiagnosticIds.InvalidPluginClass, - DiagnosticIds.MultiplePluginClasses, - DiagnosticIds.InvalidManualBootstrap, - DiagnosticIds.ReservedNamespace, - DiagnosticIds.GeneratedEntryPointCollision, - DiagnosticIds.RequiresPluginEnabledTooEarly, - DiagnosticIds.DisposeBorrowedValue, - DiagnosticIds.UnguardedUnmanagedCallersOnly, - DiagnosticIds.AsyncPluginLifecycle, - DiagnosticIds.UnsafeBlocksRequired, - DiagnosticIds.InvalidLuaBindingContainingType, - DiagnosticIds.InvalidLuaFunction, - DiagnosticIds.InvalidLuaGlobal, - DiagnosticIds.DuplicateLuaName, - DiagnosticIds.InvalidLuaAnnotationTarget, - DiagnosticIds.GeneratedLuaIdentityCollision, - ]; - - Assert.Equal(expected, SortedIds(AllDescriptors()), StringComparer.Ordinal); - Assert.Equal( - [ - "CESDK0001", "CESDK0002", "CESDK0003", "CESDK0004", "CESDK0005", "CESDK1001", "CESDK1003", "CESDK1004", - "CESDK1005", "CESDK2001", "CESDK2002", "CESDK2003", "CESDK2004", "CESDK2005", "CESDK2006", "CESDK2007", - ], - expected, - StringComparer.Ordinal); - } - - [Fact] - public void CESDK1002_remains_absent_until_the_CE_7_7_main_thread_probe_proves_its_contract() - { - Assert.DoesNotContain(AllDescriptors(), - static descriptor => string.Equals(descriptor.Id, "CESDK1002", StringComparison.Ordinal)); - } - - [Theory] - [MemberData(nameof(DescriptorIds))] - public void Descriptor_follows_the_conventions(string id) - { - var descriptor = Descriptor(id); - var title = descriptor.Title.ToString(CultureInfo.InvariantCulture); - var description = descriptor.Description.ToString(CultureInfo.InvariantCulture); - - Assert.Matches("^CESDK[0-9]{4}$", descriptor.Id); - Assert.Equal(CategoryFor(descriptor.Id), descriptor.Category); - Assert.Equal(HelpLinkBase + descriptor.Id + ".md", descriptor.HelpLinkUri); - Assert.True(descriptor.IsEnabledByDefault); - Assert.False(string.IsNullOrWhiteSpace(title)); - Assert.DoesNotMatch(@"\.$", title); - Assert.EndsWith(".", description, StringComparison.Ordinal); - } - - [Theory] - [MemberData(nameof(DescriptorIds))] - public void Descriptor_has_a_documentation_page_and_a_release_tracking_row(string id) - { - var descriptor = Descriptor(id); - - var page = RepositoryLayout.PathOf($"analyzers/docs/{id}.md"); - Assert.True(File.Exists(page), $"Missing documentation page '{page}'."); - Assert.StartsWith($"# {id}", File.ReadAllText(page), StringComparison.Ordinal); - - var unshipped = - File.ReadAllText( - RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md")); - var shipped = - File.ReadAllText( - RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Shipped.md")); - Assert.True( - HasRow(unshipped, descriptor) || HasRow(shipped, descriptor), - $"No release-tracking row starts with '{id} | {descriptor.Category} | {descriptor.DefaultSeverity} |'."); - } - - [Fact] - public void Release_tracking_rows_are_unique_across_shipped_and_unshipped_files() - { - var ids = new HashSet(StringComparer.Ordinal); - string[] trackingFiles = - [ - RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Shipped.md"), - RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md"), - ]; - - foreach (var trackingFile in trackingFiles) - foreach (var line in File.ReadLines(trackingFile)) - { - var cells = line.Split('|'); - if (cells.Length < 3) continue; - - var id = cells[0].Trim(); - if (!id.StartsWith("CESDK", StringComparison.Ordinal)) continue; - - Assert.True(ids.Add(id), $"Release tracking contains duplicate diagnostic id '{id}'."); - } - - Assert.Equal(SortedIds(AllDescriptors()), ids.Order(StringComparer.Ordinal), StringComparer.Ordinal); - } - - // The tables are aligned with padding in places, so the cells are compared trimmed: the rule id, the category and - // the severity still have to match exactly. - private static bool HasRow(string tracking, DiagnosticDescriptor descriptor) - { - foreach (var line in tracking.Split('\n')) - { - var cells = line.Split('|'); - if (cells.Length >= 3 - && string.Equals(cells[0].Trim(), descriptor.Id, StringComparison.Ordinal) - && string.Equals(cells[1].Trim(), descriptor.Category, StringComparison.Ordinal) - && string.Equals(cells[2].Trim(), descriptor.DefaultSeverity.ToString(), StringComparison.Ordinal)) - return true; - } - - return false; - } - - [Theory] - [InlineData(DiagnosticIds.InvalidPluginClass, false)] - [InlineData(DiagnosticIds.MultiplePluginClasses, true)] - [InlineData(DiagnosticIds.InvalidManualBootstrap, true)] - [InlineData(DiagnosticIds.ReservedNamespace, true)] - [InlineData(DiagnosticIds.GeneratedEntryPointCollision, true)] - [InlineData(DiagnosticIds.RequiresPluginEnabledTooEarly, false)] - [InlineData(DiagnosticIds.DisposeBorrowedValue, false)] - [InlineData(DiagnosticIds.UnguardedUnmanagedCallersOnly, false)] - [InlineData(DiagnosticIds.AsyncPluginLifecycle, false)] - [InlineData(DiagnosticIds.UnsafeBlocksRequired, false)] - [InlineData(DiagnosticIds.InvalidLuaBindingContainingType, false)] - [InlineData(DiagnosticIds.InvalidLuaFunction, false)] - [InlineData(DiagnosticIds.InvalidLuaGlobal, false)] - [InlineData(DiagnosticIds.DuplicateLuaName, true)] - [InlineData(DiagnosticIds.InvalidLuaAnnotationTarget, false)] - [InlineData(DiagnosticIds.GeneratedLuaIdentityCollision, false)] - public void Compilation_end_tag_is_on_the_rules_reported_at_compilation_end(string id, bool compilationEnd) - { - Assert.Equal(compilationEnd, - Descriptor(id).CustomTags.Contains(WellKnownDiagnosticTags.CompilationEnd, StringComparer.Ordinal)); - } - - [Fact] - public void Every_descriptor_is_reported_by_exactly_one_analyzer() - { - DiagnosticAnalyzer[] analyzers = - [ - new CheatEnginePluginAnalyzer(), - new UnmanagedCallersOnlyGuardAnalyzer(), - new LuaBindingAnalyzer(), - new PluginLifecycleAndOwnershipAnalyzer(), - new LuaObjectBindingAnalyzer(), - ]; - - var supported = SortedIds(analyzers.SelectMany(analyzer => analyzer.SupportedDiagnostics)); - - Assert.Equal(SortedIds(AllDescriptors()), supported, StringComparer.Ordinal); - } - - [Fact] - public void Code_fix_providers_fix_their_rule_and_support_fix_all() - { - CodeFixProvider pluginFix = new PluginClassShapeCodeFixProvider(); - CodeFixProvider guardFix = new UnmanagedCallersOnlyGuardCodeFixProvider(); - - Assert.Equal([DiagnosticIds.InvalidPluginClass], pluginFix.FixableDiagnosticIds, StringComparer.Ordinal); - Assert.Equal([DiagnosticIds.UnguardedUnmanagedCallersOnly], guardFix.FixableDiagnosticIds, - StringComparer.Ordinal); - Assert.Same(WellKnownFixAllProviders.BatchFixer, pluginFix.GetFixAllProvider()); - Assert.Same(WellKnownFixAllProviders.BatchFixer, guardFix.GetFixAllProvider()); - } - - [Fact] - public void Every_plugin_class_problem_has_its_own_message_and_a_place_in_the_report_order() - { - PluginShapeIssues[] problems = - [.. Enum.GetValues().Where(problem => problem != PluginShapeIssues.None)]; - string[] messages = [.. problems.Select(PluginClassProblemText.Describe)]; - var fallback = PluginClassProblemText.Describe(PluginShapeIssues.None); - - Assert.Equal(problems.Order(), PluginClassProblemText.ReportOrder.Order()); - Assert.DoesNotContain(fallback, messages, StringComparer.Ordinal); - Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); - } - - [Fact] - public void Every_containing_type_problem_has_its_own_message_and_a_place_in_the_report_order() - { - ContainingTypeIssues[] problems = - [.. Enum.GetValues().Where(problem => problem != ContainingTypeIssues.None)]; - string[] messages = [.. problems.Select(ContainingTypeProblemText.Describe)]; - - Assert.Equal(problems.Order(), ContainingTypeProblemText.ReportOrder.Order()); - Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); - } - - [Fact] - public void Every_lua_function_problem_has_its_own_message_and_a_place_in_the_report_order() - { - LuaFunctionShapeIssues[] problems = - [.. Enum.GetValues().Where(problem => problem != LuaFunctionShapeIssues.None)]; - string[] messages = [.. problems.Select(LuaFunctionProblemText.Describe)]; - - Assert.Equal(problems.Order(), LuaFunctionProblemText.ReportOrder.Order()); - Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); - } - - [Fact] - public void Every_lua_global_problem_has_its_own_message_and_a_place_in_the_report_order() - { - LuaGlobalShapeIssues[] problems = - [.. Enum.GetValues().Where(problem => problem != LuaGlobalShapeIssues.None)]; - string[] messages = [.. problems.Select(LuaGlobalProblemText.Describe)]; - - Assert.Equal(problems.Order(), LuaGlobalProblemText.ReportOrder.Order()); - Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); - } - - private static DiagnosticDescriptor Descriptor(string id) - { - return AllDescriptors().Single(descriptor => string.Equals(descriptor.Id, id, StringComparison.Ordinal)); - } - - private static IEnumerable SortedIds(IEnumerable descriptors) - { - return descriptors.Select(descriptor => descriptor.Id).Order(StringComparer.Ordinal); - } - - private static IEnumerable AllDescriptors() - { - return typeof(DiagnosticDescriptors) - .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) - .Where(field => field.FieldType == typeof(DiagnosticDescriptor)) - .Select(field => (DiagnosticDescriptor)field.GetValue(null)!); - } - - // 'CESDK0xxx' -> CheatEngine.SDK.Plugin, 'CESDK1xxx' -> CheatEngine.SDK.Usage, 'CESDK2xxx' -> CheatEngine.SDK.Generation. - private static string CategoryFor(string id) - { - return id[5] switch - { - '0' => "CheatEngine.SDK.Plugin", - '1' => "CheatEngine.SDK.Usage", - '2' => "CheatEngine.SDK.Generation", - _ => throw new ArgumentOutOfRangeException(nameof(id), id, "Unknown diagnostic range.") - }; - } + private const string HelpLinkBase = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/"; + + public static TheoryData DescriptorIds => [.. SortedIds(AllDescriptors())]; + + [Fact] + public void Catalog_contains_exactly_the_documented_identifiers() + { + string[] expected = + [ + DiagnosticIds.InvalidPluginClass, + DiagnosticIds.MultiplePluginClasses, + DiagnosticIds.InvalidManualBootstrap, + DiagnosticIds.ReservedNamespace, + DiagnosticIds.GeneratedEntryPointCollision, + DiagnosticIds.RequiresPluginEnabledTooEarly, + DiagnosticIds.DisposeBorrowedValue, + DiagnosticIds.UnguardedUnmanagedCallersOnly, + DiagnosticIds.AsyncPluginLifecycle, + DiagnosticIds.UnsafeBlocksRequired, + DiagnosticIds.InvalidLuaBindingContainingType, + DiagnosticIds.InvalidLuaFunction, + DiagnosticIds.InvalidLuaGlobal, + DiagnosticIds.DuplicateLuaName, + DiagnosticIds.InvalidLuaAnnotationTarget, + DiagnosticIds.GeneratedLuaIdentityCollision + ]; + + Assert.Equal(expected, SortedIds(AllDescriptors()), StringComparer.Ordinal); + Assert.Equal( + [ + "CESDK0001", "CESDK0002", "CESDK0003", "CESDK0004", "CESDK0005", "CESDK1001", "CESDK1003", "CESDK1004", + "CESDK1005", "CESDK2001", "CESDK2002", "CESDK2003", "CESDK2004", "CESDK2005", "CESDK2006", "CESDK2007" + ], + expected, + StringComparer.Ordinal); + } + + [Fact] + public void CESDK1002_remains_absent_until_the_CE_7_7_main_thread_probe_proves_its_contract() + { + Assert.DoesNotContain(AllDescriptors(), + static descriptor => string.Equals(descriptor.Id, "CESDK1002", StringComparison.Ordinal)); + } + + [Theory] + [MemberData(nameof(DescriptorIds))] + public void Descriptor_follows_the_conventions(string id) + { + DiagnosticDescriptor descriptor = Descriptor(id); + string title = descriptor.Title.ToString(CultureInfo.InvariantCulture); + string description = descriptor.Description.ToString(CultureInfo.InvariantCulture); + + Assert.Matches("^CESDK[0-9]{4}$", descriptor.Id); + Assert.Equal(CategoryFor(descriptor.Id), descriptor.Category); + Assert.Equal(HelpLinkBase + descriptor.Id + ".md", descriptor.HelpLinkUri); + Assert.True(descriptor.IsEnabledByDefault); + Assert.False(string.IsNullOrWhiteSpace(title)); + Assert.DoesNotMatch(@"\.$", title); + Assert.EndsWith(".", description, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(DescriptorIds))] + public void Descriptor_has_a_documentation_page_and_a_release_tracking_row(string id) + { + DiagnosticDescriptor descriptor = Descriptor(id); + + string page = RepositoryLayout.PathOf($"analyzers/docs/{id}.md"); + Assert.True(File.Exists(page), $"Missing documentation page '{page}'."); + Assert.StartsWith($"# {id}", File.ReadAllText(page), StringComparison.Ordinal); + + string unshipped = + File.ReadAllText( + RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md")); + string shipped = + File.ReadAllText( + RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Shipped.md")); + Assert.True( + HasRow(unshipped, descriptor) || HasRow(shipped, descriptor), + $"No release-tracking row starts with '{id} | {descriptor.Category} | {descriptor.DefaultSeverity} |'."); + } + + [Fact] + public void Release_tracking_rows_are_unique_across_shipped_and_unshipped_files() + { + HashSet ids = new(StringComparer.Ordinal); + string[] trackingFiles = + [ + RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Shipped.md"), + RepositoryLayout.PathOf("analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md") + ]; + + foreach (string trackingFile in trackingFiles) + foreach (string line in File.ReadLines(trackingFile)) + { + string[] cells = line.Split('|'); + if (cells.Length < 3) + { + continue; + } + + string id = cells[0].Trim(); + if (!id.StartsWith("CESDK", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(ids.Add(id), $"Release tracking contains duplicate diagnostic id '{id}'."); + } + + Assert.Equal(SortedIds(AllDescriptors()), ids.Order(StringComparer.Ordinal), StringComparer.Ordinal); + } + + // The tables are aligned with padding in places, so the cells are compared trimmed: the rule id, the category and + // the severity still have to match exactly. + private static bool HasRow(string tracking, DiagnosticDescriptor descriptor) + { + foreach (string line in tracking.Split('\n')) + { + string[] cells = line.Split('|'); + if (cells.Length >= 3 + && string.Equals(cells[0].Trim(), descriptor.Id, StringComparison.Ordinal) + && string.Equals(cells[1].Trim(), descriptor.Category, StringComparison.Ordinal) + && string.Equals(cells[2].Trim(), descriptor.DefaultSeverity.ToString(), StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + [Theory] + [InlineData(DiagnosticIds.InvalidPluginClass, false)] + [InlineData(DiagnosticIds.MultiplePluginClasses, true)] + [InlineData(DiagnosticIds.InvalidManualBootstrap, true)] + [InlineData(DiagnosticIds.ReservedNamespace, true)] + [InlineData(DiagnosticIds.GeneratedEntryPointCollision, true)] + [InlineData(DiagnosticIds.RequiresPluginEnabledTooEarly, false)] + [InlineData(DiagnosticIds.DisposeBorrowedValue, false)] + [InlineData(DiagnosticIds.UnguardedUnmanagedCallersOnly, false)] + [InlineData(DiagnosticIds.AsyncPluginLifecycle, false)] + [InlineData(DiagnosticIds.UnsafeBlocksRequired, false)] + [InlineData(DiagnosticIds.InvalidLuaBindingContainingType, false)] + [InlineData(DiagnosticIds.InvalidLuaFunction, false)] + [InlineData(DiagnosticIds.InvalidLuaGlobal, false)] + [InlineData(DiagnosticIds.DuplicateLuaName, true)] + [InlineData(DiagnosticIds.InvalidLuaAnnotationTarget, false)] + [InlineData(DiagnosticIds.GeneratedLuaIdentityCollision, false)] + public void Compilation_end_tag_is_on_the_rules_reported_at_compilation_end(string id, bool compilationEnd) + { + Assert.Equal(compilationEnd, + Descriptor(id).CustomTags.Contains(WellKnownDiagnosticTags.CompilationEnd, StringComparer.Ordinal)); + } + + [Fact] + public void Every_descriptor_is_reported_by_exactly_one_analyzer() + { + DiagnosticAnalyzer[] analyzers = + [ + new CheatEnginePluginAnalyzer(), + new UnmanagedCallersOnlyGuardAnalyzer(), + new LuaBindingAnalyzer(), + new PluginLifecycleAndOwnershipAnalyzer(), + new LuaObjectBindingAnalyzer() + ]; + + IEnumerable supported = SortedIds(analyzers.SelectMany(analyzer => analyzer.SupportedDiagnostics)); + + Assert.Equal(SortedIds(AllDescriptors()), supported, StringComparer.Ordinal); + } + + [Fact] + public void Code_fix_providers_fix_their_rule_and_support_fix_all() + { + CodeFixProvider pluginFix = new PluginClassShapeCodeFixProvider(); + CodeFixProvider guardFix = new UnmanagedCallersOnlyGuardCodeFixProvider(); + + Assert.Equal([DiagnosticIds.InvalidPluginClass], pluginFix.FixableDiagnosticIds, StringComparer.Ordinal); + Assert.Equal([DiagnosticIds.UnguardedUnmanagedCallersOnly], guardFix.FixableDiagnosticIds, + StringComparer.Ordinal); + Assert.Same(WellKnownFixAllProviders.BatchFixer, pluginFix.GetFixAllProvider()); + Assert.Same(WellKnownFixAllProviders.BatchFixer, guardFix.GetFixAllProvider()); + } + + [Fact] + public void Every_plugin_class_problem_has_its_own_message_and_a_place_in_the_report_order() + { + PluginShapeIssues[] problems = + [.. Enum.GetValues().Where(problem => problem != PluginShapeIssues.None)]; + string[] messages = [.. problems.Select(PluginClassProblemText.Describe)]; + string fallback = PluginClassProblemText.Describe(PluginShapeIssues.None); + + Assert.Equal(problems.Order(), PluginClassProblemText.ReportOrder.Order()); + Assert.DoesNotContain(fallback, messages, StringComparer.Ordinal); + Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Every_containing_type_problem_has_its_own_message_and_a_place_in_the_report_order() + { + ContainingTypeIssues[] problems = + [.. Enum.GetValues().Where(problem => problem != ContainingTypeIssues.None)]; + string[] messages = [.. problems.Select(ContainingTypeProblemText.Describe)]; + + Assert.Equal(problems.Order(), ContainingTypeProblemText.ReportOrder.Order()); + Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Every_lua_function_problem_has_its_own_message_and_a_place_in_the_report_order() + { + LuaFunctionShapeIssues[] problems = + [.. Enum.GetValues().Where(problem => problem != LuaFunctionShapeIssues.None)]; + string[] messages = [.. problems.Select(LuaFunctionProblemText.Describe)]; + + Assert.Equal(problems.Order(), LuaFunctionProblemText.ReportOrder.Order()); + Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Every_lua_global_problem_has_its_own_message_and_a_place_in_the_report_order() + { + LuaGlobalShapeIssues[] problems = + [.. Enum.GetValues().Where(problem => problem != LuaGlobalShapeIssues.None)]; + string[] messages = [.. problems.Select(LuaGlobalProblemText.Describe)]; + + Assert.Equal(problems.Order(), LuaGlobalProblemText.ReportOrder.Order()); + Assert.Equal(problems.Length, messages.Distinct(StringComparer.Ordinal).Count()); + } + + private static DiagnosticDescriptor Descriptor(string id) + { + return AllDescriptors().Single(descriptor => string.Equals(descriptor.Id, id, StringComparison.Ordinal)); + } + + private static IEnumerable SortedIds(IEnumerable descriptors) + { + return descriptors.Select(descriptor => descriptor.Id).Order(StringComparer.Ordinal); + } + + private static IEnumerable AllDescriptors() + { + return typeof(DiagnosticDescriptors) + .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Where(field => field.FieldType == typeof(DiagnosticDescriptor)) + .Select(field => (DiagnosticDescriptor) field.GetValue(null)!); + } + + // 'CESDK0xxx' -> CheatEngine.SDK.Plugin, 'CESDK1xxx' -> CheatEngine.SDK.Usage, 'CESDK2xxx' -> CheatEngine.SDK.Generation. + private static string CategoryFor(string id) + { + return id[5] switch + { + '0' => "CheatEngine.SDK.Plugin", + '1' => "CheatEngine.SDK.Usage", + '2' => "CheatEngine.SDK.Generation", + _ => throw new ArgumentOutOfRangeException(nameof(id), id, "Unknown diagnostic range.") + }; + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs index b0557732..bffcf14c 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Globalization; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.Generation; using CheatEngine.SDK.Analyzers.Tests.Infrastructure; @@ -7,6 +8,7 @@ using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.SourceGenerators.LuaBindings; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -33,504 +35,498 @@ namespace CheatEngine.SDK.Analyzers.Tests.Generation; /// public sealed class LuaBindingAnalyzerTests { - private const string ShapeUsings = "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\n"; - private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); - - // The real SDK assemblies the shape checks are written against, taken from the copies loaded in this test - // process (RoslynEnvironment of CheatEngine.SDK.SourceGenerators.LuaBindings.Tests does the same). - private static readonly ImmutableArray SdkReferences = - [ - MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(LuaApi).Assembly.Location), - MetadataReference.CreateFromFile(typeof(LuaState).Assembly.Location) - ]; - - // The annotations are present, but no CheatEngine.SDK.Lua runtime assembly is referenced. A source type with the - // runtime's metadata name must therefore not make a binding valid. - private static readonly ImmutableArray SdkReferencesWithoutLuaRuntime = - [ - MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), - ]; - - public static TheoryData Shapes => new() - { - { - "valid function", - ShapeUsings + - "public static partial class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", - true - }, - { - "instance method", - ShapeUsings + - "public partial class Functions { [LuaFunction(\"add\")] public long Add(long a, long b) => a + b; }", - false - }, - { - "not partial container", - ShapeUsings + - "public static class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", - false - }, - { - "invalid lua name", - ShapeUsings + - "public static partial class Functions { [LuaFunction(\"end\")] public static long Add(long a, long b) => a + b; }", - false - }, - { - "valid global try form", - ShapeUsings + - "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial bool TryReadInt32(nuint address, out int value); }", - true - }, - { - "global try form returning int instead of bool", - ShapeUsings + - "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial int TryReadInt32(nuint address, out int value); }", - false - }, - { - "documented detailed global form", - """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Calls; - - namespace MyPlugin; - - public static partial class Memory - { - [LuaGlobal("readInteger")] - public static partial LuaOperationStatus TryReadInt32Detailed(nuint address, bool signed, out int value); - } - """, - true - } - }; - - [Fact] - public async Task Binding_without_allow_unsafe_reports_CESDK2001() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class Functions - { - [LuaFunction("add")] - public static long Add(long a, long b) => a + b; - } - """, - false); - - var diagnostic = Assert.Single(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.UnsafeBlocksRequired, StringComparison.Ordinal)); - Assert.Contains("Add", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } - - [Fact] - public async Task Valid_binding_with_allow_unsafe_reports_nothing() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.State; - - namespace Demo; - - public static partial class Functions - { - [LuaFunction("add")] - public static long Add(long a, long b) => a + b; - - [LuaFunction("isint")] - public static bool IsInteger(LuaState state, double value) => state.IsInteger(1); - } - - public static partial class Bindings - { - [LuaGlobal("readInteger")] - public static partial bool TryReadInt32(nuint address, out int value); - } - """, - true); - - Assert.Empty(diagnostics); - } - - [Fact] - public async Task Non_partial_containing_type_reports_CESDK2002() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static class Functions - { - [LuaFunction("add")] - public static long Add(long a, long b) => a + b; - } - """, - true); - - var diagnostic = Assert.Single(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal)); - Assert.Contains("partial", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } - - [Fact] - public async Task Instance_lua_function_reports_CESDK2003() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public partial class Functions - { - [LuaFunction("add")] - public long Add(long a, long b) => a + b; - } - """, - true); - - var diagnostic = Assert.Single(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); - Assert.Contains("static", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } - - [Fact] - public async Task Lua_function_with_a_reserved_word_name_reports_CESDK2003() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class Functions - { - [LuaFunction("end")] - public static long Add(long a, long b) => a + b; - } - """, - true); - - var diagnostic = Assert.Single(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); - Assert.Contains("Lua identifier", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } - - [Fact] - public async Task Lua_global_try_form_not_returning_bool_reports_CESDK2004() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class Bindings - { - [LuaGlobal("readInteger")] - public static partial int TryReadInt32(nuint address, out int value); - } - """, - true); - - var diagnostic = Assert.Single(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); - Assert.Contains("bool", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } - - [Fact] - public async Task Lua_global_outcome_form_returning_lua_operation_status_is_accepted() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Calls; - - namespace Demo; - - public static partial class Bindings - { - [LuaGlobal("readInteger")] - public static partial LuaOperationStatus TryReadInt32Detailed(nuint address, out int value); - } - """, - true); - - Assert.DoesNotContain(diagnostics, - static diagnostic => string.Equals(diagnostic.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); - } - - [Fact] - public async Task Explicit_static_interface_marshaller_members_report_CESDK2003_and_skip_generation() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Marshalling; - using CheatEngine.SDK.Lua.State; - - namespace Demo; - - public readonly struct Token { } - - public readonly struct ExplicitMarshaller : ILuaMarshaller - { - static void ILuaMarshaller.Push(LuaState state, Token value) { } - - static bool ILuaMarshaller.TryRead(LuaState state, int index, out Token value) - { - value = default; - return false; - } - } - - public static partial class Bindings - { - [LuaFunction("token")] - public static int RoundTrip([LuaMarshaller(typeof(ExplicitMarshaller))] Token value) => 0; - } - """; - var compilation = CreateCompilation(source, true); - - Assert.False(RunGenerator(compilation)); - - var diagnostic = Assert.Single(await GetDiagnosticsAsync(compilation), - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); - Assert.Contains("parameter types a marshaller reads", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } - - [Fact] - public async Task Duplicate_lua_function_names_in_the_same_type_report_CESDK2005_on_both_members() - { - // LuaFunctionTables.Group/SelectThunks drops both members from the generator's output with no explanation - // of its own: this is the compilation-end pass that names the cause. - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class Functions - { - [LuaFunction("shared")] - public static int First() => 1; - - [LuaFunction("shared")] - public static int Second() => 2; - } - """, - true); - - Diagnostic[] duplicates = - [ - .. diagnostics.Where(static d => - string.Equals(d.Id, DiagnosticIds.DuplicateLuaName, StringComparison.Ordinal)) - ]; - Assert.Equal(2, duplicates.Length); - Assert.Contains(duplicates, - d => d.GetMessage(CultureInfo.InvariantCulture).Contains("First", StringComparison.Ordinal)); - Assert.Contains(duplicates, - d => d.GetMessage(CultureInfo.InvariantCulture).Contains("Second", StringComparison.Ordinal)); - Assert.All(duplicates, - d => Assert.Contains("duplicates the Lua name", d.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal)); - } - - [Fact] - public async Task Duplicate_lua_function_names_in_different_types_report_nothing() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class First - { - [LuaFunction("shared")] - public static int Value() => 1; - } - - public static partial class Second - { - [LuaFunction("shared")] - public static int Value() => 2; - } - """, - true); - - Assert.Empty(diagnostics); - } - - [Fact] - public async Task Duplicate_lua_function_name_where_one_member_has_another_problem_reports_only_that_problem() - { - // 'Second' is not static: it was never a candidate for the generator's grouping step either - // (LuaFunctionModel.IsValid), so the still-valid 'First' is not a duplicate of anything and is silently - // exported; only the independent NotStatic problem on 'Second' is reported. - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public partial class Functions - { - [LuaFunction("shared")] - public static int First() => 1; - - [LuaFunction("shared")] - public int Second() => 2; - } - """, - true); - - var diagnostic = Assert.Single(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); - Assert.Contains("Second", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - Assert.Contains("static", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } - - [Fact] - public async Task Several_problems_on_one_member_are_all_reported() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static class Functions - { - [LuaFunction("end")] - public static long Add(long a, long b = 0) => a + b; - } - """, - false); - - Assert.Contains(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.UnsafeBlocksRequired, StringComparison.Ordinal)); - Assert.Contains(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal)); - Assert.Contains(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("reserved word", StringComparison.Ordinal)); - Assert.Contains(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("default value", StringComparison.Ordinal)); - } - - [Fact] - public async Task Same_name_source_LuaState_without_the_sdk_runtime_is_rejected_by_generator_and_analyzer() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace CheatEngine.SDK.Lua.State - { - public readonly struct LuaState - { - } - } - - namespace Demo; - - public static partial class Functions - { - [LuaFunction("callback")] - public static int Callback(global::CheatEngine.SDK.Lua.State.LuaState state) => 0; - } - - public static partial class Globals - { - [LuaGlobal("read")] - public static partial int Read(global::CheatEngine.SDK.Lua.State.LuaState state); - } - """; - var compilation = CreateCompilation(source, allowUnsafe: true, - SdkReferencesWithoutLuaRuntime); - - Assert.False(RunGenerator(compilation)); - - var diagnostics = await GetDiagnosticsAsync(compilation); - Assert.Contains(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture) - .Contains("parameter type", StringComparison.Ordinal)); - Assert.Contains(diagnostics, - static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("argument type", StringComparison.Ordinal)); - } - - [Theory] - [MemberData(nameof(Shapes))] - public async Task Generator_and_analyzer_agree_on_every_shape(string shape, string source, bool expectedValid) - { - var compilation = CreateCompilation(source, true); - - var generatorEmits = RunGenerator(compilation); - var diagnostics = await GetDiagnosticsAsync(compilation); - var analyzerReportsShapeProblem = diagnostics.Any(static d => - string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal) - || string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) - || string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); - - Assert.True(generatorEmits == expectedValid, - $"'{shape}': the generator {(generatorEmits ? "emitted" : "stayed silent")}, expected {(expectedValid ? "output" : "silence")}."); - Assert.True( - analyzerReportsShapeProblem != expectedValid, - $"'{shape}': the analyzer {(analyzerReportsShapeProblem ? "reported" : "stayed silent")} a shape problem, expected it to {(expectedValid ? "stay silent" : "report")}."); - } - - private static CSharpCompilation CreateCompilation(string source, bool allowUnsafe) - { - return CreateCompilation(source, allowUnsafe, SdkReferences); - } - - private static CSharpCompilation CreateCompilation(string source, bool allowUnsafe, - ImmutableArray sdkReferences) - { - return CSharpCompilation.Create( - "LuaBindingAnalyzerTestAssembly", - [ - CSharpSyntaxTree.ParseText(TestText.Normalize(source), ParseOptions, "Test.cs", - cancellationToken: TestContext.Current.CancellationToken), - ], - LocalFrameworkReferences.References.AddRange(sdkReferences), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable, allowUnsafe: allowUnsafe)); - } - - private static Task> AnalyzeAsync(string source, bool allowUnsafe) - { - return GetDiagnosticsAsync(CreateCompilation(source, allowUnsafe)); - } - - private static Task> GetDiagnosticsAsync(CSharpCompilation compilation) - { - var withAnalyzers = compilation.WithAnalyzers([new LuaBindingAnalyzer()], options: null); - return withAnalyzers.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); - } - - // Same driver shape as CheatEngine.SDK.SourceGenerators.LuaBindings.Tests' GeneratorRun: "emits" means at least one - // generated source (a thunk file or a wrapper-body file). - private static bool RunGenerator(CSharpCompilation compilation) - { - GeneratorDriver driver = CSharpGeneratorDriver.Create([new LuaBindingsGenerator().AsSourceGenerator()], - parseOptions: ParseOptions); - driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _, - TestContext.Current.CancellationToken); - return !driver.GetRunResult().Results.Single().GeneratedSources.IsEmpty; - } + private const string ShapeUsings = "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\n"; + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + + // The real SDK assemblies the shape checks are written against, taken from the copies loaded in this test + // process (RoslynEnvironment of CheatEngine.SDK.SourceGenerators.LuaBindings.Tests does the same). + private static readonly ImmutableArray SdkReferences = + [ + MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(LuaApi).Assembly.Location), + MetadataReference.CreateFromFile(typeof(LuaState).Assembly.Location) + ]; + + // The annotations are present, but no CheatEngine.SDK.Lua runtime assembly is referenced. A source type with the + // runtime's metadata name must therefore not make a binding valid. + private static readonly ImmutableArray SdkReferencesWithoutLuaRuntime = + [ + MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location) + ]; + + public static TheoryData Shapes => new() + { + { + "valid function", ShapeUsings + + "public static partial class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", + true + }, + { + "instance method", ShapeUsings + + "public partial class Functions { [LuaFunction(\"add\")] public long Add(long a, long b) => a + b; }", + false + }, + { + "not partial container", ShapeUsings + + "public static class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", + false + }, + { + "invalid lua name", ShapeUsings + + "public static partial class Functions { [LuaFunction(\"end\")] public static long Add(long a, long b) => a + b; }", + false + }, + { + "valid global try form", ShapeUsings + + "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial bool TryReadInt32(nuint address, out int value); }", + true + }, + { + "global try form returning int instead of bool", ShapeUsings + + "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial int TryReadInt32(nuint address, out int value); }", + false + }, + { + "documented detailed global form", """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + + namespace MyPlugin; + + public static partial class Memory + { + [LuaGlobal("readInteger")] + public static partial LuaOperationStatus TryReadInt32Detailed(nuint address, bool signed, out int value); + } + """, + true + } + }; + + [Fact] + public async Task Binding_without_allow_unsafe_reports_CESDK2001() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class Functions + { + [LuaFunction("add")] + public static long Add(long a, long b) => a + b; + } + """, + false); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.UnsafeBlocksRequired, StringComparison.Ordinal)); + Assert.Contains("Add", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task Valid_binding_with_allow_unsafe_reports_nothing() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public static partial class Functions + { + [LuaFunction("add")] + public static long Add(long a, long b) => a + b; + + [LuaFunction("isint")] + public static bool IsInteger(LuaState state, double value) => state.IsInteger(1); + } + + public static partial class Bindings + { + [LuaGlobal("readInteger")] + public static partial bool TryReadInt32(nuint address, out int value); + } + """, + true); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task Non_partial_containing_type_reports_CESDK2002() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static class Functions + { + [LuaFunction("add")] + public static long Add(long a, long b) => a + b; + } + """, + true); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal)); + Assert.Contains("partial", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task Instance_lua_function_reports_CESDK2003() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public partial class Functions + { + [LuaFunction("add")] + public long Add(long a, long b) => a + b; + } + """, + true); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); + Assert.Contains("static", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task Lua_function_with_a_reserved_word_name_reports_CESDK2003() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class Functions + { + [LuaFunction("end")] + public static long Add(long a, long b) => a + b; + } + """, + true); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); + Assert.Contains("Lua identifier", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + + [Fact] + public async Task Lua_global_try_form_not_returning_bool_reports_CESDK2004() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class Bindings + { + [LuaGlobal("readInteger")] + public static partial int TryReadInt32(nuint address, out int value); + } + """, + true); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); + Assert.Contains("bool", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task Lua_global_outcome_form_returning_lua_operation_status_is_accepted() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + + namespace Demo; + + public static partial class Bindings + { + [LuaGlobal("readInteger")] + public static partial LuaOperationStatus TryReadInt32Detailed(nuint address, out int value); + } + """, + true); + + Assert.DoesNotContain(diagnostics, + static diagnostic => + string.Equals(diagnostic.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); + } + + [Fact] + public async Task Explicit_static_interface_marshaller_members_report_CESDK2003_and_skip_generation() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public readonly struct Token { } + + public readonly struct ExplicitMarshaller : ILuaMarshaller + { + static void ILuaMarshaller.Push(LuaState state, Token value) { } + + static bool ILuaMarshaller.TryRead(LuaState state, int index, out Token value) + { + value = default; + return false; + } + } + + public static partial class Bindings + { + [LuaFunction("token")] + public static int RoundTrip([LuaMarshaller(typeof(ExplicitMarshaller))] Token value) => 0; + } + """; + CSharpCompilation compilation = CreateCompilation(source, true); + + Assert.False(RunGenerator(compilation)); + + Diagnostic diagnostic = Assert.Single(await GetDiagnosticsAsync(compilation), + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); + Assert.Contains("parameter types a marshaller reads", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + + [Fact] + public async Task Duplicate_lua_function_names_in_the_same_type_report_CESDK2005_on_both_members() + { + // LuaFunctionTables.Group/SelectThunks drops both members from the generator's output with no explanation + // of its own: this is the compilation-end pass that names the cause. + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class Functions + { + [LuaFunction("shared")] + public static int First() => 1; + + [LuaFunction("shared")] + public static int Second() => 2; + } + """, + true); + + Diagnostic[] duplicates = + [ + .. diagnostics.Where(static d => + string.Equals(d.Id, DiagnosticIds.DuplicateLuaName, StringComparison.Ordinal)) + ]; + Assert.Equal(2, duplicates.Length); + Assert.Contains(duplicates, + d => d.GetMessage(CultureInfo.InvariantCulture).Contains("First", StringComparison.Ordinal)); + Assert.Contains(duplicates, + d => d.GetMessage(CultureInfo.InvariantCulture).Contains("Second", StringComparison.Ordinal)); + Assert.All(duplicates, + d => Assert.Contains("duplicates the Lua name", d.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal)); + } + + [Fact] + public async Task Duplicate_lua_function_names_in_different_types_report_nothing() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class First + { + [LuaFunction("shared")] + public static int Value() => 1; + } + + public static partial class Second + { + [LuaFunction("shared")] + public static int Value() => 2; + } + """, + true); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task Duplicate_lua_function_name_where_one_member_has_another_problem_reports_only_that_problem() + { + // 'Second' is not static: it was never a candidate for the generator's grouping step either + // (LuaFunctionModel.IsValid), so the still-valid 'First' is not a duplicate of anything and is silently + // exported; only the independent NotStatic problem on 'Second' is reported. + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public partial class Functions + { + [LuaFunction("shared")] + public static int First() => 1; + + [LuaFunction("shared")] + public int Second() => 2; + } + """, + true); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); + Assert.Contains("Second", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + Assert.Contains("static", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task Several_problems_on_one_member_are_all_reported() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static class Functions + { + [LuaFunction("end")] + public static long Add(long a, long b = 0) => a + b; + } + """, + false); + + Assert.Contains(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.UnsafeBlocksRequired, StringComparison.Ordinal)); + Assert.Contains(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal)); + Assert.Contains(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && + d.GetMessage(CultureInfo.InvariantCulture).Contains("reserved word", StringComparison.Ordinal)); + Assert.Contains(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && + d.GetMessage(CultureInfo.InvariantCulture).Contains("default value", StringComparison.Ordinal)); + } + + [Fact] + public async Task Same_name_source_LuaState_without_the_sdk_runtime_is_rejected_by_generator_and_analyzer() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Lua.State + { + public readonly struct LuaState + { + } + } + + namespace Demo; + + public static partial class Functions + { + [LuaFunction("callback")] + public static int Callback(global::CheatEngine.SDK.Lua.State.LuaState state) => 0; + } + + public static partial class Globals + { + [LuaGlobal("read")] + public static partial int Read(global::CheatEngine.SDK.Lua.State.LuaState state); + } + """; + CSharpCompilation compilation = CreateCompilation(source, true, + SdkReferencesWithoutLuaRuntime); + + Assert.False(RunGenerator(compilation)); + + ImmutableArray diagnostics = await GetDiagnosticsAsync(compilation); + Assert.Contains(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && + d.GetMessage(CultureInfo.InvariantCulture) + .Contains("parameter type", StringComparison.Ordinal)); + Assert.Contains(diagnostics, + static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal) && + d.GetMessage(CultureInfo.InvariantCulture).Contains("argument type", StringComparison.Ordinal)); + } + + [Theory] + [MemberData(nameof(Shapes))] + public async Task Generator_and_analyzer_agree_on_every_shape(string shape, string source, bool expectedValid) + { + CSharpCompilation compilation = CreateCompilation(source, true); + + bool generatorEmits = RunGenerator(compilation); + ImmutableArray diagnostics = await GetDiagnosticsAsync(compilation); + bool analyzerReportsShapeProblem = diagnostics.Any(static d => + string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); + + Assert.True(generatorEmits == expectedValid, + $"'{shape}': the generator {(generatorEmits ? "emitted" : "stayed silent")}, expected {(expectedValid ? "output" : "silence")}."); + Assert.True( + analyzerReportsShapeProblem != expectedValid, + $"'{shape}': the analyzer {(analyzerReportsShapeProblem ? "reported" : "stayed silent")} a shape problem, expected it to {(expectedValid ? "stay silent" : "report")}."); + } + + private static CSharpCompilation CreateCompilation(string source, bool allowUnsafe) + { + return CreateCompilation(source, allowUnsafe, SdkReferences); + } + + private static CSharpCompilation CreateCompilation(string source, bool allowUnsafe, + ImmutableArray sdkReferences) + { + return CSharpCompilation.Create( + "LuaBindingAnalyzerTestAssembly", + [ + CSharpSyntaxTree.ParseText(TestText.Normalize(source), ParseOptions, "Test.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + LocalFrameworkReferences.References.AddRange(sdkReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, allowUnsafe: allowUnsafe)); + } + + private static Task> AnalyzeAsync(string source, bool allowUnsafe) + { + return GetDiagnosticsAsync(CreateCompilation(source, allowUnsafe)); + } + + private static Task> GetDiagnosticsAsync(CSharpCompilation compilation) + { + CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers([new LuaBindingAnalyzer()], options: null); + return withAnalyzers.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); + } + + // Same driver shape as CheatEngine.SDK.SourceGenerators.LuaBindings.Tests' GeneratorRun: "emits" means at least one + // generated source (a thunk file or a wrapper-body file). + private static bool RunGenerator(CSharpCompilation compilation) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create([new LuaBindingsGenerator().AsSourceGenerator()], + parseOptions: ParseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _, + TestContext.Current.CancellationToken); + return !driver.GetRunResult().Results.Single().GeneratedSources.IsEmpty; + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs index 7ebaa822..db5218fb 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs @@ -1,7 +1,9 @@ using System.Collections.Immutable; using System.Globalization; + using CheatEngine.SDK.Analyzers.Generation; using CheatEngine.SDK.Analyzers.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -11,413 +13,419 @@ namespace CheatEngine.SDK.Analyzers.Tests.Generation; /// CESDK2006 and CESDK2007: unsupported Lua object declarations and source collisions with generated members. public sealed class LuaObjectBindingAnalyzerTests { - private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); - - [Fact] - public async Task Invalid_lua_class_name_reserved_method_parameter_and_property_shape_report_CESDK2006() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("end")] - public readonly partial struct {|CESDK2006:InvalidName|} - { - } - - [LuaClass("Object")] - public readonly partial struct ValidHandle - { - [LuaMethod("call")] - partial void {|CESDK2006:Call|}(int __ceState); - - [LuaMethod("operate")] - partial void {|CESDK2006:Operate|}(int __ceOperation); - - [LuaProperty("value")] - public int {|CESDK2006:Value|} => 0; - } - """); - } - - [Fact] - public async Task Generated_handle_and_lua_thunk_identity_collisions_report_CESDK2007() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Object")] - public readonly partial struct {|CESDK2007:Handle|} - { - } - - [LuaClass("Object")] - public readonly partial struct HandleWithCollision - { - private readonly int {|CESDK2007:_handle|}; - private readonly int {|CESDK2007:Handle|}; - } - - public static partial class Functions - { - [LuaFunction("load")] - public static void Load() { } - - private static int {|CESDK2007:__LuaThunk_load|}() => 0; - private static void {|CESDK2007:TryRegisterLuaFunctions|}() { } - private static void {|CESDK2007:RegisterLuaFunctions|}() { } - - [LuaGlobal("read")] - static partial void Read(); - - private static int {|CESDK2007:s_luaGlobal_read|}; - } - """); - } - - [Theory] - [InlineData("_handle")] - [InlineData("Handle")] - [InlineData("FromHandle")] - [InlineData("Equals")] - [InlineData("GetHashCode")] - [InlineData("Push")] - [InlineData("TryRead")] - public async Task Every_generated_handle_member_name_reports_CESDK2007(string memberName) - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Object")] - public readonly partial struct Collision - { - private int {|CESDK2007:MEMBER|} => 0; - } - - [LuaClass("Object")] - public readonly partial struct Valid - { - } - """.Replace("MEMBER", memberName, StringComparison.Ordinal)); - } - - [Theory] - [InlineData("_handle")] - [InlineData("Handle")] - [InlineData("FromHandle")] - [InlineData("Equals")] - [InlineData("GetHashCode")] - [InlineData("Push")] - [InlineData("TryRead")] - public async Task Every_generated_handle_type_name_reports_CESDK2007(string typeName) - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Object")] - public readonly partial struct {|CESDK2007:TYPE|} - { - } - """.Replace("TYPE", typeName, StringComparison.Ordinal)); - } - - [Fact] - public async Task Lua_global_generated_local_parameters_report_CESDK2007() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class Globals - { - [LuaGlobal("readL")] - static partial void ReadL(int {|CESDK2007:__L|}); - - [LuaGlobal("readOperation")] - static partial void ReadOperation(int {|CESDK2007:__operation|}); - - [LuaGlobal("readTop")] - static partial void ReadTop(int {|CESDK2007:__top|}); - - [LuaGlobal("readOk")] - static partial void ReadOk(int {|CESDK2007:__ok|}); - - [LuaGlobal("readStatus")] - static partial void ReadStatus(int {|CESDK2007:__status|}); - - [LuaGlobal("readResult")] - static partial void ReadResult(int {|CESDK2007:__result|}); - } - """); - } - - [Fact] - public async Task Generated_handle_constructor_collision_reports_CESDK2007() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace CheatEngine.SDK.Engine.Objects - { - public readonly struct CEObject - { - } - } - - namespace Demo - { - [LuaClass("Object")] - public readonly partial struct ConstructorCollision - { - private {|CESDK2007:ConstructorCollision|}( - global::CheatEngine.SDK.Engine.Objects.CEObject handle) - { - } - } - } - """); - } - - [Fact] - public async Task Generated_handle_accessor_collision_reports_CESDK2007() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace CheatEngine.SDK.Engine.Objects - { - public readonly struct CEObject - { - } - } - - namespace Demo - { - [LuaClass("Object")] - public readonly partial struct ObjectHandle - { - private global::CheatEngine.SDK.Engine.Objects.CEObject {|CESDK2007:get_Handle|}() => default; - } - - [LuaClass("Sibling")] - public readonly partial struct Sibling - { - } - } - """); - } - - [Fact] - public async Task Generic_handle_accessor_collision_reports_CESDK2007() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace CheatEngine.SDK.Engine.Objects - { - public readonly struct CEObject - { - } - } - - namespace Demo - { - [LuaClass("Object")] - public readonly partial struct ObjectHandle - { - private global::CheatEngine.SDK.Engine.Objects.CEObject {|CESDK2007:get_Handle|}() => default; - } - } - """); - } - - [Fact] - public async Task Generated_handle_setter_collision_requires_the_exact_CEObject_signature() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace CheatEngine.SDK.Engine.Objects - { - public readonly struct CEObject - { - } - } - - namespace Demo - { - [LuaClass("Object")] - public readonly partial struct ObjectHandle - { - private void {|CESDK2007:set_Handle|}(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } - private void {|CESDK2007:set_Handle|}(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } - private void set_Handle(int value) { } - } - } - """); - } - - [Fact] - public async Task Generated_handle_accessor_named_non_methods_report_CESDK2007() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo - { - [LuaClass("Field")] - public readonly partial struct Field - { - private readonly int {|CESDK2007:get_Handle|}; - } - - [LuaClass("Property")] - public readonly partial struct Property - { - private int {|CESDK2007:set_Handle|} => 0; - } - - [LuaClass("Nested")] - public readonly partial struct Nested - { - private struct {|CESDK2007:get_Handle|} { } - } - } - """); - } - - [Fact] - public async Task Record_and_ref_like_borrowed_handles_report_CESDK2006() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Record")] - public readonly partial record struct {|CESDK2006:RecordHandle|} - { - } - - [LuaClass("Ref")] - public readonly ref partial struct {|CESDK2006:RefLikeHandle|} - { - } - """); - } - - [Fact] - public async Task Ref_return_methods_and_properties_report_CESDK2006() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Object")] - public readonly partial struct ObjectHandle - { - [LuaMethod("refMethod")] - private partial ref int RefMethod(); - - [LuaMethod("readonlyRefMethod")] - private partial ref readonly int ReadonlyRefMethod(); - - [LuaProperty("refProperty")] - public partial ref int RefProperty { get; } - - [LuaProperty("readonlyRefProperty")] - public partial ref readonly int ReadonlyRefProperty { get; } - } - """); - - var count = 0; - foreach (var diagnostic in diagnostics) - { - if (!string.Equals(diagnostic.Id, "CESDK2006", StringComparison.Ordinal)) continue; - - count++; - Assert.Contains("ref and ref readonly", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } - - Assert.Equal(4, count); - } - - [Fact] - public async Task Explicit_interface_lua_property_reports_CESDK2006() - { - var diagnostics = await AnalyzeAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public interface IValue - { - int Value { get; } - } - - [LuaClass("Object")] - public readonly partial struct ObjectHandle : IValue - { - [LuaProperty("value")] - partial int IValue.Value { get; } - } - """); - - var diagnostic = Assert.Single(diagnostics, - static candidate => string.Equals(candidate.Id, "CESDK2006", StringComparison.Ordinal)); - Assert.Contains("partial property", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } - - [Fact] - public async Task A_valid_borrowed_handle_reports_nothing() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Object")] - public readonly partial struct ObjectHandle - { - } - """); - } - - private static Task> AnalyzeAsync(string source) - { - var compilation = CSharpCompilation.Create( - "LuaObjectBindingAnalyzerTestAssembly", - [CSharpSyntaxTree.ParseText(TestText.Normalize(source), ParseOptions, "Test.cs", - cancellationToken: TestContext.Current.CancellationToken)], - LocalFrameworkReferences.References.AddRange(ContractStubs.References), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable)); - var withAnalyzers = compilation.WithAnalyzers([new LuaObjectBindingAnalyzer()], options: null); - return withAnalyzers.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); - } + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + + [Fact] + public async Task Invalid_lua_class_name_reserved_method_parameter_and_property_shape_report_CESDK2006() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("end")] + public readonly partial struct {|CESDK2006:InvalidName|} + { + } + + [LuaClass("Object")] + public readonly partial struct ValidHandle + { + [LuaMethod("call")] + partial void {|CESDK2006:Call|}(int __ceState); + + [LuaMethod("operate")] + partial void {|CESDK2006:Operate|}(int __ceOperation); + + [LuaProperty("value")] + public int {|CESDK2006:Value|} => 0; + } + """); + } + + [Fact] + public async Task Generated_handle_and_lua_thunk_identity_collisions_report_CESDK2007() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct {|CESDK2007:Handle|} + { + } + + [LuaClass("Object")] + public readonly partial struct HandleWithCollision + { + private readonly int {|CESDK2007:_handle|}; + private readonly int {|CESDK2007:Handle|}; + } + + public static partial class Functions + { + [LuaFunction("load")] + public static void Load() { } + + private static int {|CESDK2007:__LuaThunk_load|}() => 0; + private static void {|CESDK2007:TryRegisterLuaFunctions|}() { } + private static void {|CESDK2007:RegisterLuaFunctions|}() { } + + [LuaGlobal("read")] + static partial void Read(); + + private static int {|CESDK2007:s_luaGlobal_read|}; + } + """); + } + + [Theory] + [InlineData("_handle")] + [InlineData("Handle")] + [InlineData("FromHandle")] + [InlineData("Equals")] + [InlineData("GetHashCode")] + [InlineData("Push")] + [InlineData("TryRead")] + public async Task Every_generated_handle_member_name_reports_CESDK2007(string memberName) + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct Collision + { + private int {|CESDK2007:MEMBER|} => 0; + } + + [LuaClass("Object")] + public readonly partial struct Valid + { + } + """.Replace("MEMBER", memberName, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("_handle")] + [InlineData("Handle")] + [InlineData("FromHandle")] + [InlineData("Equals")] + [InlineData("GetHashCode")] + [InlineData("Push")] + [InlineData("TryRead")] + public async Task Every_generated_handle_type_name_reports_CESDK2007(string typeName) + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct {|CESDK2007:TYPE|} + { + } + """.Replace("TYPE", typeName, StringComparison.Ordinal)); + } + + [Fact] + public async Task Lua_global_generated_local_parameters_report_CESDK2007() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class Globals + { + [LuaGlobal("readL")] + static partial void ReadL(int {|CESDK2007:__L|}); + + [LuaGlobal("readOperation")] + static partial void ReadOperation(int {|CESDK2007:__operation|}); + + [LuaGlobal("readTop")] + static partial void ReadTop(int {|CESDK2007:__top|}); + + [LuaGlobal("readOk")] + static partial void ReadOk(int {|CESDK2007:__ok|}); + + [LuaGlobal("readStatus")] + static partial void ReadStatus(int {|CESDK2007:__status|}); + + [LuaGlobal("readResult")] + static partial void ReadResult(int {|CESDK2007:__result|}); + } + """); + } + + [Fact] + public async Task Generated_handle_constructor_collision_reports_CESDK2007() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Engine.Objects + { + public readonly struct CEObject + { + } + } + + namespace Demo + { + [LuaClass("Object")] + public readonly partial struct ConstructorCollision + { + private {|CESDK2007:ConstructorCollision|}( + global::CheatEngine.SDK.Engine.Objects.CEObject handle) + { + } + } + } + """); + } + + [Fact] + public async Task Generated_handle_accessor_collision_reports_CESDK2007() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Engine.Objects + { + public readonly struct CEObject + { + } + } + + namespace Demo + { + [LuaClass("Object")] + public readonly partial struct ObjectHandle + { + private global::CheatEngine.SDK.Engine.Objects.CEObject {|CESDK2007:get_Handle|}() => default; + } + + [LuaClass("Sibling")] + public readonly partial struct Sibling + { + } + } + """); + } + + [Fact] + public async Task Generic_handle_accessor_collision_reports_CESDK2007() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Engine.Objects + { + public readonly struct CEObject + { + } + } + + namespace Demo + { + [LuaClass("Object")] + public readonly partial struct ObjectHandle + { + private global::CheatEngine.SDK.Engine.Objects.CEObject {|CESDK2007:get_Handle|}() => default; + } + } + """); + } + + [Fact] + public async Task Generated_handle_setter_collision_requires_the_exact_CEObject_signature() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Engine.Objects + { + public readonly struct CEObject + { + } + } + + namespace Demo + { + [LuaClass("Object")] + public readonly partial struct ObjectHandle + { + private void {|CESDK2007:set_Handle|}(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } + private void {|CESDK2007:set_Handle|}(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } + private void set_Handle(int value) { } + } + } + """); + } + + [Fact] + public async Task Generated_handle_accessor_named_non_methods_report_CESDK2007() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo + { + [LuaClass("Field")] + public readonly partial struct Field + { + private readonly int {|CESDK2007:get_Handle|}; + } + + [LuaClass("Property")] + public readonly partial struct Property + { + private int {|CESDK2007:set_Handle|} => 0; + } + + [LuaClass("Nested")] + public readonly partial struct Nested + { + private struct {|CESDK2007:get_Handle|} { } + } + } + """); + } + + [Fact] + public async Task Record_and_ref_like_borrowed_handles_report_CESDK2006() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Record")] + public readonly partial record struct {|CESDK2006:RecordHandle|} + { + } + + [LuaClass("Ref")] + public readonly ref partial struct {|CESDK2006:RefLikeHandle|} + { + } + """); + } + + [Fact] + public async Task Ref_return_methods_and_properties_report_CESDK2006() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct ObjectHandle + { + [LuaMethod("refMethod")] + private partial ref int RefMethod(); + + [LuaMethod("readonlyRefMethod")] + private partial ref readonly int ReadonlyRefMethod(); + + [LuaProperty("refProperty")] + public partial ref int RefProperty { get; } + + [LuaProperty("readonlyRefProperty")] + public partial ref readonly int ReadonlyRefProperty { get; } + } + """); + + int count = 0; + foreach (Diagnostic diagnostic in diagnostics) + { + if (!string.Equals(diagnostic.Id, "CESDK2006", StringComparison.Ordinal)) + { + continue; + } + + count++; + Assert.Contains("ref and ref readonly", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + + Assert.Equal(4, count); + } + + [Fact] + public async Task Explicit_interface_lua_property_reports_CESDK2006() + { + ImmutableArray diagnostics = await AnalyzeAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public interface IValue + { + int Value { get; } + } + + [LuaClass("Object")] + public readonly partial struct ObjectHandle : IValue + { + [LuaProperty("value")] + partial int IValue.Value { get; } + } + """); + + Diagnostic diagnostic = Assert.Single(diagnostics, + static candidate => string.Equals(candidate.Id, "CESDK2006", StringComparison.Ordinal)); + Assert.Contains("partial property", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + + [Fact] + public async Task A_valid_borrowed_handle_reports_nothing() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct ObjectHandle + { + } + """); + } + + private static Task> AnalyzeAsync(string source) + { + CSharpCompilation compilation = CSharpCompilation.Create( + "LuaObjectBindingAnalyzerTestAssembly", + [ + CSharpSyntaxTree.ParseText(TestText.Normalize(source), ParseOptions, "Test.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + LocalFrameworkReferences.References.AddRange(ContractStubs.References), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + CompilationWithAnalyzers withAnalyzers = + compilation.WithAnalyzers([new LuaObjectBindingAnalyzer()], options: null); + return withAnalyzers.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/AnalyzerVerifier.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/AnalyzerVerifier.cs index dc34d413..6835e1d4 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/AnalyzerVerifier.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/AnalyzerVerifier.cs @@ -10,54 +10,57 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// location when the message arguments matter. Anything unexpected, compiler errors included, fails the test. /// internal static class AnalyzerVerifier - where TAnalyzer : DiagnosticAnalyzer, new() + where TAnalyzer : DiagnosticAnalyzer, new() { - /// An expected diagnostic of ; add location and arguments fluently. - public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) - { - return new DiagnosticResult(descriptor); - } - - /// Verifies a single-file plugin project that references the contract stubs. - public static Task VerifyAsync(string source, params DiagnosticResult[] expected) - { - return VerifyAsync([("Test0.cs", source)], expected); - } - - /// Verifies a multi-file plugin project that references the contract stubs. - public static Task VerifyAsync((string FileName, string Source)[] sources, params DiagnosticResult[] expected) - { - return RunAsync(new CheatEngineSdkAnalyzerTest(), sources, expected); - } - - /// - /// Verifies a plugin project that exposes one MSBuild property to the compiler, the way - /// CompilerVisibleProperty does: as build_property.<name> in a global analyzer config. - /// - public static Task VerifyWithBuildPropertyAsync(string name, string value, string source, - params DiagnosticResult[] expected) - { - CheatEngineSdkAnalyzerTest test = new(applyDirectPackageDefaults: false); - test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", - TestText.Normalize($"is_global = true\nbuild_property.{name} = {value}\n"))); - return RunAsync(test, [("Test0.cs", source)], expected); - } - - /// Verifies a project that does not reference CheatEngine.SDK at all: the analyzers must stay out of the way. - public static Task VerifyWithoutCheatEngineSdkAsync(string source, params DiagnosticResult[] expected) - { - return RunAsync( - new CheatEngineSdkAnalyzerTest(referenceCheatEngineSdk: false), - [("Test0.cs", source)], - expected); - } - - private static Task RunAsync(CheatEngineSdkAnalyzerTest test, (string FileName, string Source)[] sources, - DiagnosticResult[] expected) - { - foreach (var (fileName, source) in sources) test.TestState.Sources.Add((fileName, TestText.Normalize(source))); - - test.ExpectedDiagnostics.AddRange(expected); - return test.RunAsync(TestContext.Current.CancellationToken); - } + /// An expected diagnostic of ; add location and arguments fluently. + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + { + return new DiagnosticResult(descriptor); + } + + /// Verifies a single-file plugin project that references the contract stubs. + public static Task VerifyAsync(string source, params DiagnosticResult[] expected) + { + return VerifyAsync([("Test0.cs", source)], expected); + } + + /// Verifies a multi-file plugin project that references the contract stubs. + public static Task VerifyAsync((string FileName, string Source)[] sources, params DiagnosticResult[] expected) + { + return RunAsync(new CheatEngineSdkAnalyzerTest(), sources, expected); + } + + /// + /// Verifies a plugin project that exposes one MSBuild property to the compiler, the way + /// CompilerVisibleProperty does: as build_property.<name> in a global analyzer config. + /// + public static Task VerifyWithBuildPropertyAsync(string name, string value, string source, + params DiagnosticResult[] expected) + { + CheatEngineSdkAnalyzerTest test = new(applyDirectPackageDefaults: false); + test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", + TestText.Normalize($"is_global = true\nbuild_property.{name} = {value}\n"))); + return RunAsync(test, [("Test0.cs", source)], expected); + } + + /// Verifies a project that does not reference CheatEngine.SDK at all: the analyzers must stay out of the way. + public static Task VerifyWithoutCheatEngineSdkAsync(string source, params DiagnosticResult[] expected) + { + return RunAsync( + new CheatEngineSdkAnalyzerTest(false), + [("Test0.cs", source)], + expected); + } + + private static Task RunAsync(CheatEngineSdkAnalyzerTest test, (string FileName, string Source)[] sources, + DiagnosticResult[] expected) + { + foreach ((string fileName, string source) in sources) + { + test.TestState.Sources.Add((fileName, TestText.Normalize(source))); + } + + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(TestContext.Current.CancellationToken); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkAnalyzerTest.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkAnalyzerTest.cs index 071d99bb..e4e1b7c4 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkAnalyzerTest.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkAnalyzerTest.cs @@ -11,15 +11,21 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// as exceptions, which xUnit v3 reports like any other. /// internal sealed class CheatEngineSdkAnalyzerTest : CSharpAnalyzerTest - where TAnalyzer : DiagnosticAnalyzer, new() + where TAnalyzer : DiagnosticAnalyzer, new() { - public CheatEngineSdkAnalyzerTest(bool referenceCheatEngineSdk = true, bool applyDirectPackageDefaults = true) - { - ReferenceAssemblies = LocalFrameworkReferences.WithoutPackages; - TestState.AdditionalReferences.AddRange(LocalFrameworkReferences.References); - if (referenceCheatEngineSdk) ContractStubs.AddTo(TestState); - if (applyDirectPackageDefaults) - TestState.AnalyzerConfigFiles.Add(("/.globalconfig", - TestText.Normalize("is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n"))); - } + public CheatEngineSdkAnalyzerTest(bool referenceCheatEngineSdk = true, bool applyDirectPackageDefaults = true) + { + ReferenceAssemblies = LocalFrameworkReferences.WithoutPackages; + TestState.AdditionalReferences.AddRange(LocalFrameworkReferences.References); + if (referenceCheatEngineSdk) + { + ContractStubs.AddTo(TestState); + } + + if (applyDirectPackageDefaults) + { + TestState.AnalyzerConfigFiles.Add(("/.globalconfig", + TestText.Normalize("is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n"))); + } + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkCodeFixTest.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkCodeFixTest.cs index 5db828a7..05d04e4c 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkCodeFixTest.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CheatEngineSdkCodeFixTest.cs @@ -11,16 +11,16 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// the diagnostics, the fix applied one diagnostic at a time, and Fix All in document, project and solution. /// internal sealed class - CheatEngineSdkCodeFixTest : CSharpCodeFixTest - where TAnalyzer : DiagnosticAnalyzer, new() - where TCodeFix : CodeFixProvider, new() + CheatEngineSdkCodeFixTest : CSharpCodeFixTest + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() { - public CheatEngineSdkCodeFixTest() - { - ReferenceAssemblies = LocalFrameworkReferences.WithoutPackages; - TestState.AdditionalReferences.AddRange(LocalFrameworkReferences.References); - ContractStubs.AddTo(TestState); - TestState.AnalyzerConfigFiles.Add(("/.globalconfig", - TestText.Normalize("is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n"))); - } + public CheatEngineSdkCodeFixTest() + { + ReferenceAssemblies = LocalFrameworkReferences.WithoutPackages; + TestState.AdditionalReferences.AddRange(LocalFrameworkReferences.References); + ContractStubs.AddTo(TestState); + TestState.AnalyzerConfigFiles.Add(("/.globalconfig", + TestText.Normalize("is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n"))); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs index 4b520142..c0127865 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs @@ -11,45 +11,49 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// offered. /// internal static class CodeFixVerifier - where TAnalyzer : DiagnosticAnalyzer, new() - where TCodeFix : CodeFixProvider, new() + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() { - /// Verifies a fix inside a single file. - public static Task VerifyAsync(string source, string fixedSource, string? equivalenceKey = null, - int? fixAllIterations = null) - { - return VerifyAsync([("Test0.cs", source)], [("Test0.cs", fixedSource)], equivalenceKey, fixAllIterations); - } + /// Verifies a fix inside a single file. + public static Task VerifyAsync(string source, string fixedSource, string? equivalenceKey = null, + int? fixAllIterations = null) + { + return VerifyAsync([("Test0.cs", source)], [("Test0.cs", fixedSource)], equivalenceKey, fixAllIterations); + } - /// Verifies a fix over several files; the fixed state lists every file, changed or not. - /// The files before the fix, with the diagnostics marked up. - /// The files after the fix, with the remaining diagnostics marked up. - /// The action to apply when the provider offers several. - /// - /// Number of Fix All passes needed to reach the fixed state when the edits of one pass overlap; the library - /// expects a single pass otherwise. - /// - public static Task VerifyAsync( - (string FileName, string Source)[] sources, - (string FileName, string Source)[] fixedSources, - string? equivalenceKey = null, - int? fixAllIterations = null) - { - CheatEngineSdkCodeFixTest test = new() - { - CodeActionEquivalenceKey = equivalenceKey, - NumberOfFixAllIterations = fixAllIterations - }; + /// Verifies a fix over several files; the fixed state lists every file, changed or not. + /// The files before the fix, with the diagnostics marked up. + /// The files after the fix, with the remaining diagnostics marked up. + /// The action to apply when the provider offers several. + /// + /// Number of Fix All passes needed to reach the fixed state when the edits of one pass overlap; the library + /// expects a single pass otherwise. + /// + public static Task VerifyAsync( + (string FileName, string Source)[] sources, + (string FileName, string Source)[] fixedSources, + string? equivalenceKey = null, + int? fixAllIterations = null) + { + CheatEngineSdkCodeFixTest test = new() + { + CodeActionEquivalenceKey = equivalenceKey, NumberOfFixAllIterations = fixAllIterations + }; - // The default drops fixable ids from the markup of the fixed state, assuming a fix always removes them all. - // CESDK0001 stands for several problems, fixed one at a time: what remains must be stated and checked. - test.FixedState.MarkupHandling = MarkupMode.Allow; + // The default drops fixable ids from the markup of the fixed state, assuming a fix always removes them all. + // CESDK0001 stands for several problems, fixed one at a time: what remains must be stated and checked. + test.FixedState.MarkupHandling = MarkupMode.Allow; - foreach (var (fileName, source) in sources) test.TestState.Sources.Add((fileName, TestText.Normalize(source))); + foreach ((string fileName, string source) in sources) + { + test.TestState.Sources.Add((fileName, TestText.Normalize(source))); + } - foreach (var (fileName, source) in fixedSources) - test.FixedState.Sources.Add((fileName, TestText.Normalize(source))); + foreach ((string fileName, string source) in fixedSources) + { + test.FixedState.Sources.Add((fileName, TestText.Normalize(source))); + } - return test.RunAsync(TestContext.Current.CancellationToken); - } + return test.RunAsync(TestContext.Current.CancellationToken); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/ContractStubs.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/ContractStubs.cs index 6aaecd7e..3e6c7230 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/ContractStubs.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/ContractStubs.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; @@ -18,16 +20,19 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// internal static class ContractStubs { - /// The real assembly references that a consumer receives from direct SDK package references. - public static ImmutableArray References { get; } = - [ - MetadataReference.CreateFromFile(typeof(CheatEnginePluginAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(CheatEnginePlugin).Assembly.Location), - ]; + /// The real assembly references that a consumer receives from direct SDK package references. + public static ImmutableArray References + { + get; + } = + [ + MetadataReference.CreateFromFile(typeof(CheatEnginePluginAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CheatEnginePlugin).Assembly.Location) + ]; - /// Adds the real SDK contract metadata to . - public static void AddTo(SolutionState state) - { - state.AdditionalReferences.AddRange(References); - } + /// Adds the real SDK contract metadata to . + public static void AddTo(SolutionState state) + { + state.AdditionalReferences.AddRange(References); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/DirectPackageAnalyzerConfigOptions.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/DirectPackageAnalyzerConfigOptions.cs index cb094cdb..aef517df 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/DirectPackageAnalyzerConfigOptions.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/DirectPackageAnalyzerConfigOptions.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -10,38 +11,41 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// internal sealed class DirectPackageAnalyzerConfigOptions : AnalyzerConfigOptionsProvider { - /// The direct-package configuration: bootstrap generation is explicitly enabled. - public static readonly DirectPackageAnalyzerConfigOptions Enabled = new(); - - private static readonly AnalyzerConfigOptions Empty = new TestOptions(ImmutableDictionary.Empty); - - private DirectPackageAnalyzerConfigOptions() - { - GlobalOptions = new TestOptions( - ImmutableDictionary.Create(AnalyzerConfigOptions.KeyComparer) - .Add("build_property.CheatEngineSdkGenerateEntryPoint", "true")); - } - - /// - public override AnalyzerConfigOptions GlobalOptions { get; } - - /// - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) - { - return Empty; - } - - /// - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) - { - return Empty; - } - - private sealed class TestOptions(ImmutableDictionary values) : AnalyzerConfigOptions - { - public override bool TryGetValue(string key, out string value) - { - return values.TryGetValue(key, out value!); - } - } + /// The direct-package configuration: bootstrap generation is explicitly enabled. + public static readonly DirectPackageAnalyzerConfigOptions Enabled = new(); + + private static readonly AnalyzerConfigOptions Empty = new TestOptions(ImmutableDictionary.Empty); + + private DirectPackageAnalyzerConfigOptions() + { + GlobalOptions = new TestOptions( + ImmutableDictionary.Create(AnalyzerConfigOptions.KeyComparer) + .Add("build_property.CheatEngineSdkGenerateEntryPoint", "true")); + } + + /// + public override AnalyzerConfigOptions GlobalOptions + { + get; + } + + /// + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return Empty; + } + + /// + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return Empty; + } + + private sealed class TestOptions(ImmutableDictionary values) : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) + { + return values.TryGetValue(key, out value!); + } + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs index e6a1079e..d681851c 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; @@ -31,91 +32,109 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// internal static class LocalFrameworkReferences { - private const string TargetFramework = "net10.0"; - - private static readonly Lazy> LazyReferences = new(Load); - - /// A without any package: resolving it touches no NuGet source. - public static ReferenceAssemblies WithoutPackages { get; } = new(TargetFramework); - - /// - /// Targeting pack when there is one, the running runtime otherwise. Loaded once per test process: sharing the - /// instances lets Roslyn share the metadata they read. - /// - /// Neither source yields a single assembly. - public static ImmutableArray References => LazyReferences.Value; - - private static string RuntimeDirectory => - Path.GetDirectoryName(typeof(object).Assembly.Location) - ?? throw new InvalidOperationException( - "System.Private.CoreLib has no location: single-file test hosts are not supported."); - - /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. - public static ImmutableArray FromTargetingPack() - { - // /shared/Microsoft.NETCore.App// -> /packs/Microsoft.NETCore.App.Ref//ref/net10.0/ - var dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); - if (dotnetRoot is null) return []; - - var packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); - if (!Directory.Exists(packs)) return []; - - string? best = null; - Version? bestVersion = null; - foreach (var pack in Directory.EnumerateDirectories(packs)) - { - var candidate = Path.Combine(pack, "ref", TargetFramework); - if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out var version) - && (bestVersion is null || version > bestVersion)) - { - best = candidate; - bestVersion = version; - } - } - - return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); - } - - /// Implementation assemblies of the runtime this process runs on (managed ones only). - public static ImmutableArray FromRunningRuntime() - { - // The list also holds the test application's own dependencies (xUnit, Roslyn, the analyzers): only what - // sits in the shared framework directory is Microsoft.NETCore.App. Native DLLs are not on the list. - var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; - List paths = []; - foreach (var path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) - paths.Add(path); - - return CreateReferences(paths); - } - - private static ImmutableArray Load() - { - var references = FromTargetingPack(); - if (references.IsEmpty) references = FromRunningRuntime(); - - return references.IsEmpty - ? throw new InvalidOperationException( - $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") - : references; - } - - // "10.0.1", "10.0.0-rc.2.25502.107": the pre-release label does not matter for picking a pack. - private static bool TryParsePackVersion(string directoryName, out Version? version) - { - var label = directoryName.IndexOf('-', StringComparison.Ordinal); - return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); - } - - // Sorted: the order of references is part of a compilation, and directory enumeration order is not specified. - private static ImmutableArray CreateReferences(IEnumerable paths) - { - var references = ImmutableArray.CreateBuilder(); - foreach (var path in paths.Order(StringComparer.OrdinalIgnoreCase)) - references.Add(MetadataReference.CreateFromFile(path)); - - return references.ToImmutable(); - } + private const string TargetFramework = "net10.0"; + + private static readonly Lazy> LazyReferences = new(Load); + + /// A without any package: resolving it touches no NuGet source. + public static ReferenceAssemblies WithoutPackages + { + get; + } = new(TargetFramework); + + /// + /// Targeting pack when there is one, the running runtime otherwise. Loaded once per test process: sharing the + /// instances lets Roslyn share the metadata they read. + /// + /// Neither source yields a single assembly. + public static ImmutableArray References => LazyReferences.Value; + + private static string RuntimeDirectory => + Path.GetDirectoryName(typeof(object).Assembly.Location) + ?? throw new InvalidOperationException( + "System.Private.CoreLib has no location: single-file test hosts are not supported."); + + /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. + public static ImmutableArray FromTargetingPack() + { + // /shared/Microsoft.NETCore.App// -> /packs/Microsoft.NETCore.App.Ref//ref/net10.0/ + string? dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); + if (dotnetRoot is null) + { + return []; + } + + string packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); + if (!Directory.Exists(packs)) + { + return []; + } + + string? best = null; + Version? bestVersion = null; + foreach (string pack in Directory.EnumerateDirectories(packs)) + { + string candidate = Path.Combine(pack, "ref", TargetFramework); + if (Directory.Exists(candidate) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) + { + best = candidate; + bestVersion = version; + } + } + + return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); + } + + /// Implementation assemblies of the runtime this process runs on (managed ones only). + public static ImmutableArray FromRunningRuntime() + { + // The list also holds the test application's own dependencies (xUnit, Roslyn, the analyzers): only what + // sits in the shared framework directory is Microsoft.NETCore.App. Native DLLs are not on the list. + string trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; + List paths = []; + foreach (string path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(path); + } + } + + return CreateReferences(paths); + } + + private static ImmutableArray Load() + { + ImmutableArray references = FromTargetingPack(); + if (references.IsEmpty) + { + references = FromRunningRuntime(); + } + + return references.IsEmpty + ? throw new InvalidOperationException( + $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") + : references; + } + + // "10.0.1", "10.0.0-rc.2.25502.107": the pre-release label does not matter for picking a pack. + private static bool TryParsePackVersion(string directoryName, out Version? version) + { + int label = directoryName.IndexOf('-', StringComparison.Ordinal); + return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); + } + + // Sorted: the order of references is part of a compilation, and directory enumeration order is not specified. + private static ImmutableArray CreateReferences(IEnumerable paths) + { + ImmutableArray.Builder references = ImmutableArray.CreateBuilder(); + foreach (string path in paths.Order(StringComparer.OrdinalIgnoreCase)) + { + references.Add(MetadataReference.CreateFromFile(path)); + } + + return references.ToImmutable(); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs index 6797826c..f3392bd5 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs @@ -1,5 +1,6 @@ using CheatEngine.SDK.Analyzers.CodeFixes.Usage; using CheatEngine.SDK.Analyzers.Usage; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; @@ -11,62 +12,62 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// public sealed class LocalFrameworkReferencesTests { - [Fact] - public void Analyzer_test_names_no_package_to_restore() - { - CheatEngineSdkAnalyzerTest test = new(); + [Fact] + public void Analyzer_test_names_no_package_to_restore() + { + CheatEngineSdkAnalyzerTest test = new(); - AssertNothingToRestore(test.ReferenceAssemblies); - Assert.NotEmpty(test.TestState.AdditionalReferences); - Assert.Contains(test.TestState.AdditionalReferences, - static reference => reference is PortableExecutableReference); - } + AssertNothingToRestore(test.ReferenceAssemblies); + Assert.NotEmpty(test.TestState.AdditionalReferences); + Assert.Contains(test.TestState.AdditionalReferences, + static reference => reference is PortableExecutableReference); + } - [Fact] - public void Code_fix_test_names_no_package_to_restore() - { - CheatEngineSdkCodeFixTest test = - new(); + [Fact] + public void Code_fix_test_names_no_package_to_restore() + { + CheatEngineSdkCodeFixTest test = + new(); - AssertNothingToRestore(test.ReferenceAssemblies); - Assert.NotEmpty(test.TestState.AdditionalReferences); - Assert.Contains(test.TestState.AdditionalReferences, - static reference => reference is PortableExecutableReference); - } + AssertNothingToRestore(test.ReferenceAssemblies); + Assert.NotEmpty(test.TestState.AdditionalReferences); + Assert.Contains(test.TestState.AdditionalReferences, + static reference => reference is PortableExecutableReference); + } - [Fact] - public void References_are_existing_local_files_and_include_the_core_facade() - { - var hasSystemRuntime = false; - foreach (var reference in LocalFrameworkReferences.References) - { - var path = Assert.IsType(reference, false).FilePath - ?? throw new InvalidOperationException("A framework reference without a file path."); - Assert.True(File.Exists(path), path); - hasSystemRuntime |= string.Equals(Path.GetFileName(path), "System.Runtime.dll", - StringComparison.OrdinalIgnoreCase); - } + [Fact] + public void References_are_existing_local_files_and_include_the_core_facade() + { + bool hasSystemRuntime = false; + foreach (MetadataReference reference in LocalFrameworkReferences.References) + { + string path = Assert.IsType(reference, false).FilePath + ?? throw new InvalidOperationException("A framework reference without a file path."); + Assert.True(File.Exists(path), path); + hasSystemRuntime |= string.Equals(Path.GetFileName(path), "System.Runtime.dll", + StringComparison.OrdinalIgnoreCase); + } - Assert.True(hasSystemRuntime, "System.Runtime.dll is not among the framework references."); - } + Assert.True(hasSystemRuntime, "System.Runtime.dll is not among the framework references."); + } - [Fact] - public void Running_runtime_fallback_yields_only_shared_framework_assemblies() - { - var runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + [Fact] + public void Running_runtime_fallback_yields_only_shared_framework_assemblies() + { + string runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location)!; - Assert.NotEmpty(LocalFrameworkReferences.FromRunningRuntime()); - foreach (var reference in LocalFrameworkReferences.FromRunningRuntime()) - { - var path = Assert.IsType(reference, false).FilePath; - Assert.Equal(runtimeDirectory, Path.GetDirectoryName(path), true); - } - } + Assert.NotEmpty(LocalFrameworkReferences.FromRunningRuntime()); + foreach (MetadataReference reference in LocalFrameworkReferences.FromRunningRuntime()) + { + string? path = Assert.IsType(reference, false).FilePath; + Assert.Equal(runtimeDirectory, Path.GetDirectoryName(path), true); + } + } - private static void AssertNothingToRestore(ReferenceAssemblies referenceAssemblies) - { - Assert.Null(referenceAssemblies.ReferenceAssemblyPackage); - Assert.Empty(referenceAssemblies.Packages); - Assert.Empty(referenceAssemblies.Assemblies); - } + private static void AssertNothingToRestore(ReferenceAssemblies referenceAssemblies) + { + Assert.Null(referenceAssemblies.ReferenceAssemblyPackage); + Assert.Empty(referenceAssemblies.Packages); + Assert.Empty(referenceAssemblies.Assemblies); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs index ad9df1ae..7b8fe539 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs @@ -3,26 +3,33 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// Locates files of the repository from the test output directory, for the documentation checks. internal static class RepositoryLayout { - private const string SolutionFileName = "CheatEngine.SDK.slnx"; + private const string SolutionFileName = "CheatEngine.SDK.slnx"; - /// The directory that contains CheatEngine.SDK.slnx, found by walking up from the test binaries. - public static string Root { get; } = FindRoot(); + /// The directory that contains CheatEngine.SDK.slnx, found by walking up from the test binaries. + public static string Root + { + get; + } = FindRoot(); - /// Absolute path of a repository-relative path written with forward slashes. - public static string PathOf(string relativePath) - { - return Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); - } + /// Absolute path of a repository-relative path written with forward slashes. + public static string PathOf(string relativePath) + { + return Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } - private static string FindRoot() - { - for (DirectoryInfo? directory = new(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) - if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) - return directory.FullName; + private static string FindRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) + { + return directory.FullName; + } + } - throw new InvalidOperationException( - $"'{SolutionFileName}' was not found above '{AppContext.BaseDirectory}': the tests expect to run from the repository's artifacts directory."); - } + throw new InvalidOperationException( + $"'{SolutionFileName}' was not found above '{AppContext.BaseDirectory}': the tests expect to run from the repository's artifacts directory."); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/TestText.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/TestText.cs index 775b9e8e..9ba60393 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/TestText.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/TestText.cs @@ -3,28 +3,32 @@ namespace CheatEngine.SDK.Analyzers.Tests.Infrastructure; /// Text helpers shared by every test. internal static class TestText { - /// - /// Rewrites every line break to . The raw string literals of the tests carry - /// whatever line ending the test file was checked out with, while the Roslyn formatter writes the platform - /// line ending for the lines a code fix adds: without this the fixed text would depend on git settings. - /// - public static string Normalize(string source) - { - return source.ReplaceLineEndings(); - } + /// + /// Rewrites every line break to . The raw string literals of the tests carry + /// whatever line ending the test file was checked out with, while the Roslyn formatter writes the platform + /// line ending for the lines a code fix adds: without this the fixed text would depend on git settings. + /// + public static string Normalize(string source) + { + return source.ReplaceLineEndings(); + } - /// - /// Indents every non-empty line by four spaces per level, so that a multi-line member can be spliced into a - /// class template and still compare equal, character for character, with formatted code-fix output. - /// - public static string Indent(string text, int levels = 1) - { - string indentation = new(' ', 4 * levels); - var lines = text.ReplaceLineEndings("\n").Split('\n'); - for (var index = 0; index < lines.Length; index++) - if (lines[index].Length > 0) - lines[index] = indentation + lines[index]; + /// + /// Indents every non-empty line by four spaces per level, so that a multi-line member can be spliced into a + /// class template and still compare equal, character for character, with formatted code-fix output. + /// + public static string Indent(string text, int levels = 1) + { + string indentation = new(' ', 4 * levels); + string[] lines = text.ReplaceLineEndings("\n").Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + if (lines[index].Length > 0) + { + lines[index] = indentation + lines[index]; + } + } - return string.Join('\n', lines); - } + return string.Join('\n', lines); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/EntryPointSwitchTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/EntryPointSwitchTests.cs index e2efe7a4..be05286d 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/EntryPointSwitchTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/EntryPointSwitchTests.cs @@ -1,8 +1,9 @@ using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.Plugin; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.AnalyzerVerifier< - CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; + CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; namespace CheatEngine.SDK.Analyzers.Tests.Plugin; @@ -13,101 +14,101 @@ namespace CheatEngine.SDK.Analyzers.Tests.Plugin; /// public sealed class EntryPointSwitchTests { - private const string SwitchName = "CheatEngineSdkGenerateEntryPoint"; + private const string SwitchName = "CheatEngineSdkGenerateEntryPoint"; - // Two plugin classes, one of them abstract, in a namespace under CESDK: all three rules have something to say. - private const string Source = """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; + // Two plugin classes, one of them abstract, in a namespace under CESDK: all three rules have something to say. + private const string Source = """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; - namespace {|#0:CESDK.MyPlugin|} - { + namespace {|#0:CESDK.MyPlugin|} + { - [CheatEnginePlugin("First")] - public abstract class {|#1:FirstPlugin|} : CheatEnginePlugin - { - } + [CheatEnginePlugin("First")] + public abstract class {|#1:FirstPlugin|} : CheatEnginePlugin + { + } - [CheatEnginePlugin("Second")] - public sealed class {|#2:SecondPlugin|} : FirstPlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """; + [CheatEnginePlugin("Second")] + public sealed class {|#2:SecondPlugin|} : FirstPlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """; - private const string SourceWithManualBootstrap = Source + """ + private const string SourceWithManualBootstrap = Source + """ - namespace CESDK - { - public static class CESDK - { - public static int CEPluginInitialize(System.IntPtr _, int __) => 1; - } - } - """; + namespace CESDK + { + public static class CESDK + { + public static int CEPluginInitialize(System.IntPtr _, int __) => 1; + } + } + """; - [Theory] - [InlineData("false")] - [InlineData("False")] - [InlineData(" FALSE ")] - public async Task Switched_off_entry_point_accepts_a_valid_manual_bootstrap(string value) - { - await Verifier.VerifyWithBuildPropertyAsync( - SwitchName, - value, - SourceWithManualBootstrap); - } + [Theory] + [InlineData("false")] + [InlineData("False")] + [InlineData(" FALSE ")] + public async Task Switched_off_entry_point_accepts_a_valid_manual_bootstrap(string value) + { + await Verifier.VerifyWithBuildPropertyAsync( + SwitchName, + value, + SourceWithManualBootstrap); + } - [Fact] - public async Task Switched_off_entry_point_rejects_generic_or_by_ref_bootstrap_lookalikes() - { - const string source = Source + """ + [Fact] + public async Task Switched_off_entry_point_rejects_generic_or_by_ref_bootstrap_lookalikes() + { + const string source = Source + """ - namespace CESDK - { - public static class {|#3:CESDK|} - { - public static int CEPluginInitialize(System.IntPtr _, int __) => 1; - public static int CEPluginInitialize(ref System.IntPtr _, int __) => 1; - } - } - """; + namespace CESDK + { + public static class {|#3:CESDK|} + { + public static int CEPluginInitialize(System.IntPtr _, int __) => 1; + public static int CEPluginInitialize(ref System.IntPtr _, int __) => 1; + } + } + """; - await Verifier.VerifyWithBuildPropertyAsync( - SwitchName, - "false", - source, - Verifier.Diagnostic(DiagnosticDescriptors.InvalidManualBootstrap).WithLocation(3) - .WithArguments("CESDK.CESDK has no public static int CEPluginInitialize(System.IntPtr, int) method")); - } + await Verifier.VerifyWithBuildPropertyAsync( + SwitchName, + "false", + source, + Verifier.Diagnostic(DiagnosticDescriptors.InvalidManualBootstrap).WithLocation(3) + .WithArguments("CESDK.CESDK has no public static int CEPluginInitialize(System.IntPtr, int) method")); + } - [Theory] - [InlineData("true")] - [InlineData("True")] - public async Task Explicit_true_keeps_every_generated_entry_point_rule_on(string value) - { - await Verifier.VerifyWithBuildPropertyAsync( - SwitchName, - value, - Source, - Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(0) - .WithArguments("CESDK.MyPlugin"), - Verifier.Diagnostic(DiagnosticDescriptors.InvalidPluginClass) - .WithLocation(1) - .WithArguments("FirstPlugin", PluginClassProblemText.Describe(PluginShapeIssues.Abstract)), - Verifier.Diagnostic(DiagnosticDescriptors.MultiplePluginClasses).WithLocation(1) - .WithArguments("FirstPlugin", 2), - Verifier.Diagnostic(DiagnosticDescriptors.MultiplePluginClasses).WithLocation(2) - .WithArguments("SecondPlugin", 2)); - } + [Theory] + [InlineData("true")] + [InlineData("True")] + public async Task Explicit_true_keeps_every_generated_entry_point_rule_on(string value) + { + await Verifier.VerifyWithBuildPropertyAsync( + SwitchName, + value, + Source, + Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(0) + .WithArguments("CESDK.MyPlugin"), + Verifier.Diagnostic(DiagnosticDescriptors.InvalidPluginClass) + .WithLocation(1) + .WithArguments("FirstPlugin", PluginClassProblemText.Describe(PluginShapeIssues.Abstract)), + Verifier.Diagnostic(DiagnosticDescriptors.MultiplePluginClasses).WithLocation(1) + .WithArguments("FirstPlugin", 2), + Verifier.Diagnostic(DiagnosticDescriptors.MultiplePluginClasses).WithLocation(2) + .WithArguments("SecondPlugin", 2)); + } - [Theory] - [InlineData("")] - [InlineData("maybe")] - public async Task Missing_or_invalid_switch_keeps_generation_rules_silent(string value) - { - await Verifier.VerifyWithBuildPropertyAsync(SwitchName, value, Source); - } + [Theory] + [InlineData("")] + [InlineData("maybe")] + public async Task Missing_or_invalid_switch_keeps_generation_rules_silent(string value) + { + await Verifier.VerifyWithBuildPropertyAsync(SwitchName, value, Source); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/MultiplePluginClassesTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/MultiplePluginClassesTests.cs index dd48140f..34c20f25 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/MultiplePluginClassesTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/MultiplePluginClassesTests.cs @@ -1,196 +1,198 @@ using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.Plugin; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Microsoft.CodeAnalysis.Testing; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.AnalyzerVerifier< - CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; + CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; namespace CheatEngine.SDK.Analyzers.Tests.Plugin; /// CESDK0002: exactly one plugin class per assembly (compilation-end diagnostic, reported on each class). public sealed class MultiplePluginClassesTests { - [Fact] - public async Task One_plugin_class_next_to_ordinary_classes_reports_nothing() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - - public sealed class Helper : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Fact] - public async Task Two_plugin_classes_report_on_each() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("First")] - public sealed class {|#0:FirstPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - - [CheatEnginePlugin("Second")] - public sealed class {|#1:SecondPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Multiple(0, "FirstPlugin", 2), - Multiple(1, "SecondPlugin", 2)); - } - - [Fact] - public async Task Three_plugin_classes_in_separate_files_report_on_each() - { - await Verifier.VerifyAsync( - [ - ("First.cs", PluginIn("MyPlugin.One", "{|#0:FirstPlugin|}")), - ("Second.cs", PluginIn("MyPlugin.Two", "{|#1:SecondPlugin|}")), - ("Third.cs", PluginIn("MyPlugin.Three", "{|#2:ThirdPlugin|}")) - ], - Multiple(0, "FirstPlugin", 3), - Multiple(1, "SecondPlugin", 3), - Multiple(2, "ThirdPlugin", 3)); - } - - [Fact] - public async Task Nested_and_top_level_plugin_classes_report_on_each() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Outer")] - public class {|#0:OuterPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - - [CheatEnginePlugin("Inner")] - public sealed class {|#1:InnerPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """, - Multiple(0, "OuterPlugin", 2), - Multiple(1, "OuterPlugin.InnerPlugin", 2)); - } - - [Fact] - public async Task Partial_plugin_class_in_two_files_counts_once() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed partial class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - } - """), - ("Plugin.Disable.cs", """ - namespace MyPlugin; - - public sealed partial class DemoPlugin - { - protected override void OnDisable() { } - } - """) - ]); - } - - [Fact] - public async Task Invalid_plugin_class_still_counts() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("First")] - public sealed class {|#0:FirstPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - - [CheatEnginePlugin("Second")] - public abstract class {|#1:SecondPlugin|} : CheatEnginePlugin - { - } - """, - Multiple(0, "FirstPlugin", 2), - Multiple(1, "SecondPlugin", 2), - Verifier.Diagnostic(DiagnosticDescriptors.InvalidPluginClass) - .WithLocation(1) - .WithArguments("SecondPlugin", PluginClassProblemText.Describe(PluginShapeIssues.Abstract))); - } - - [Fact] - public async Task Plugin_class_in_generated_code_is_not_counted() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginIn("MyPlugin", "DemoPlugin")), - ("Generated.g.cs", "// \n" + PluginIn("MyPlugin.Generated", "GeneratedPlugin")) - ]); - } - - private static string PluginIn(string namespaceName, string className) - { - return $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace {{namespaceName}}; - - [CheatEnginePlugin("Demo")] - public sealed class {{className}} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """; - } - - private static DiagnosticResult Multiple(int location, string className, int count) - { - return Verifier.Diagnostic(DiagnosticDescriptors.MultiplePluginClasses).WithLocation(location) - .WithArguments(className, count); - } + [Fact] + public async Task One_plugin_class_next_to_ordinary_classes_reports_nothing() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + + public sealed class Helper : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Fact] + public async Task Two_plugin_classes_report_on_each() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("First")] + public sealed class {|#0:FirstPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + + [CheatEnginePlugin("Second")] + public sealed class {|#1:SecondPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Multiple(0, "FirstPlugin", 2), + Multiple(1, "SecondPlugin", 2)); + } + + [Fact] + public async Task Three_plugin_classes_in_separate_files_report_on_each() + { + await Verifier.VerifyAsync( + [ + ("First.cs", PluginIn("MyPlugin.One", "{|#0:FirstPlugin|}")), + ("Second.cs", PluginIn("MyPlugin.Two", "{|#1:SecondPlugin|}")), + ("Third.cs", PluginIn("MyPlugin.Three", "{|#2:ThirdPlugin|}")) + ], + Multiple(0, "FirstPlugin", 3), + Multiple(1, "SecondPlugin", 3), + Multiple(2, "ThirdPlugin", 3)); + } + + [Fact] + public async Task Nested_and_top_level_plugin_classes_report_on_each() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Outer")] + public class {|#0:OuterPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + + [CheatEnginePlugin("Inner")] + public sealed class {|#1:InnerPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """, + Multiple(0, "OuterPlugin", 2), + Multiple(1, "OuterPlugin.InnerPlugin", 2)); + } + + [Fact] + public async Task Partial_plugin_class_in_two_files_counts_once() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed partial class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + } + """), + ("Plugin.Disable.cs", """ + namespace MyPlugin; + + public sealed partial class DemoPlugin + { + protected override void OnDisable() { } + } + """) + ]); + } + + [Fact] + public async Task Invalid_plugin_class_still_counts() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("First")] + public sealed class {|#0:FirstPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + + [CheatEnginePlugin("Second")] + public abstract class {|#1:SecondPlugin|} : CheatEnginePlugin + { + } + """, + Multiple(0, "FirstPlugin", 2), + Multiple(1, "SecondPlugin", 2), + Verifier.Diagnostic(DiagnosticDescriptors.InvalidPluginClass) + .WithLocation(1) + .WithArguments("SecondPlugin", PluginClassProblemText.Describe(PluginShapeIssues.Abstract))); + } + + [Fact] + public async Task Plugin_class_in_generated_code_is_not_counted() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginIn("MyPlugin", "DemoPlugin")), + ("Generated.g.cs", "// \n" + PluginIn("MyPlugin.Generated", "GeneratedPlugin")) + ]); + } + + private static string PluginIn(string namespaceName, string className) + { + return $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace {{namespaceName}}; + + [CheatEnginePlugin("Demo")] + public sealed class {{className}} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """; + } + + private static DiagnosticResult Multiple(int location, string className, int count) + { + return Verifier.Diagnostic(DiagnosticDescriptors.MultiplePluginClasses).WithLocation(location) + .WithArguments(className, count); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeCodeFixTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeCodeFixTests.cs index 0c7433f3..f814b334 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeCodeFixTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeCodeFixTests.cs @@ -1,7 +1,8 @@ using CheatEngine.SDK.Analyzers.CodeFixes.Plugin; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.CodeFixVerifier< - CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer, - CheatEngine.SDK.Analyzers.CodeFixes.Plugin.PluginClassShapeCodeFixProvider>; + CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer, + CheatEngine.SDK.Analyzers.CodeFixes.Plugin.PluginClassShapeCodeFixProvider>; namespace CheatEngine.SDK.Analyzers.Tests.Plugin; @@ -11,528 +12,528 @@ namespace CheatEngine.SDK.Analyzers.Tests.Plugin; /// public sealed class PluginClassShapeCodeFixTests { - [Fact] - public async Task Abstract_class_becomes_sealed() - { - await Verifier.VerifyAsync( - Plugin(""" - [CheatEnginePlugin("Demo")] - public abstract partial class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed partial class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); - } - - [Fact] - public async Task Static_class_becomes_sealed_and_the_next_problem_shows_up() - { - await Verifier.VerifyAsync( - Plugin(""" - [CheatEnginePlugin("Demo")] - internal static class {|CESDK0001:DemoPlugin|} - { - public static int Counter; - } - """), - Plugin(""" - [CheatEnginePlugin("Demo")] - internal sealed class {|CESDK0001:DemoPlugin|} - { - public static int Counter; - } - """), - PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); - } - - [Fact] - public async Task Abstract_modifier_on_another_part_is_replaced_in_its_own_document() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", Plugin(""" - [CheatEnginePlugin("Demo")] - public partial class {|CESDK0001:DemoPlugin|} - { - } - """)), - ("Plugin.Lifecycle.cs", Plugin(""" - public abstract partial class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """)) - ], - [ - ("Plugin.cs", Plugin(""" - [CheatEnginePlugin("Demo")] - public partial class DemoPlugin - { - } - """)), - ("Plugin.Lifecycle.cs", Plugin(""" - public sealed partial class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """)) - ], - PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); - } - - [Fact] - public async Task Missing_constructor_is_added_in_front_of_the_first_constructor() - { - await Verifier.VerifyAsync( - Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - private readonly int _value; - - public DemoPlugin(int value) => _value = value; - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - private readonly int _value; - - public DemoPlugin() - { - } - - public DemoPlugin(int value) => _value = value; - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); - } - - [Fact] - public async Task Missing_constructor_is_added_after_the_fields_when_the_constructors_live_in_another_part() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed partial class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - private int _first; - private int _second; - - protected override void OnEnable() => _first = _second; - protected override void OnDisable() => _second = _first; - } - """)), - ("Plugin.Construction.cs", Plugin(""" - public sealed partial class DemoPlugin - { - public DemoPlugin(int value) => _first = value; - } - """)) - ], - [ - ("Plugin.cs", Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed partial class DemoPlugin : CheatEnginePlugin - { - private int _first; - private int _second; - - public DemoPlugin() - { - } - - protected override void OnEnable() => _first = _second; - protected override void OnDisable() => _second = _first; - } - """)), - ("Plugin.Construction.cs", Plugin(""" - public sealed partial class DemoPlugin - { - public DemoPlugin(int value) => _first = value; - } - """)) - ], - PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); - } - - [Theory] - [InlineData("private DemoPlugin()")] - [InlineData("protected DemoPlugin()")] - [InlineData("private protected DemoPlugin()")] - [InlineData("DemoPlugin()")] - public async Task Hidden_constructor_becomes_public(string constructor) - { - await Verifier.VerifyAsync( - Plugin($$""" - [CheatEnginePlugin("Demo")] - public class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - // The comment stays in front of the constructor. - {{constructor}} - { - } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - [CheatEnginePlugin("Demo")] - public class DemoPlugin : CheatEnginePlugin - { - // The comment stays in front of the constructor. - public DemoPlugin() - { - } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); - } - - [Fact] - public async Task Hidden_constructor_in_another_part_becomes_public_in_its_own_document() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed partial class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """)), - ("Plugin.Construction.cs", Plugin(""" - public sealed partial class DemoPlugin - { - [System.Obsolete("Only to show that attributes stay in front of the modifier.")] - private unsafe DemoPlugin() { } - } - """)) - ], - [ - ("Plugin.cs", Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed partial class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """)), - ("Plugin.Construction.cs", Plugin(""" - public sealed partial class DemoPlugin - { - [System.Obsolete("Only to show that attributes stay in front of the modifier.")] - public unsafe DemoPlugin() { } - } - """)) - ], - PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); - } - - [Fact] - public async Task Comment_on_the_dropped_second_accessibility_keyword_is_kept() - { - await Verifier.VerifyAsync( - Plugin(""" - [CheatEnginePlugin("Demo")] - public class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - private /* a */ protected /* b: documented reason */ DemoPlugin() { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - [CheatEnginePlugin("Demo")] - public class DemoPlugin : CheatEnginePlugin - { - public /* a */ /* b: documented reason */ DemoPlugin() { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); - } - - [Fact] - public async Task Comment_on_a_dropped_keyword_that_follows_another_modifier_stays_in_place() - { - await Verifier.VerifyAsync( - Plugin(""" - [CheatEnginePlugin("Demo")] - public class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - private unsafe protected // only derived plugins of this assembly - DemoPlugin() { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - [CheatEnginePlugin("Demo")] - public class DemoPlugin : CheatEnginePlugin - { - public unsafe // only derived plugins of this assembly - DemoPlugin() { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); - } - - [Theory] - [InlineData("protected PluginBase(int value) { }")] - [InlineData("private PluginBase() { } protected PluginBase(int value) { }")] - public async Task Missing_constructor_gets_no_fix_when_the_base_class_cannot_be_constructed_without_arguments( - string baseConstructors) - { - var source = Plugin($$""" - public abstract class PluginBase : CheatEnginePlugin - { - {{baseConstructors}} - } - - [CheatEnginePlugin("Demo")] - public sealed class {|CESDK0001:DemoPlugin|} : PluginBase - { - public DemoPlugin(int value) : base(value) { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Missing_constructor_is_added_when_the_base_constructor_has_only_optional_parameters() - { - await Verifier.VerifyAsync( - Plugin(""" - public abstract class PluginBase : CheatEnginePlugin - { - protected PluginBase(int value = 0) { } - } - - [CheatEnginePlugin("Demo")] - public sealed class {|CESDK0001:DemoPlugin|} : PluginBase - { - public DemoPlugin(int value) : base(value) { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - public abstract class PluginBase : CheatEnginePlugin - { - protected PluginBase(int value = 0) { } - } - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : PluginBase - { - public DemoPlugin() - { - } - - public DemoPlugin(int value) : base(value) { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); - } - - [Fact] - public async Task Required_members_and_obsolete_errors_get_no_fix() - { - var source = Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed class {|CESDK0001:{|CESDK0001:DemoPlugin|}|} : CheatEnginePlugin - { - [System.Obsolete("Construct through the host.", error: true)] - public DemoPlugin() { } - - public required int Value { get; set; } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Primary_constructor_gets_no_fix() - { - var source = Plugin(""" - [CheatEnginePlugin("Demo")] - public sealed class {|CESDK0001:DemoPlugin|}(int value) : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() => value.ToString(); - } - """); - - await Verifier.VerifyAsync(source, source); - } - - [Theory] - [InlineData("public sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin")] - [InlineData("file sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin")] - [InlineData("public sealed class {|CESDK0001:DemoPlugin|} : Unrelated")] - public async Task Design_problems_of_the_class_get_no_fix(string declaration) - { - var source = Plugin($$""" - public abstract class Unrelated - { - protected abstract void OnEnable(); - protected abstract void OnDisable(); - } - - [CheatEnginePlugin("Demo")] - {{declaration}} - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Hidden_nested_class_and_unusable_name_get_no_fix() - { - var source = Plugin(""" - public class Container - { - [{|CESDK0001:CheatEnginePlugin("")|}] - private sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """); - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Fix_all_adds_the_constructor_to_every_plugin_class() - { - await Verifier.VerifyAsync( - [ - ("First.cs", PluginWithoutConstructor("First", false)), - ("Second.cs", PluginWithoutConstructor("Second", false)) - ], - [ - ("First.cs", PluginWithoutConstructor("First", true)), - ("Second.cs", PluginWithoutConstructor("Second", true)) - ], - PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); - } - - [Fact] - public async Task Fix_all_replaces_abstract_in_every_plugin_class_of_a_document() - { - await Verifier.VerifyAsync( - Plugin(""" - [CheatEnginePlugin("First")] - public abstract class {|CESDK0001:{|CESDK0002:FirstPlugin|}|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - - [CheatEnginePlugin("Second")] - public abstract class {|CESDK0001:{|CESDK0002:SecondPlugin|}|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - Plugin(""" - [CheatEnginePlugin("First")] - public sealed class {|CESDK0002:FirstPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - - [CheatEnginePlugin("Second")] - public sealed class {|CESDK0002:SecondPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """), - PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); - } - - private static string PluginWithoutConstructor(string name, bool fixedState) - { - var className = fixedState - ? $$"""{|CESDK0002:{{name}}Plugin|}""" - : $$"""{|CESDK0001:{|CESDK0002:{{name}}Plugin|}|}"""; - var constructor = fixedState ? $" public {name}Plugin()\n {{\n }}\n\n" : string.Empty; - return Plugin($$""" - [CheatEnginePlugin("{{name}}")] - public sealed class {{className}} : CheatEnginePlugin - { - {{constructor}} public {{name}}Plugin(string text) { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - // File-scoped namespace and the two usings around the type declarations under test. - private static string Plugin(string types) - { - return $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - {{types}} - """; - } + [Fact] + public async Task Abstract_class_becomes_sealed() + { + await Verifier.VerifyAsync( + Plugin(""" + [CheatEnginePlugin("Demo")] + public abstract partial class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed partial class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); + } + + [Fact] + public async Task Static_class_becomes_sealed_and_the_next_problem_shows_up() + { + await Verifier.VerifyAsync( + Plugin(""" + [CheatEnginePlugin("Demo")] + internal static class {|CESDK0001:DemoPlugin|} + { + public static int Counter; + } + """), + Plugin(""" + [CheatEnginePlugin("Demo")] + internal sealed class {|CESDK0001:DemoPlugin|} + { + public static int Counter; + } + """), + PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); + } + + [Fact] + public async Task Abstract_modifier_on_another_part_is_replaced_in_its_own_document() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", Plugin(""" + [CheatEnginePlugin("Demo")] + public partial class {|CESDK0001:DemoPlugin|} + { + } + """)), + ("Plugin.Lifecycle.cs", Plugin(""" + public abstract partial class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """)) + ], + [ + ("Plugin.cs", Plugin(""" + [CheatEnginePlugin("Demo")] + public partial class DemoPlugin + { + } + """)), + ("Plugin.Lifecycle.cs", Plugin(""" + public sealed partial class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """)) + ], + PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); + } + + [Fact] + public async Task Missing_constructor_is_added_in_front_of_the_first_constructor() + { + await Verifier.VerifyAsync( + Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + private readonly int _value; + + public DemoPlugin(int value) => _value = value; + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + private readonly int _value; + + public DemoPlugin() + { + } + + public DemoPlugin(int value) => _value = value; + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); + } + + [Fact] + public async Task Missing_constructor_is_added_after_the_fields_when_the_constructors_live_in_another_part() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed partial class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + private int _first; + private int _second; + + protected override void OnEnable() => _first = _second; + protected override void OnDisable() => _second = _first; + } + """)), + ("Plugin.Construction.cs", Plugin(""" + public sealed partial class DemoPlugin + { + public DemoPlugin(int value) => _first = value; + } + """)) + ], + [ + ("Plugin.cs", Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed partial class DemoPlugin : CheatEnginePlugin + { + private int _first; + private int _second; + + public DemoPlugin() + { + } + + protected override void OnEnable() => _first = _second; + protected override void OnDisable() => _second = _first; + } + """)), + ("Plugin.Construction.cs", Plugin(""" + public sealed partial class DemoPlugin + { + public DemoPlugin(int value) => _first = value; + } + """)) + ], + PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); + } + + [Theory] + [InlineData("private DemoPlugin()")] + [InlineData("protected DemoPlugin()")] + [InlineData("private protected DemoPlugin()")] + [InlineData("DemoPlugin()")] + public async Task Hidden_constructor_becomes_public(string constructor) + { + await Verifier.VerifyAsync( + Plugin($$""" + [CheatEnginePlugin("Demo")] + public class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + // The comment stays in front of the constructor. + {{constructor}} + { + } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + [CheatEnginePlugin("Demo")] + public class DemoPlugin : CheatEnginePlugin + { + // The comment stays in front of the constructor. + public DemoPlugin() + { + } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); + } + + [Fact] + public async Task Hidden_constructor_in_another_part_becomes_public_in_its_own_document() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed partial class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """)), + ("Plugin.Construction.cs", Plugin(""" + public sealed partial class DemoPlugin + { + [System.Obsolete("Only to show that attributes stay in front of the modifier.")] + private unsafe DemoPlugin() { } + } + """)) + ], + [ + ("Plugin.cs", Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed partial class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """)), + ("Plugin.Construction.cs", Plugin(""" + public sealed partial class DemoPlugin + { + [System.Obsolete("Only to show that attributes stay in front of the modifier.")] + public unsafe DemoPlugin() { } + } + """)) + ], + PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); + } + + [Fact] + public async Task Comment_on_the_dropped_second_accessibility_keyword_is_kept() + { + await Verifier.VerifyAsync( + Plugin(""" + [CheatEnginePlugin("Demo")] + public class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + private /* a */ protected /* b: documented reason */ DemoPlugin() { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + [CheatEnginePlugin("Demo")] + public class DemoPlugin : CheatEnginePlugin + { + public /* a */ /* b: documented reason */ DemoPlugin() { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); + } + + [Fact] + public async Task Comment_on_a_dropped_keyword_that_follows_another_modifier_stays_in_place() + { + await Verifier.VerifyAsync( + Plugin(""" + [CheatEnginePlugin("Demo")] + public class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + private unsafe protected // only derived plugins of this assembly + DemoPlugin() { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + [CheatEnginePlugin("Demo")] + public class DemoPlugin : CheatEnginePlugin + { + public unsafe // only derived plugins of this assembly + DemoPlugin() { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.MakeConstructorPublicEquivalenceKey); + } + + [Theory] + [InlineData("protected PluginBase(int value) { }")] + [InlineData("private PluginBase() { } protected PluginBase(int value) { }")] + public async Task Missing_constructor_gets_no_fix_when_the_base_class_cannot_be_constructed_without_arguments( + string baseConstructors) + { + string source = Plugin($$""" + public abstract class PluginBase : CheatEnginePlugin + { + {{baseConstructors}} + } + + [CheatEnginePlugin("Demo")] + public sealed class {|CESDK0001:DemoPlugin|} : PluginBase + { + public DemoPlugin(int value) : base(value) { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Missing_constructor_is_added_when_the_base_constructor_has_only_optional_parameters() + { + await Verifier.VerifyAsync( + Plugin(""" + public abstract class PluginBase : CheatEnginePlugin + { + protected PluginBase(int value = 0) { } + } + + [CheatEnginePlugin("Demo")] + public sealed class {|CESDK0001:DemoPlugin|} : PluginBase + { + public DemoPlugin(int value) : base(value) { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + public abstract class PluginBase : CheatEnginePlugin + { + protected PluginBase(int value = 0) { } + } + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : PluginBase + { + public DemoPlugin() + { + } + + public DemoPlugin(int value) : base(value) { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); + } + + [Fact] + public async Task Required_members_and_obsolete_errors_get_no_fix() + { + string source = Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed class {|CESDK0001:{|CESDK0001:DemoPlugin|}|} : CheatEnginePlugin + { + [System.Obsolete("Construct through the host.", error: true)] + public DemoPlugin() { } + + public required int Value { get; set; } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Primary_constructor_gets_no_fix() + { + string source = Plugin(""" + [CheatEnginePlugin("Demo")] + public sealed class {|CESDK0001:DemoPlugin|}(int value) : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() => value.ToString(); + } + """); + + await Verifier.VerifyAsync(source, source); + } + + [Theory] + [InlineData("public sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin")] + [InlineData("file sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin")] + [InlineData("public sealed class {|CESDK0001:DemoPlugin|} : Unrelated")] + public async Task Design_problems_of_the_class_get_no_fix(string declaration) + { + string source = Plugin($$""" + public abstract class Unrelated + { + protected abstract void OnEnable(); + protected abstract void OnDisable(); + } + + [CheatEnginePlugin("Demo")] + {{declaration}} + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Hidden_nested_class_and_unusable_name_get_no_fix() + { + string source = Plugin(""" + public class Container + { + [{|CESDK0001:CheatEnginePlugin("")|}] + private sealed class {|CESDK0001:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """); + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Fix_all_adds_the_constructor_to_every_plugin_class() + { + await Verifier.VerifyAsync( + [ + ("First.cs", PluginWithoutConstructor("First", false)), + ("Second.cs", PluginWithoutConstructor("Second", false)) + ], + [ + ("First.cs", PluginWithoutConstructor("First", true)), + ("Second.cs", PluginWithoutConstructor("Second", true)) + ], + PluginClassShapeCodeFixProvider.AddConstructorEquivalenceKey); + } + + [Fact] + public async Task Fix_all_replaces_abstract_in_every_plugin_class_of_a_document() + { + await Verifier.VerifyAsync( + Plugin(""" + [CheatEnginePlugin("First")] + public abstract class {|CESDK0001:{|CESDK0002:FirstPlugin|}|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + + [CheatEnginePlugin("Second")] + public abstract class {|CESDK0001:{|CESDK0002:SecondPlugin|}|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + Plugin(""" + [CheatEnginePlugin("First")] + public sealed class {|CESDK0002:FirstPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + + [CheatEnginePlugin("Second")] + public sealed class {|CESDK0002:SecondPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """), + PluginClassShapeCodeFixProvider.MakeSealedEquivalenceKey); + } + + private static string PluginWithoutConstructor(string name, bool fixedState) + { + string className = fixedState + ? $$"""{|CESDK0002:{{name}}Plugin|}""" + : $$"""{|CESDK0001:{|CESDK0002:{{name}}Plugin|}|}"""; + string constructor = fixedState ? $" public {name}Plugin()\n {{\n }}\n\n" : string.Empty; + return Plugin($$""" + [CheatEnginePlugin("{{name}}")] + public sealed class {{className}} : CheatEnginePlugin + { + {{constructor}} public {{name}}Plugin(string text) { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + // File-scoped namespace and the two usings around the type declarations under test. + private static string Plugin(string types) + { + return $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + {{types}} + """; + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeTests.cs index 148acefd..18d32712 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginClassShapeTests.cs @@ -1,848 +1,850 @@ using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.Plugin; using CheatEngine.SDK.SourceGenerators.Shared.Shapes; + using Microsoft.CodeAnalysis.Testing; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.AnalyzerVerifier< - CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; + CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; namespace CheatEngine.SDK.Analyzers.Tests.Plugin; /// CESDK0001: which classes the generated entry point can construct, and which it cannot. public sealed class PluginClassShapeTests { - [Fact] - public async Task Sealed_public_class_with_implicit_constructor_reports_nothing() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Fact] - public async Task Internal_unsealed_class_with_internal_constructor_reports_nothing() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - internal class DemoPlugin : CheatEnginePlugin - { - internal DemoPlugin() { } - public DemoPlugin(int value) { } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Fact] - public async Task Class_in_the_global_namespace_reports_nothing() - { - await Verifier.VerifyAsync(""" - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Fact] - public async Task Invalid_class_in_the_global_namespace_reports() - { - await Verifier.VerifyAsync( - """ - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} - { - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase)); - } - - [Theory] - [InlineData("public")] - [InlineData("internal")] - [InlineData("protected internal")] - public async Task Nested_class_visible_in_the_assembly_reports_nothing(string accessibility) - { - await Verifier.VerifyAsync($$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public class Container - { - [CheatEnginePlugin("Demo")] - {{accessibility}} sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """); - } - - [Theory] - [InlineData("private")] - [InlineData("protected")] - [InlineData("private protected")] - public async Task Nested_class_hidden_from_the_assembly_reports_inaccessible(string accessibility) - { - await Verifier.VerifyAsync( - $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public class Container - { - [CheatEnginePlugin("Demo")] - {{accessibility}} sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """, - Problem(0, "Container.DemoPlugin", PluginShapeIssues.Inaccessible)); - } - - [Fact] - public async Task Public_class_nested_in_a_private_class_reports_inaccessible() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public class Outer - { - private class Hidden - { - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - } - """, - Problem(0, "Outer.Hidden.DemoPlugin", PluginShapeIssues.Inaccessible)); - } - - [Fact] - public async Task Class_deriving_indirectly_from_the_plugin_base_reports_nothing() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public abstract class PluginBase : CheatEnginePlugin - { - protected override void OnDisable() { } - } - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : PluginBase - { - protected override void OnEnable() { } - } - """); - } - - [Fact] - public async Task Primary_constructor_without_parameters_reports_nothing() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin() : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Fact] - public async Task Primary_constructor_with_parameters_reports_missing_constructor() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|}(int value) : CheatEnginePlugin - { - public int Value { get; } = value; - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); - } - - [Fact] - public async Task Only_parameterized_constructors_report_missing_constructor() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - public DemoPlugin(int value) { } - public DemoPlugin(string text) { } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); - } - - [Fact] - public async Task Constructor_with_only_optional_parameters_reports_missing_parameterless_constructor() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - public DemoPlugin(int value = 0) { } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); - } - - [Fact] - public async Task Constructor_with_a_trailing_params_parameter_reports_missing_parameterless_constructor() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - public DemoPlugin(params int[] xs) { } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); - } - - [Fact] - public async Task Optional_and_params_constructors_do_not_supply_a_parameterless_constructor() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - private DemoPlugin(int value = 0) { } - internal DemoPlugin(string text = "") { } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); - } - - [Theory] - [InlineData("private ")] - [InlineData("protected ")] - [InlineData("private protected ")] - [InlineData("")] - public async Task Hidden_parameterless_constructor_reports_inaccessible_constructor(string accessibility) - { - await Verifier.VerifyAsync( - $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - {{accessibility}}DemoPlugin() { } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.InaccessibleParameterlessConstructor)); - } - - [Theory] - [InlineData("public required int Value { get; set; }")] - [InlineData("public required int Value;")] - [InlineData("public DemoPlugin() { } public required int Value { get; init; }")] - public async Task Required_member_that_the_constructor_does_not_set_reports_required_members(string members) - { - await Verifier.VerifyAsync( - $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - {{members}} - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.RequiredMembers)); - } - - [Fact] - public async Task Required_member_of_a_base_class_reports_required_members() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public abstract class PluginBase : CheatEnginePlugin - { - public required string Title { get; init; } - } - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : PluginBase - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.RequiredMembers)); - } - - [Fact] - public async Task Required_member_set_by_the_parameterless_constructor_reports_nothing() - { - await Verifier.VerifyAsync(""" - using System.Diagnostics.CodeAnalysis; - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - [SetsRequiredMembers] - public DemoPlugin() => Value = 1; - - public DemoPlugin(int value) => Value = value; - - public required int Value { get; set; } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Theory] - [InlineData("[System.Obsolete(\"Use the host.\", error: true)]", "", "")] - [InlineData("[System.Obsolete(\"Use the host.\", true)]", "", "")] - [InlineData("", "[System.Obsolete(\"Use the host.\", true)]", "")] - [InlineData("", "", "[System.Obsolete(\"Use the host.\", true)]")] - public async Task Obsolete_error_on_anything_the_entry_point_names_reports_obsolete_error(string onConstructor, - string onClass, string onContainer) - { - await Verifier.VerifyAsync( - $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - {{onContainer}} - public class Container - { - {{onClass}} - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - {{onConstructor}} - public DemoPlugin() { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """, - Problem(0, "Container.DemoPlugin", PluginShapeIssues.ObsoleteError)); - } - - [Theory] - [InlineData("[System.Obsolete]")] - [InlineData("[System.Obsolete(\"Use the host.\")]")] - [InlineData("[System.Obsolete(\"Use the host.\", false)]")] - public async Task Obsolete_warning_is_left_to_the_generated_pragma_and_reports_nothing(string attribute) - { - await Verifier.VerifyAsync($$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - {{attribute}} - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - {{attribute}} - public DemoPlugin() { } - - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Fact] - public async Task Static_class_reports_static_only() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public static class {|#0:DemoPlugin|} - { - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.Static)); - } - - [Fact] - public async Task Abstract_class_reports_abstract_and_not_its_protected_implicit_constructor() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public abstract class {|#0:DemoPlugin|} : CheatEnginePlugin - { - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.Abstract)); - } - - [Fact] - public async Task Generic_class_reports_generic() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.Generic)); - } - - [Fact] - public async Task Class_nested_in_a_generic_type_reports_nested_in_generic() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public class Container - { - public class Inner - { - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - } - """, - Problem(0, "Container.Inner.DemoPlugin", PluginShapeIssues.NestedInGeneric)); - } - - [Fact] - public async Task Class_without_the_plugin_base_reports_not_derived() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - public class CheatEnginePlugin - { - } - - [CheatEnginePlugin("Demo")] - public sealed class {|#0:DemoPlugin|} : MyPlugin.CheatEnginePlugin - { - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase)); - } - - [Fact] - public async Task Record_reports_not_derived_because_a_record_cannot_inherit_a_class() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed record {|#0:DemoPlugin|}; - """, - Problem(0, "DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase)); - } - - [Fact] - public async Task File_local_class_reports_file_local() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - file sealed class {|#0:DemoPlugin|} : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.FileLocal)); - } - - [Theory] - [InlineData( - "[CheatEnginePlugin(\"Demo\")] public sealed class {|#0:CESDK|} : CheatEnginePlugin { protected override void OnEnable() { } protected override void OnDisable() { } }", - "CESDK")] - [InlineData( - "public class CESDK { [CheatEnginePlugin(\"Demo\")] public sealed class {|#0:Inner|} : CheatEnginePlugin { protected override void OnEnable() { } protected override void OnDisable() { } } }", - "CESDK.Inner")] - public async Task Class_that_takes_the_name_of_the_generated_entry_point_reports_reserved_name(string declaration, - string displayName) - { - var directCollision = string.Equals(displayName, "CESDK", StringComparison.Ordinal); - var collision = directCollision - ? Verifier.Diagnostic(DiagnosticDescriptors.GeneratedEntryPointCollision) - .WithSpan("Test0.cs", 6, 55, 6, 60) - .WithArguments("CESDK") - : Verifier.Diagnostic(DiagnosticDescriptors.GeneratedEntryPointCollision) - .WithSpan("Test0.cs", 6, 20, 6, 25) - .WithArguments("CESDK"); - var reservedName = Problem(0, displayName, PluginShapeIssues.ReservedEntryPointName); - - await Verifier.VerifyAsync( - $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace {|#1:CESDK|} - { - {{declaration}} - } - """, - directCollision - ? - [ - Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(1).WithArguments("CESDK"), - reservedName, - collision, - ] - : - [ - Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(1).WithArguments("CESDK"), - collision, - reservedName, - ]); - } - - [Fact] - public async Task Class_named_like_the_entry_point_in_another_namespace_reports_nothing() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class CESDK : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - } - - [Theory] - [InlineData("\"\"")] - [InlineData("\" \"")] - [InlineData("null!")] - public async Task Unusable_display_name_reports_on_the_attribute(string name) - { - await Verifier.VerifyAsync( - $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [{|#0:CheatEnginePlugin({{name}})|}] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.InvalidName)); - } - - [Fact] - public async Task Several_problems_report_one_diagnostic_each() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - public class Container - { - [CheatEnginePlugin("Demo")] - private abstract class {|#0:DemoPlugin|} - { - private DemoPlugin() { } - } - } - """, - Problem(0, "Container.DemoPlugin", PluginShapeIssues.Abstract), - Problem(0, "Container.DemoPlugin", PluginShapeIssues.Generic), - Problem(0, "Container.DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase), - Problem(0, "Container.DemoPlugin", PluginShapeIssues.Inaccessible), - Problem(0, "Container.DemoPlugin", PluginShapeIssues.InaccessibleParameterlessConstructor)); - } - - [Fact] - public async Task Attribute_through_an_alias_is_recognised() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Hosting.Plugin; - using Plugin = CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute; - - namespace MyPlugin; - - [Plugin("Demo")] - public abstract class {|#0:DemoPlugin|} : CheatEnginePlugin - { - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.Abstract)); - } - - [Fact] - public async Task Attribute_with_a_fully_qualified_name_is_recognised() - { - await Verifier.VerifyAsync( - """ - namespace MyPlugin; - - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute("Demo")] - public abstract class {|#0:DemoPlugin|} : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - } - """, - Problem(0, "DemoPlugin", PluginShapeIssues.Abstract)); - } - - [Fact] - public async Task Look_alike_attribute_from_another_namespace_is_ignored() - { - await Verifier.VerifyAsync(""" - using System; - - namespace MyPlugin; - - [AttributeUsage(AttributeTargets.Class)] - public sealed class CheatEnginePluginAttribute(string name) : Attribute - { - public string Name { get; } = name; - } - - [CheatEnginePlugin("Demo")] - public abstract class DemoPlugin - { - } - """); - } - - [Fact] - public async Task Partial_class_reports_once_on_the_part_that_carries_the_attribute() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", """ - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public partial class {|#0:DemoPlugin|} - { - } - """), - ("Plugin.Lifecycle.cs", """ - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - public abstract partial class DemoPlugin : CheatEnginePlugin - { - private DemoPlugin() { } - } - """) - ], - Problem(0, "DemoPlugin", PluginShapeIssues.Abstract), - Problem(0, "DemoPlugin", PluginShapeIssues.InaccessibleParameterlessConstructor)); - } - - [Fact] - public async Task Class_in_generated_code_is_not_analysed() - { - await Verifier.VerifyAsync(""" - // - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public abstract class DemoPlugin - { - } - """); - } - - [Fact] - public async Task Attribute_on_a_struct_is_left_to_the_compiler() - { - await Verifier.VerifyAsync(""" - using CheatEngine.SDK.Annotations.Plugin; - - namespace MyPlugin; - - [{|CS0592:CheatEnginePlugin|}("Demo")] - public struct DemoPlugin - { - } - """); - } - - [Fact] - public async Task Project_without_a_cheatengine_sdk_reference_is_not_analysed() - { - await Verifier.VerifyWithoutCheatEngineSdkAsync(""" - using System; - - namespace CESDK.Lookalike - { - [AttributeUsage(AttributeTargets.Class)] - public sealed class CheatEnginePluginAttribute(string name) : Attribute - { - public string Name { get; } = name; - } - - [CheatEnginePlugin("Demo")] - public abstract class DemoPlugin - { - } - } - """); - } - - private static DiagnosticResult Problem(int location, string className, PluginShapeIssues problem) - { - return Verifier.Diagnostic(DiagnosticDescriptors.InvalidPluginClass) - .WithLocation(location) - .WithArguments(className, PluginClassProblemText.Describe(problem)); - } + [Fact] + public async Task Sealed_public_class_with_implicit_constructor_reports_nothing() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Fact] + public async Task Internal_unsealed_class_with_internal_constructor_reports_nothing() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + internal class DemoPlugin : CheatEnginePlugin + { + internal DemoPlugin() { } + public DemoPlugin(int value) { } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Fact] + public async Task Class_in_the_global_namespace_reports_nothing() + { + await Verifier.VerifyAsync(""" + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Fact] + public async Task Invalid_class_in_the_global_namespace_reports() + { + await Verifier.VerifyAsync( + """ + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} + { + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase)); + } + + [Theory] + [InlineData("public")] + [InlineData("internal")] + [InlineData("protected internal")] + public async Task Nested_class_visible_in_the_assembly_reports_nothing(string accessibility) + { + await Verifier.VerifyAsync($$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public class Container + { + [CheatEnginePlugin("Demo")] + {{accessibility}} sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """); + } + + [Theory] + [InlineData("private")] + [InlineData("protected")] + [InlineData("private protected")] + public async Task Nested_class_hidden_from_the_assembly_reports_inaccessible(string accessibility) + { + await Verifier.VerifyAsync( + $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public class Container + { + [CheatEnginePlugin("Demo")] + {{accessibility}} sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """, + Problem(0, "Container.DemoPlugin", PluginShapeIssues.Inaccessible)); + } + + [Fact] + public async Task Public_class_nested_in_a_private_class_reports_inaccessible() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public class Outer + { + private class Hidden + { + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + } + """, + Problem(0, "Outer.Hidden.DemoPlugin", PluginShapeIssues.Inaccessible)); + } + + [Fact] + public async Task Class_deriving_indirectly_from_the_plugin_base_reports_nothing() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public abstract class PluginBase : CheatEnginePlugin + { + protected override void OnDisable() { } + } + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : PluginBase + { + protected override void OnEnable() { } + } + """); + } + + [Fact] + public async Task Primary_constructor_without_parameters_reports_nothing() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin() : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Fact] + public async Task Primary_constructor_with_parameters_reports_missing_constructor() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|}(int value) : CheatEnginePlugin + { + public int Value { get; } = value; + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); + } + + [Fact] + public async Task Only_parameterized_constructors_report_missing_constructor() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + public DemoPlugin(int value) { } + public DemoPlugin(string text) { } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); + } + + [Fact] + public async Task Constructor_with_only_optional_parameters_reports_missing_parameterless_constructor() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + public DemoPlugin(int value = 0) { } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); + } + + [Fact] + public async Task Constructor_with_a_trailing_params_parameter_reports_missing_parameterless_constructor() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + public DemoPlugin(params int[] xs) { } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); + } + + [Fact] + public async Task Optional_and_params_constructors_do_not_supply_a_parameterless_constructor() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + private DemoPlugin(int value = 0) { } + internal DemoPlugin(string text = "") { } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.MissingParameterlessConstructor)); + } + + [Theory] + [InlineData("private ")] + [InlineData("protected ")] + [InlineData("private protected ")] + [InlineData("")] + public async Task Hidden_parameterless_constructor_reports_inaccessible_constructor(string accessibility) + { + await Verifier.VerifyAsync( + $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + {{accessibility}}DemoPlugin() { } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.InaccessibleParameterlessConstructor)); + } + + [Theory] + [InlineData("public required int Value { get; set; }")] + [InlineData("public required int Value;")] + [InlineData("public DemoPlugin() { } public required int Value { get; init; }")] + public async Task Required_member_that_the_constructor_does_not_set_reports_required_members(string members) + { + await Verifier.VerifyAsync( + $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + {{members}} + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.RequiredMembers)); + } + + [Fact] + public async Task Required_member_of_a_base_class_reports_required_members() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public abstract class PluginBase : CheatEnginePlugin + { + public required string Title { get; init; } + } + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : PluginBase + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.RequiredMembers)); + } + + [Fact] + public async Task Required_member_set_by_the_parameterless_constructor_reports_nothing() + { + await Verifier.VerifyAsync(""" + using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + [SetsRequiredMembers] + public DemoPlugin() => Value = 1; + + public DemoPlugin(int value) => Value = value; + + public required int Value { get; set; } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Theory] + [InlineData("[System.Obsolete(\"Use the host.\", error: true)]", "", "")] + [InlineData("[System.Obsolete(\"Use the host.\", true)]", "", "")] + [InlineData("", "[System.Obsolete(\"Use the host.\", true)]", "")] + [InlineData("", "", "[System.Obsolete(\"Use the host.\", true)]")] + public async Task Obsolete_error_on_anything_the_entry_point_names_reports_obsolete_error(string onConstructor, + string onClass, string onContainer) + { + await Verifier.VerifyAsync( + $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + {{onContainer}} + public class Container + { + {{onClass}} + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + {{onConstructor}} + public DemoPlugin() { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """, + Problem(0, "Container.DemoPlugin", PluginShapeIssues.ObsoleteError)); + } + + [Theory] + [InlineData("[System.Obsolete]")] + [InlineData("[System.Obsolete(\"Use the host.\")]")] + [InlineData("[System.Obsolete(\"Use the host.\", false)]")] + public async Task Obsolete_warning_is_left_to_the_generated_pragma_and_reports_nothing(string attribute) + { + await Verifier.VerifyAsync($$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + {{attribute}} + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + {{attribute}} + public DemoPlugin() { } + + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Fact] + public async Task Static_class_reports_static_only() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public static class {|#0:DemoPlugin|} + { + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.Static)); + } + + [Fact] + public async Task Abstract_class_reports_abstract_and_not_its_protected_implicit_constructor() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public abstract class {|#0:DemoPlugin|} : CheatEnginePlugin + { + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.Abstract)); + } + + [Fact] + public async Task Generic_class_reports_generic() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.Generic)); + } + + [Fact] + public async Task Class_nested_in_a_generic_type_reports_nested_in_generic() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public class Container + { + public class Inner + { + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + } + """, + Problem(0, "Container.Inner.DemoPlugin", PluginShapeIssues.NestedInGeneric)); + } + + [Fact] + public async Task Class_without_the_plugin_base_reports_not_derived() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + public class CheatEnginePlugin + { + } + + [CheatEnginePlugin("Demo")] + public sealed class {|#0:DemoPlugin|} : MyPlugin.CheatEnginePlugin + { + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase)); + } + + [Fact] + public async Task Record_reports_not_derived_because_a_record_cannot_inherit_a_class() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed record {|#0:DemoPlugin|}; + """, + Problem(0, "DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase)); + } + + [Fact] + public async Task File_local_class_reports_file_local() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + file sealed class {|#0:DemoPlugin|} : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.FileLocal)); + } + + [Theory] + [InlineData( + "[CheatEnginePlugin(\"Demo\")] public sealed class {|#0:CESDK|} : CheatEnginePlugin { protected override void OnEnable() { } protected override void OnDisable() { } }", + "CESDK")] + [InlineData( + "public class CESDK { [CheatEnginePlugin(\"Demo\")] public sealed class {|#0:Inner|} : CheatEnginePlugin { protected override void OnEnable() { } protected override void OnDisable() { } } }", + "CESDK.Inner")] + public async Task Class_that_takes_the_name_of_the_generated_entry_point_reports_reserved_name(string declaration, + string displayName) + { + bool directCollision = string.Equals(displayName, "CESDK", StringComparison.Ordinal); + DiagnosticResult collision = directCollision + ? Verifier.Diagnostic(DiagnosticDescriptors.GeneratedEntryPointCollision) + .WithSpan("Test0.cs", 6, 55, 6, 60) + .WithArguments("CESDK") + : Verifier.Diagnostic(DiagnosticDescriptors.GeneratedEntryPointCollision) + .WithSpan("Test0.cs", 6, 20, 6, 25) + .WithArguments("CESDK"); + DiagnosticResult reservedName = Problem(0, displayName, PluginShapeIssues.ReservedEntryPointName); + + await Verifier.VerifyAsync( + $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace {|#1:CESDK|} + { + {{declaration}} + } + """, + directCollision + ? + [ + Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(1).WithArguments("CESDK"), + reservedName, + collision + ] + : + [ + Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(1).WithArguments("CESDK"), + collision, + reservedName + ]); + } + + [Fact] + public async Task Class_named_like_the_entry_point_in_another_namespace_reports_nothing() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class CESDK : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("\" \"")] + [InlineData("null!")] + public async Task Unusable_display_name_reports_on_the_attribute(string name) + { + await Verifier.VerifyAsync( + $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [{|#0:CheatEnginePlugin({{name}})|}] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.InvalidName)); + } + + [Fact] + public async Task Several_problems_report_one_diagnostic_each() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + public class Container + { + [CheatEnginePlugin("Demo")] + private abstract class {|#0:DemoPlugin|} + { + private DemoPlugin() { } + } + } + """, + Problem(0, "Container.DemoPlugin", PluginShapeIssues.Abstract), + Problem(0, "Container.DemoPlugin", PluginShapeIssues.Generic), + Problem(0, "Container.DemoPlugin", PluginShapeIssues.NotDerivedFromPluginBase), + Problem(0, "Container.DemoPlugin", PluginShapeIssues.Inaccessible), + Problem(0, "Container.DemoPlugin", PluginShapeIssues.InaccessibleParameterlessConstructor)); + } + + [Fact] + public async Task Attribute_through_an_alias_is_recognised() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Hosting.Plugin; + using Plugin = CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute; + + namespace MyPlugin; + + [Plugin("Demo")] + public abstract class {|#0:DemoPlugin|} : CheatEnginePlugin + { + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.Abstract)); + } + + [Fact] + public async Task Attribute_with_a_fully_qualified_name_is_recognised() + { + await Verifier.VerifyAsync( + """ + namespace MyPlugin; + + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute("Demo")] + public abstract class {|#0:DemoPlugin|} : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + } + """, + Problem(0, "DemoPlugin", PluginShapeIssues.Abstract)); + } + + [Fact] + public async Task Look_alike_attribute_from_another_namespace_is_ignored() + { + await Verifier.VerifyAsync(""" + using System; + + namespace MyPlugin; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class CheatEnginePluginAttribute(string name) : Attribute + { + public string Name { get; } = name; + } + + [CheatEnginePlugin("Demo")] + public abstract class DemoPlugin + { + } + """); + } + + [Fact] + public async Task Partial_class_reports_once_on_the_part_that_carries_the_attribute() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", """ + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public partial class {|#0:DemoPlugin|} + { + } + """), + ("Plugin.Lifecycle.cs", """ + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + public abstract partial class DemoPlugin : CheatEnginePlugin + { + private DemoPlugin() { } + } + """) + ], + Problem(0, "DemoPlugin", PluginShapeIssues.Abstract), + Problem(0, "DemoPlugin", PluginShapeIssues.InaccessibleParameterlessConstructor)); + } + + [Fact] + public async Task Class_in_generated_code_is_not_analysed() + { + await Verifier.VerifyAsync(""" + // + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public abstract class DemoPlugin + { + } + """); + } + + [Fact] + public async Task Attribute_on_a_struct_is_left_to_the_compiler() + { + await Verifier.VerifyAsync(""" + using CheatEngine.SDK.Annotations.Plugin; + + namespace MyPlugin; + + [{|CS0592:CheatEnginePlugin|}("Demo")] + public struct DemoPlugin + { + } + """); + } + + [Fact] + public async Task Project_without_a_cheatengine_sdk_reference_is_not_analysed() + { + await Verifier.VerifyWithoutCheatEngineSdkAsync(""" + using System; + + namespace CESDK.Lookalike + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class CheatEnginePluginAttribute(string name) : Attribute + { + public string Name { get; } = name; + } + + [CheatEnginePlugin("Demo")] + public abstract class DemoPlugin + { + } + } + """); + } + + private static DiagnosticResult Problem(int location, string className, PluginShapeIssues problem) + { + return Verifier.Diagnostic(DiagnosticDescriptors.InvalidPluginClass) + .WithLocation(location) + .WithArguments(className, PluginClassProblemText.Describe(problem)); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginShapeParityTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginShapeParityTests.cs index 65d985dc..68dded16 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginShapeParityTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/PluginShapeParityTests.cs @@ -1,8 +1,10 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.Plugin; using CheatEngine.SDK.Analyzers.Tests.Infrastructure; using CheatEngine.SDK.SourceGenerators.EntryPoint; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -36,237 +38,256 @@ namespace CheatEngine.SDK.Analyzers.Tests.Plugin; /// public sealed class PluginShapeParityTests { - private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; + private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; + + private const string Body = "{ protected override void OnEnable() { } protected override void OnDisable() { } }"; + + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + + public static TheoryData Shapes + { + get + { + TheoryData data = new(); + foreach ((string shape, string declaration, bool expectedValid) in ClassAndConstructorValidShapes()) + { + data.Add(shape, declaration, expectedValid); + } + + foreach ((string shape, string declaration, bool expectedValid) in AdvancedValidShapes()) + { + data.Add(shape, declaration, expectedValid); + } + + foreach ((string shape, string declaration, bool expectedValid) in ClassShapeRejections()) + { + data.Add(shape, declaration, expectedValid); + } + + foreach ((string shape, string declaration, bool expectedValid) in AccessibilityAndBaseRejections()) + { + data.Add(shape, declaration, expectedValid); + } - private const string Body = "{ protected override void OnEnable() { } protected override void OnDisable() { } }"; + foreach ((string shape, string declaration, bool expectedValid) in ConstructorRejections()) + { + data.Add(shape, declaration, expectedValid); + } - private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + foreach ((string shape, string declaration, bool expectedValid) in NameAndEntryPointRejections()) + { + data.Add(shape, declaration, expectedValid); + } - public static TheoryData Shapes - { - get - { - var data = new TheoryData(); - foreach (var (shape, declaration, expectedValid) in ClassAndConstructorValidShapes()) - data.Add(shape, declaration, expectedValid); - foreach (var (shape, declaration, expectedValid) in AdvancedValidShapes()) - data.Add(shape, declaration, expectedValid); - foreach (var (shape, declaration, expectedValid) in ClassShapeRejections()) - data.Add(shape, declaration, expectedValid); - foreach (var (shape, declaration, expectedValid) in AccessibilityAndBaseRejections()) - data.Add(shape, declaration, expectedValid); - foreach (var (shape, declaration, expectedValid) in ConstructorRejections()) - data.Add(shape, declaration, expectedValid); - foreach (var (shape, declaration, expectedValid) in NameAndEntryPointRejections()) - data.Add(shape, declaration, expectedValid); - return data; - } - } + return data; + } + } - // Mirrors ValidShapeTests.ValidShapes, plus the required-members and record/primary-constructor cases. - private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> ClassAndConstructorValidShapes() - { - yield return ("sealed class", $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}", - true); - yield return ("internal class", - $"[CheatEnginePlugin(\"P\")] internal sealed class P : CheatEnginePlugin {Body}", true); - yield return ("unsealed class", $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {Body}", true); - yield return ( - "internal constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ internal P() {{ }} {Body[1..]}", - true); - yield return ( - "protected internal constructor", - $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected internal P() {{ }} {Body[1..]}", - true); - yield return ( - "extra constructors", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P() {{ }} public P(int value) {{ _ = value; }} {Body[1..]}", - true); - yield return ( - "indirect derivation", - $"public abstract class Base : CheatEnginePlugin {Body} [CheatEnginePlugin(\"P\")] public sealed class P : Base {{ }}", - true); - } + // Mirrors ValidShapeTests.ValidShapes, plus the required-members and record/primary-constructor cases. + private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> ClassAndConstructorValidShapes() + { + yield return ("sealed class", $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}", + true); + yield return ("internal class", + $"[CheatEnginePlugin(\"P\")] internal sealed class P : CheatEnginePlugin {Body}", true); + yield return ("unsealed class", $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {Body}", true); + yield return ( + "internal constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ internal P() {{ }} {Body[1..]}", + true); + yield return ( + "protected internal constructor", + $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected internal P() {{ }} {Body[1..]}", + true); + yield return ( + "extra constructors", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P() {{ }} public P(int value) {{ _ = value; }} {Body[1..]}", + true); + yield return ( + "indirect derivation", + $"public abstract class Base : CheatEnginePlugin {Body} [CheatEnginePlugin(\"P\")] public sealed class P : Base {{ }}", + true); + } - private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> AdvancedValidShapes() - { - yield return ( - "primary constructor without parameters", - $"[CheatEnginePlugin(\"P\")] public sealed class P() : CheatEnginePlugin {Body}", true); - yield return ( - "obsolete as a warning", - $"[CheatEnginePlugin(\"P\")] [System.Obsolete(\"Use the new plugin.\")] public sealed class P : CheatEnginePlugin {Body}", - true); - yield return ( - "required members set by the constructor", - """ - [CheatEnginePlugin("P")] - public sealed class P : CheatEnginePlugin - { - [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public P() => Value = 1; + private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> AdvancedValidShapes() + { + yield return ( + "primary constructor without parameters", + $"[CheatEnginePlugin(\"P\")] public sealed class P() : CheatEnginePlugin {Body}", true); + yield return ( + "obsolete as a warning", + $"[CheatEnginePlugin(\"P\")] [System.Obsolete(\"Use the new plugin.\")] public sealed class P : CheatEnginePlugin {Body}", + true); + yield return ( + "required members set by the constructor", + """ + [CheatEnginePlugin("P")] + public sealed class P : CheatEnginePlugin + { + [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + public P() => Value = 1; - public required int Value { get; init; } + public required int Value { get; init; } - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - true); - } + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + true); + } - // Invalid shapes (mirrors NoOutputTests.InvalidShapes, plus the predicate's RequiredMembers and ObsoleteError - // checks), split by category so every helper stays comfortably under MA0051's line limit. - private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> ClassShapeRejections() - { - yield return ("abstract class", - $"[CheatEnginePlugin(\"P\")] public abstract class P : CheatEnginePlugin {Body}", false); - yield return ("static class", "[CheatEnginePlugin(\"P\")] public static class P { }", false); - yield return ("generic class", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}", false); - yield return ( - "nested in a generic class", - $"public static class Outer {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }}", - false); - } + // Invalid shapes (mirrors NoOutputTests.InvalidShapes, plus the predicate's RequiredMembers and ObsoleteError + // checks), split by category so every helper stays comfortably under MA0051's line limit. + private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> ClassShapeRejections() + { + yield return ("abstract class", + $"[CheatEnginePlugin(\"P\")] public abstract class P : CheatEnginePlugin {Body}", false); + yield return ("static class", "[CheatEnginePlugin(\"P\")] public static class P { }", false); + yield return ("generic class", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}", false); + yield return ( + "nested in a generic class", + $"public static class Outer {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }}", + false); + } - private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> AccessibilityAndBaseRejections() - { - yield return ("not derived from the plugin base", "[CheatEnginePlugin(\"P\")] public sealed class P { }", - false); - yield return ( - "derived from a look-alike base", - "namespace Other.Hosting { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : Other.Hosting.CheatEnginePlugin { }", - false); - yield return ( - "private nested class", - $"public static class Outer {{ [CheatEnginePlugin(\"P\")] private sealed class P : CheatEnginePlugin {Body} }}", - false); - yield return ( - "protected nested class", - $"public class Outer {{ [CheatEnginePlugin(\"P\")] protected sealed class P : CheatEnginePlugin {Body} }}", - false); - yield return ( - "nested in a private class", - $"public static class Outer {{ private static class Hidden {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}", - false); - yield return ("file-local class", $"[CheatEnginePlugin(\"P\")] file sealed class P : CheatEnginePlugin {Body}", - false); - } + private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> AccessibilityAndBaseRejections() + { + yield return ("not derived from the plugin base", "[CheatEnginePlugin(\"P\")] public sealed class P { }", + false); + yield return ( + "derived from a look-alike base", + "namespace Other.Hosting { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : Other.Hosting.CheatEnginePlugin { }", + false); + yield return ( + "private nested class", + $"public static class Outer {{ [CheatEnginePlugin(\"P\")] private sealed class P : CheatEnginePlugin {Body} }}", + false); + yield return ( + "protected nested class", + $"public class Outer {{ [CheatEnginePlugin(\"P\")] protected sealed class P : CheatEnginePlugin {Body} }}", + false); + yield return ( + "nested in a private class", + $"public static class Outer {{ private static class Hidden {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}", + false); + yield return ("file-local class", $"[CheatEnginePlugin(\"P\")] file sealed class P : CheatEnginePlugin {Body}", + false); + } - private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> ConstructorRejections() - { - yield return ( - "only optional parameters", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value = 0) {{ _ = value; }} {Body[1..]}", - false); - yield return ( - "trailing params constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(params int[] xs) {{ _ = xs; }} {Body[1..]}", - false); - yield return ( - "no parameterless constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value) {{ _ = value; }} {Body[1..]}", - false); - yield return ( - "private constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ private P() {{ }} {Body[1..]}", - false); - yield return ( - "protected constructor", - $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected P() {{ }} {Body[1..]}", false); - yield return ( - "required member without a constructor that sets it", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public required int Value {{ get; init; }} {Body[1..]}", - false); - yield return ( - "obsolete as error on the class", - $"[System.Obsolete(\"no\", true)] [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}", - false); - yield return ( - "obsolete as error on the constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Obsolete(\"no\", true)] public P() {{ }} {Body[1..]}", - false); - } + private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> ConstructorRejections() + { + yield return ( + "only optional parameters", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value = 0) {{ _ = value; }} {Body[1..]}", + false); + yield return ( + "trailing params constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(params int[] xs) {{ _ = xs; }} {Body[1..]}", + false); + yield return ( + "no parameterless constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value) {{ _ = value; }} {Body[1..]}", + false); + yield return ( + "private constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ private P() {{ }} {Body[1..]}", + false); + yield return ( + "protected constructor", + $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected P() {{ }} {Body[1..]}", false); + yield return ( + "required member without a constructor that sets it", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public required int Value {{ get; init; }} {Body[1..]}", + false); + yield return ( + "obsolete as error on the class", + $"[System.Obsolete(\"no\", true)] [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}", + false); + yield return ( + "obsolete as error on the constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Obsolete(\"no\", true)] public P() {{ }} {Body[1..]}", + false); + } - private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> NameAndEntryPointRejections() - { - yield return ("empty name", $"[CheatEnginePlugin(\"\")] public sealed class P : CheatEnginePlugin {Body}", - false); - yield return ( - "white-space name", - $"[CheatEnginePlugin(\" \\t\\u00A0\")] public sealed class P : CheatEnginePlugin {Body}", false); - yield return ("null name", $"[CheatEnginePlugin(null!)] public sealed class P : CheatEnginePlugin {Body}", - false); - yield return ("missing name argument", $"[CheatEnginePlugin] public sealed class P : CheatEnginePlugin {Body}", - false); + private static IEnumerable<(string Shape, string Declaration, bool ExpectedValid)> NameAndEntryPointRejections() + { + yield return ("empty name", $"[CheatEnginePlugin(\"\")] public sealed class P : CheatEnginePlugin {Body}", + false); + yield return ( + "white-space name", + $"[CheatEnginePlugin(\" \\t\\u00A0\")] public sealed class P : CheatEnginePlugin {Body}", false); + yield return ("null name", $"[CheatEnginePlugin(null!)] public sealed class P : CheatEnginePlugin {Body}", + false); + yield return ("missing name argument", $"[CheatEnginePlugin] public sealed class P : CheatEnginePlugin {Body}", + false); - // Not "struct": the compiler itself rejects the attribute there (CS0592, AttributeTargets.Class), left - // alone on purpose by both sides (see analyzers/docs/CESDK0001.md) - not part of the shared - // predicate's contract, so not part of this parity matrix either. - yield return ("record class", "[CheatEnginePlugin(\"P\")] public sealed record P;", false); - yield return ( - "named like the entry point", - $"namespace CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class CESDK : CheatEnginePlugin {Body} }}", - false); - yield return ( - "nested in a type named like the entry point", - $"namespace CESDK {{ public static class CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}", - false); - } + // Not "struct": the compiler itself rejects the attribute there (CS0592, AttributeTargets.Class), left + // alone on purpose by both sides (see analyzers/docs/CESDK0001.md) - not part of the shared + // predicate's contract, so not part of this parity matrix either. + yield return ("record class", "[CheatEnginePlugin(\"P\")] public sealed record P;", false); + yield return ( + "named like the entry point", + $"namespace CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class CESDK : CheatEnginePlugin {Body} }}", + false); + yield return ( + "nested in a type named like the entry point", + $"namespace CESDK {{ public static class CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}", + false); + } - [Theory] - [MemberData(nameof(Shapes))] - public async Task Generator_and_analyzer_agree_on_every_shape(string shape, string declaration, bool expectedValid) - { - var compilation = CreateCompilation(Usings + declaration); + [Theory] + [MemberData(nameof(Shapes))] + public async Task Generator_and_analyzer_agree_on_every_shape(string shape, string declaration, bool expectedValid) + { + CSharpCompilation compilation = CreateCompilation(Usings + declaration); - var generatorEmits = RunGenerator(compilation); - var analyzerReportsInvalidPluginClass = await AnalyzerReportsInvalidPluginClassAsync(compilation); + bool generatorEmits = RunGenerator(compilation); + bool analyzerReportsInvalidPluginClass = await AnalyzerReportsInvalidPluginClassAsync(compilation); - Assert.True( - generatorEmits == expectedValid, - $"'{shape}': the generator {(generatorEmits ? "emitted" : "stayed silent")}, expected {(expectedValid ? "an entry point" : "silence")}."); - Assert.True( - analyzerReportsInvalidPluginClass != expectedValid, - $"'{shape}': CESDK0001 {(analyzerReportsInvalidPluginClass ? "reported" : "stayed silent")}, expected it to {(expectedValid ? "stay silent" : "report")}."); - } + Assert.True( + generatorEmits == expectedValid, + $"'{shape}': the generator {(generatorEmits ? "emitted" : "stayed silent")}, expected {(expectedValid ? "an entry point" : "silence")}."); + Assert.True( + analyzerReportsInvalidPluginClass != expectedValid, + $"'{shape}': CESDK0001 {(analyzerReportsInvalidPluginClass ? "reported" : "stayed silent")}, expected it to {(expectedValid ? "stay silent" : "report")}."); + } - private static CSharpCompilation CreateCompilation(string pluginSource) - { - return CSharpCompilation.Create( - "PluginShapeParityAssembly", - [ - CSharpSyntaxTree.ParseText(TestText.Normalize(pluginSource), ParseOptions, "Plugin.cs", - cancellationToken: TestContext.Current.CancellationToken), - ], - LocalFrameworkReferences.References.AddRange(ContractStubs.References), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable)); - } + private static CSharpCompilation CreateCompilation(string pluginSource) + { + return CSharpCompilation.Create( + "PluginShapeParityAssembly", + [ + CSharpSyntaxTree.ParseText(TestText.Normalize(pluginSource), ParseOptions, "Plugin.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + LocalFrameworkReferences.References.AddRange(ContractStubs.References), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + } - // Same driver shape as CheatEngine.SDK.SourceGenerators.EntryPoint.Tests' GeneratorRun/RoslynFixture: "emits" means at - // least one generated source, which for this generator only ever happens for exactly one valid plugin class. - private static bool RunGenerator(CSharpCompilation compilation) - { - GeneratorDriver driver = CSharpGeneratorDriver.Create([new EntryPointGenerator().AsSourceGenerator()], - [], - ParseOptions, - DirectPackageAnalyzerConfigOptions.Enabled, - new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); - driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _, - TestContext.Current.CancellationToken); - return !driver.GetRunResult().Results.Single().GeneratedSources.IsEmpty; - } + // Same driver shape as CheatEngine.SDK.SourceGenerators.EntryPoint.Tests' GeneratorRun/RoslynFixture: "emits" means at + // least one generated source, which for this generator only ever happens for exactly one valid plugin class. + private static bool RunGenerator(CSharpCompilation compilation) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create([new EntryPointGenerator().AsSourceGenerator()], + [], + ParseOptions, + DirectPackageAnalyzerConfigOptions.Enabled, + new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, true)); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _, + TestContext.Current.CancellationToken); + return !driver.GetRunResult().Results.Single().GeneratedSources.IsEmpty; + } - private static async Task AnalyzerReportsInvalidPluginClassAsync(CSharpCompilation compilation) - { - AnalyzerOptions options = new(ImmutableArray.Empty, DirectPackageAnalyzerConfigOptions.Enabled); - var withAnalyzers = compilation.WithAnalyzers([new CheatEnginePluginAnalyzer()], options); - var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken) - .ConfigureAwait(false); - return diagnostics.Any(static diagnostic => - string.Equals(diagnostic.Id, DiagnosticIds.InvalidPluginClass, StringComparison.Ordinal)); - } + private static async Task AnalyzerReportsInvalidPluginClassAsync(CSharpCompilation compilation) + { + AnalyzerOptions options = new(ImmutableArray.Empty, DirectPackageAnalyzerConfigOptions.Enabled); + CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers([new CheatEnginePluginAnalyzer()], options); + ImmutableArray diagnostics = await withAnalyzers + .GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(false); + return diagnostics.Any(static diagnostic => + string.Equals(diagnostic.Id, DiagnosticIds.InvalidPluginClass, StringComparison.Ordinal)); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ReservedNamespaceTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ReservedNamespaceTests.cs index 4bff061b..69280a11 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ReservedNamespaceTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ReservedNamespaceTests.cs @@ -1,7 +1,9 @@ using CheatEngine.SDK.Analyzers.Diagnostics; + using Microsoft.CodeAnalysis.Testing; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.AnalyzerVerifier< - CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; + CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer>; namespace CheatEngine.SDK.Analyzers.Tests.Plugin; @@ -11,377 +13,377 @@ namespace CheatEngine.SDK.Analyzers.Tests.Plugin; /// public sealed class ReservedNamespaceTests { - private const string PluginInMyPlugin = """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace MyPlugin; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """; - - [Fact] - public async Task File_scoped_namespace_under_cesdk_reports_on_the_name() - { - await Verifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace {|#0:CESDK.MyPlugin|}; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """, - Reserved(0, "CESDK.MyPlugin")); - } - - [Fact] - public async Task Namespace_exactly_cesdk_reports() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginInMyPlugin), - ("Helpers.cs", """ - namespace {|#0:CESDK|} - { - internal static class Helpers - { - } - } - """) - ], - Reserved(0, "CESDK")); - } - - [Fact] - public async Task Nested_declarations_report_the_outermost_one_only() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginInMyPlugin), - ("Helpers.cs", """ - namespace {|#0:CESDK.Tools|} - { - namespace Deep.Deeper - { - internal static class Helpers - { - } - } - } - """) - ], - Reserved(0, "CESDK.Tools")); - } - - [Fact] - public async Task Every_declaration_in_every_file_reports() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginInMyPlugin), - ("First.cs", """ - namespace {|#0:CESDK.Tools|} - { - internal static class First - { - } - } - - namespace {|#1:CESDK.Tools|} - { - internal static class Second - { - } - } - """), - ("Second.cs", """ - namespace {|#2:CESDK.Other.Deep|}; - - internal static class Third - { - } - """) - ], - Reserved(0, "CESDK.Tools"), - Reserved(1, "CESDK.Tools"), - Reserved(2, "CESDK.Other.Deep")); - } - - [Fact] - public async Task Namespaces_that_only_look_like_cesdk_report_nothing() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginInMyPlugin), - ("Helpers.cs", """ - namespace CESDKPlugin - { - internal static class A - { - } - } - - namespace Cesdk.Tools - { - internal static class B - { - } - } - - namespace MyPlugin.CESDK - { - internal static class C - { - } - } - - namespace MyPlugin - { - namespace CESDK.Tools - { - internal static class D - { - } - } - } - """) - ]); - } - - [Fact] - public async Task Namespaces_of_the_sdk_itself_are_not_reserved() - { - // Only the namespace of the host-mandated CESDK.CESDK type is reserved. The SDK's own root, CheatEngine.SDK, is not: - // its simple name 'CheatEngine' never binds to the generated bootstrap type. - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginInMyPlugin), - ("Helpers.cs", """ - namespace CheatEngine.SDK.Extensions - { - internal static class Helpers - { - } - } - """) - ]); - } - - [Fact] - public async Task Assembly_without_a_plugin_class_reports_nothing() - { - // A class library is not a plugin assembly: it declares a namespace under CESDK and references the contract - // types, but no class carries [CheatEnginePlugin], so the rules that are about plugins stay silent. - await Verifier.VerifyAsync(""" - namespace CESDK.Tools - { - public abstract class Helper : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - } - } - """); - } - - [Fact] - public async Task Generated_bootstrap_namespace_reports_nothing() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", PluginInMyPlugin), - ("CheatEngine.SDK.EntryPoint.g.cs", """ - // - #nullable enable - namespace CESDK - { - internal static class CESDK - { - public static int CEPluginInitialize(global::System.IntPtr args, int size) => 1; - } - } - """) - ]); - } - - [Fact] - public async Task Simple_name_cesdk_binds_to_the_bootstrap_type_inside_the_namespace() - { - // The reason for the rule, as the compiler sees it: with the bootstrap type present, 'CESDK.Tools' inside - // namespace CESDK.MyPlugin is looked up in the TYPE CESDK.CESDK, not among the namespaces this assembly declares - // under CESDK, and no longer compiles. Both declarations sit under CESDK, so the rule reports both. - await Verifier.VerifyAsync( - [ - ("Plugin.cs", """ - namespace {|#0:CESDK.MyPlugin|} - { - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - - public static CESDK.{|CS0426:Tools|}.Helper? Other => null; - } - } - """), - ("Helper.cs", """ - namespace {|#1:CESDK.Tools|} - { - public sealed class Helper - { - } - } - """), - ("CheatEngine.SDK.EntryPoint.g.cs", """ - // - #nullable enable - namespace CESDK - { - internal static class CESDK - { - } - } - """) - ], - Reserved(0, "CESDK.MyPlugin"), - Reserved(1, "CESDK.Tools")); - } - - [Fact] - public async Task Sdk_namespaces_still_resolve_inside_the_namespace() - { - // What the rule does not have to protect any more: the SDK's own namespaces start with 'CheatEngine', so a fully - // qualified SDK name inside namespace CESDK.MyPlugin compiles even with the bootstrap type present. - await Verifier.VerifyAsync( - [ - ("Plugin.cs", """ - namespace {|#0:CESDK.MyPlugin|} - { - using CheatEngine.SDK.Annotations.Plugin; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """), - ("CheatEngine.SDK.EntryPoint.g.cs", """ - // - #nullable enable - namespace CESDK - { - internal static class CESDK - { - } - } - """) - ], - Reserved(0, "CESDK.MyPlugin")); - } - - [Fact] - public async Task Using_directive_inside_the_namespace_binds_to_the_bootstrap_type_too() - { - await Verifier.VerifyAsync( - [ - ("Plugin.cs", """ - namespace {|#0:CESDK.MyPlugin|} - { - using CESDK.{|CS0426:Tools|}; - - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - } - """), - ("Helper.cs", """ - namespace {|#1:CESDK.Tools|} - { - public sealed class Helper - { - } - } - """), - ("CheatEngine.SDK.EntryPoint.g.cs", """ - // - #nullable enable - namespace CESDK - { - internal static class CESDK - { - } - } - """) - ], - Reserved(0, "CESDK.MyPlugin"), - Reserved(1, "CESDK.Tools")); - } - - [Fact] - public async Task Usings_above_the_namespace_and_global_qualification_keep_compiling() - { - // Why the breakage looks random to a plugin author: this variant of the same code compiles. - await Verifier.VerifyAsync( - [ - ("Plugin.cs", """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - using CESDK.Tools; - - namespace {|#0:CESDK.MyPlugin|}; - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - - public static Helper? ViaUsing => null; - - public static global::CESDK.Tools.Helper? Qualified => null; - } - """), - ("Helper.cs", """ - namespace {|#1:CESDK.Tools|} - { - public sealed class Helper - { - } - } - """), - ("CheatEngine.SDK.EntryPoint.g.cs", """ - // - #nullable enable - namespace CESDK - { - internal static class CESDK - { - } - } - """) - ], - Reserved(0, "CESDK.MyPlugin"), - Reserved(1, "CESDK.Tools")); - } - - private static DiagnosticResult Reserved(int location, string namespaceName) - { - return Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(location) - .WithArguments(namespaceName); - } + private const string PluginInMyPlugin = """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace MyPlugin; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """; + + [Fact] + public async Task File_scoped_namespace_under_cesdk_reports_on_the_name() + { + await Verifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace {|#0:CESDK.MyPlugin|}; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """, + Reserved(0, "CESDK.MyPlugin")); + } + + [Fact] + public async Task Namespace_exactly_cesdk_reports() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginInMyPlugin), + ("Helpers.cs", """ + namespace {|#0:CESDK|} + { + internal static class Helpers + { + } + } + """) + ], + Reserved(0, "CESDK")); + } + + [Fact] + public async Task Nested_declarations_report_the_outermost_one_only() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginInMyPlugin), + ("Helpers.cs", """ + namespace {|#0:CESDK.Tools|} + { + namespace Deep.Deeper + { + internal static class Helpers + { + } + } + } + """) + ], + Reserved(0, "CESDK.Tools")); + } + + [Fact] + public async Task Every_declaration_in_every_file_reports() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginInMyPlugin), + ("First.cs", """ + namespace {|#0:CESDK.Tools|} + { + internal static class First + { + } + } + + namespace {|#1:CESDK.Tools|} + { + internal static class Second + { + } + } + """), + ("Second.cs", """ + namespace {|#2:CESDK.Other.Deep|}; + + internal static class Third + { + } + """) + ], + Reserved(0, "CESDK.Tools"), + Reserved(1, "CESDK.Tools"), + Reserved(2, "CESDK.Other.Deep")); + } + + [Fact] + public async Task Namespaces_that_only_look_like_cesdk_report_nothing() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginInMyPlugin), + ("Helpers.cs", """ + namespace CESDKPlugin + { + internal static class A + { + } + } + + namespace Cesdk.Tools + { + internal static class B + { + } + } + + namespace MyPlugin.CESDK + { + internal static class C + { + } + } + + namespace MyPlugin + { + namespace CESDK.Tools + { + internal static class D + { + } + } + } + """) + ]); + } + + [Fact] + public async Task Namespaces_of_the_sdk_itself_are_not_reserved() + { + // Only the namespace of the host-mandated CESDK.CESDK type is reserved. The SDK's own root, CheatEngine.SDK, is not: + // its simple name 'CheatEngine' never binds to the generated bootstrap type. + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginInMyPlugin), + ("Helpers.cs", """ + namespace CheatEngine.SDK.Extensions + { + internal static class Helpers + { + } + } + """) + ]); + } + + [Fact] + public async Task Assembly_without_a_plugin_class_reports_nothing() + { + // A class library is not a plugin assembly: it declares a namespace under CESDK and references the contract + // types, but no class carries [CheatEnginePlugin], so the rules that are about plugins stay silent. + await Verifier.VerifyAsync(""" + namespace CESDK.Tools + { + public abstract class Helper : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + } + } + """); + } + + [Fact] + public async Task Generated_bootstrap_namespace_reports_nothing() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", PluginInMyPlugin), + ("CheatEngine.SDK.EntryPoint.g.cs", """ + // + #nullable enable + namespace CESDK + { + internal static class CESDK + { + public static int CEPluginInitialize(global::System.IntPtr args, int size) => 1; + } + } + """) + ]); + } + + [Fact] + public async Task Simple_name_cesdk_binds_to_the_bootstrap_type_inside_the_namespace() + { + // The reason for the rule, as the compiler sees it: with the bootstrap type present, 'CESDK.Tools' inside + // namespace CESDK.MyPlugin is looked up in the TYPE CESDK.CESDK, not among the namespaces this assembly declares + // under CESDK, and no longer compiles. Both declarations sit under CESDK, so the rule reports both. + await Verifier.VerifyAsync( + [ + ("Plugin.cs", """ + namespace {|#0:CESDK.MyPlugin|} + { + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + + public static CESDK.{|CS0426:Tools|}.Helper? Other => null; + } + } + """), + ("Helper.cs", """ + namespace {|#1:CESDK.Tools|} + { + public sealed class Helper + { + } + } + """), + ("CheatEngine.SDK.EntryPoint.g.cs", """ + // + #nullable enable + namespace CESDK + { + internal static class CESDK + { + } + } + """) + ], + Reserved(0, "CESDK.MyPlugin"), + Reserved(1, "CESDK.Tools")); + } + + [Fact] + public async Task Sdk_namespaces_still_resolve_inside_the_namespace() + { + // What the rule does not have to protect any more: the SDK's own namespaces start with 'CheatEngine', so a fully + // qualified SDK name inside namespace CESDK.MyPlugin compiles even with the bootstrap type present. + await Verifier.VerifyAsync( + [ + ("Plugin.cs", """ + namespace {|#0:CESDK.MyPlugin|} + { + using CheatEngine.SDK.Annotations.Plugin; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """), + ("CheatEngine.SDK.EntryPoint.g.cs", """ + // + #nullable enable + namespace CESDK + { + internal static class CESDK + { + } + } + """) + ], + Reserved(0, "CESDK.MyPlugin")); + } + + [Fact] + public async Task Using_directive_inside_the_namespace_binds_to_the_bootstrap_type_too() + { + await Verifier.VerifyAsync( + [ + ("Plugin.cs", """ + namespace {|#0:CESDK.MyPlugin|} + { + using CESDK.{|CS0426:Tools|}; + + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + } + """), + ("Helper.cs", """ + namespace {|#1:CESDK.Tools|} + { + public sealed class Helper + { + } + } + """), + ("CheatEngine.SDK.EntryPoint.g.cs", """ + // + #nullable enable + namespace CESDK + { + internal static class CESDK + { + } + } + """) + ], + Reserved(0, "CESDK.MyPlugin"), + Reserved(1, "CESDK.Tools")); + } + + [Fact] + public async Task Usings_above_the_namespace_and_global_qualification_keep_compiling() + { + // Why the breakage looks random to a plugin author: this variant of the same code compiles. + await Verifier.VerifyAsync( + [ + ("Plugin.cs", """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + using CESDK.Tools; + + namespace {|#0:CESDK.MyPlugin|}; + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + + public static Helper? ViaUsing => null; + + public static global::CESDK.Tools.Helper? Qualified => null; + } + """), + ("Helper.cs", """ + namespace {|#1:CESDK.Tools|} + { + public sealed class Helper + { + } + } + """), + ("CheatEngine.SDK.EntryPoint.g.cs", """ + // + #nullable enable + namespace CESDK + { + internal static class CESDK + { + } + } + """) + ], + Reserved(0, "CESDK.MyPlugin"), + Reserved(1, "CESDK.Tools")); + } + + private static DiagnosticResult Reserved(int location, string namespaceName) + { + return Verifier.Diagnostic(DiagnosticDescriptors.ReservedNamespace).WithLocation(location) + .WithArguments(namespaceName); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/PluginLifecycleAndOwnershipAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/PluginLifecycleAndOwnershipAnalyzerTests.cs index 86f86b1b..0b5c00fc 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/PluginLifecycleAndOwnershipAnalyzerTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/PluginLifecycleAndOwnershipAnalyzerTests.cs @@ -6,124 +6,124 @@ namespace CheatEngine.SDK.Analyzers.Tests.Usage; /// Direct semantic tests for the lifecycle and borrowed-ownership rules CESDK1001, CESDK1003 and CESDK1005. public sealed class PluginLifecycleAndOwnershipAnalyzerTests { - [Fact] - public async Task Enabled_only_calls_from_a_plugin_constructor_and_field_initializer_report_CESDK1001() - { - await AnalyzerVerifier.VerifyAsync( - """ - using CheatEngine.SDK.Annotations.Lifetime; - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - using System.Threading.Tasks; - - namespace Demo; - - [RequiresPluginEnabled] - internal static class EnabledApi - { - public static int Value => 1; - public static void Connect() { } - } - - internal sealed class EnabledClient - { - [RequiresPluginEnabled] - public EnabledClient() { } - } - - [CheatEnginePlugin("Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - private readonly int _value = {|CESDK1001:EnabledApi.Value|}; - - public int Initializer { get; } = {|CESDK1001:EnabledApi.Value|}; - - static DemoPlugin() - { - var value = {|CESDK1001:EnabledApi.Value|}; - } - - public DemoPlugin() - { - {|CESDK1001:EnabledApi.Connect()|}; - {|CESDK1001:new EnabledClient()|}; - } - - protected override async void {|CESDK1005:OnEnable|}() - { - await Task.Yield(); - EnabledApi.Connect(); - } - - protected override async void {|CESDK1005:OnDisable|}() - { - await Task.Yield(); - } - } - """); - } - - [Fact] - public async Task Directly_borrowed_parameters_properties_and_returns_cannot_be_disposed() - { - await AnalyzerVerifier.VerifyAsync( - """ - using System; - using System.Threading.Tasks; - using CheatEngine.SDK.Annotations.Lifetime; - - namespace Demo; - - internal sealed class Borrowed : IDisposable, IAsyncDisposable - { - public void Dispose() { } - public ValueTask DisposeAsync() => default; - } - - internal static class BorrowedSource - { - [return: CEOwned] - public static Borrowed Create() => new(); - - [CEOwned] - public static Borrowed Current => new(); - - public static void Consume([CEOwned] Borrowed value) - { - {|CESDK1003:Create().Dispose()|}; - {|CESDK1003:Current.Dispose()|}; - {|CESDK1003:value.DisposeAsync()|}; - } - } - """); - } - - [Fact] - public async Task Borrowed_value_stored_in_a_local_is_not_guessed_to_be_owned_or_borrowed() - { - await AnalyzerVerifier.VerifyAsync( - """ - using System; - using CheatEngine.SDK.Annotations.Lifetime; - - namespace Demo; - - internal sealed class Borrowed : IDisposable - { - public void Dispose() { } - } - - internal static class BorrowedSource - { - [return: CEOwned] - public static Borrowed Create() => new(); - - public static void DisposeAfterExplicitTransferBoundary() - { - var local = Create(); - local.Dispose(); - } - } - """); - } + [Fact] + public async Task Enabled_only_calls_from_a_plugin_constructor_and_field_initializer_report_CESDK1001() + { + await AnalyzerVerifier.VerifyAsync( + """ + using CheatEngine.SDK.Annotations.Lifetime; + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + using System.Threading.Tasks; + + namespace Demo; + + [RequiresPluginEnabled] + internal static class EnabledApi + { + public static int Value => 1; + public static void Connect() { } + } + + internal sealed class EnabledClient + { + [RequiresPluginEnabled] + public EnabledClient() { } + } + + [CheatEnginePlugin("Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + private readonly int _value = {|CESDK1001:EnabledApi.Value|}; + + public int Initializer { get; } = {|CESDK1001:EnabledApi.Value|}; + + static DemoPlugin() + { + var value = {|CESDK1001:EnabledApi.Value|}; + } + + public DemoPlugin() + { + {|CESDK1001:EnabledApi.Connect()|}; + {|CESDK1001:new EnabledClient()|}; + } + + protected override async void {|CESDK1005:OnEnable|}() + { + await Task.Yield(); + EnabledApi.Connect(); + } + + protected override async void {|CESDK1005:OnDisable|}() + { + await Task.Yield(); + } + } + """); + } + + [Fact] + public async Task Directly_borrowed_parameters_properties_and_returns_cannot_be_disposed() + { + await AnalyzerVerifier.VerifyAsync( + """ + using System; + using System.Threading.Tasks; + using CheatEngine.SDK.Annotations.Lifetime; + + namespace Demo; + + internal sealed class Borrowed : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + internal static class BorrowedSource + { + [return: CEOwned] + public static Borrowed Create() => new(); + + [CEOwned] + public static Borrowed Current => new(); + + public static void Consume([CEOwned] Borrowed value) + { + {|CESDK1003:Create().Dispose()|}; + {|CESDK1003:Current.Dispose()|}; + {|CESDK1003:value.DisposeAsync()|}; + } + } + """); + } + + [Fact] + public async Task Borrowed_value_stored_in_a_local_is_not_guessed_to_be_owned_or_borrowed() + { + await AnalyzerVerifier.VerifyAsync( + """ + using System; + using CheatEngine.SDK.Annotations.Lifetime; + + namespace Demo; + + internal sealed class Borrowed : IDisposable + { + public void Dispose() { } + } + + internal static class BorrowedSource + { + [return: CEOwned] + public static Borrowed Create() => new(); + + public static void DisposeAfterExplicitTransferBoundary() + { + var local = Create(); + local.Dispose(); + } + } + """); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardCodeFixTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardCodeFixTests.cs index f5532ddd..0389d5c1 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardCodeFixTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardCodeFixTests.cs @@ -1,7 +1,8 @@ using CheatEngine.SDK.Analyzers.CodeFixes.Usage; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.CodeFixVerifier< - CheatEngine.SDK.Analyzers.Usage.UnmanagedCallersOnlyGuardAnalyzer, - CheatEngine.SDK.Analyzers.CodeFixes.Usage.UnmanagedCallersOnlyGuardCodeFixProvider>; + CheatEngine.SDK.Analyzers.Usage.UnmanagedCallersOnlyGuardAnalyzer, + CheatEngine.SDK.Analyzers.CodeFixes.Usage.UnmanagedCallersOnlyGuardCodeFixProvider>; namespace CheatEngine.SDK.Analyzers.Tests.Usage; @@ -11,140 +12,140 @@ namespace CheatEngine.SDK.Analyzers.Tests.Usage; /// public sealed class UnmanagedCallersOnlyGuardCodeFixTests { - [Fact] - public async Task Exact_ce_bootstrap_is_wrapped_with_its_documented_zero_failure_value() - { - await Verifier.VerifyAsync( - """ - using System; - using System.Runtime.InteropServices; - - namespace CESDK; - - internal static class CESDK - { - [UnmanagedCallersOnly] - public static int {|CESDK1004:CEPluginInitialize|}(IntPtr exportedFunctions, int bootstrap) => Work(exportedFunctions); - - private static int Work(IntPtr exportedFunctions) => - exportedFunctions == default ? throw new InvalidOperationException() : 1; - } - """, - """ - using System; - using System.Runtime.InteropServices; - - namespace CESDK; - - internal static class CESDK - { - [UnmanagedCallersOnly] - public static int CEPluginInitialize(IntPtr exportedFunctions, int bootstrap) - { - try - { - return Work(exportedFunctions); - } - catch (Exception) - { - return 0; - } - } - - private static int Work(IntPtr exportedFunctions) => - exportedFunctions == default ? throw new InvalidOperationException() : 1; - } - """, - UnmanagedCallersOnlyGuardCodeFixProvider.WrapEquivalenceKey); - } - - [Fact] - public async Task Arbitrary_integer_callback_gets_no_generic_zero_return_fix() - { - const string source = """ - using System.Runtime.InteropServices; - - namespace MyPlugin; - - internal static class Callbacks - { - [UnmanagedCallersOnly] - private static int {|CESDK1004:OnCall|}(nint state) => state.ToString().Length; - } - """; - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Bootstrap_like_method_with_an_unverified_signature_gets_no_fix() - { - const string source = """ - using System; - using System.Runtime.InteropServices; - - namespace CESDK; - - internal static class CESDK - { - [UnmanagedCallersOnly] - internal static int {|CESDK1004:CEPluginInitialize|}(IntPtr exportedFunctions, int bootstrap) => - exportedFunctions == IntPtr.Zero ? throw new InvalidOperationException() : 1; - } - """; - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Bootstrap_name_outside_the_canonical_namespace_gets_no_fix() - { - const string source = """ - using System; - using System.Runtime.InteropServices; - - namespace OtherPlugin; - - internal static class CESDK - { - [UnmanagedCallersOnly] - public static int {|CESDK1004:CEPluginInitialize|}(IntPtr exportedFunctions, int bootstrap) => - exportedFunctions == IntPtr.Zero ? throw new InvalidOperationException() : 1; - } - """; - - await Verifier.VerifyAsync(source, source); - } - - [Fact] - public async Task Local_unmanaged_callback_inside_the_bootstrap_gets_no_outer_bootstrap_fix() - { - const string source = """ - using System; - using System.Runtime.InteropServices; - - namespace CESDK; - - internal static class CESDK - { - [UnmanagedCallersOnly] - public static int CEPluginInitialize(IntPtr exportedFunctions, int bootstrap) - { - [UnmanagedCallersOnly] - static int {|CESDK1004:Callback|}(nint state) => throw new InvalidOperationException(); - - try - { - return 1; - } - catch (Exception) - { - return 0; - } - } - } - """; - - await Verifier.VerifyAsync(source, source); - } + [Fact] + public async Task Exact_ce_bootstrap_is_wrapped_with_its_documented_zero_failure_value() + { + await Verifier.VerifyAsync( + """ + using System; + using System.Runtime.InteropServices; + + namespace CESDK; + + internal static class CESDK + { + [UnmanagedCallersOnly] + public static int {|CESDK1004:CEPluginInitialize|}(IntPtr exportedFunctions, int bootstrap) => Work(exportedFunctions); + + private static int Work(IntPtr exportedFunctions) => + exportedFunctions == default ? throw new InvalidOperationException() : 1; + } + """, + """ + using System; + using System.Runtime.InteropServices; + + namespace CESDK; + + internal static class CESDK + { + [UnmanagedCallersOnly] + public static int CEPluginInitialize(IntPtr exportedFunctions, int bootstrap) + { + try + { + return Work(exportedFunctions); + } + catch (Exception) + { + return 0; + } + } + + private static int Work(IntPtr exportedFunctions) => + exportedFunctions == default ? throw new InvalidOperationException() : 1; + } + """, + UnmanagedCallersOnlyGuardCodeFixProvider.WrapEquivalenceKey); + } + + [Fact] + public async Task Arbitrary_integer_callback_gets_no_generic_zero_return_fix() + { + const string source = """ + using System.Runtime.InteropServices; + + namespace MyPlugin; + + internal static class Callbacks + { + [UnmanagedCallersOnly] + private static int {|CESDK1004:OnCall|}(nint state) => state.ToString().Length; + } + """; + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Bootstrap_like_method_with_an_unverified_signature_gets_no_fix() + { + const string source = """ + using System; + using System.Runtime.InteropServices; + + namespace CESDK; + + internal static class CESDK + { + [UnmanagedCallersOnly] + internal static int {|CESDK1004:CEPluginInitialize|}(IntPtr exportedFunctions, int bootstrap) => + exportedFunctions == IntPtr.Zero ? throw new InvalidOperationException() : 1; + } + """; + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Bootstrap_name_outside_the_canonical_namespace_gets_no_fix() + { + const string source = """ + using System; + using System.Runtime.InteropServices; + + namespace OtherPlugin; + + internal static class CESDK + { + [UnmanagedCallersOnly] + public static int {|CESDK1004:CEPluginInitialize|}(IntPtr exportedFunctions, int bootstrap) => + exportedFunctions == IntPtr.Zero ? throw new InvalidOperationException() : 1; + } + """; + + await Verifier.VerifyAsync(source, source); + } + + [Fact] + public async Task Local_unmanaged_callback_inside_the_bootstrap_gets_no_outer_bootstrap_fix() + { + const string source = """ + using System; + using System.Runtime.InteropServices; + + namespace CESDK; + + internal static class CESDK + { + [UnmanagedCallersOnly] + public static int CEPluginInitialize(IntPtr exportedFunctions, int bootstrap) + { + [UnmanagedCallersOnly] + static int {|CESDK1004:Callback|}(nint state) => throw new InvalidOperationException(); + + try + { + return 1; + } + catch (Exception) + { + return 0; + } + } + } + """; + + await Verifier.VerifyAsync(source, source); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardTests.cs index 9072ce66..81f5a23a 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/UnmanagedCallersOnlyGuardTests.cs @@ -1,7 +1,9 @@ using CheatEngine.SDK.Analyzers.Diagnostics; + using Microsoft.CodeAnalysis.Testing; + using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.AnalyzerVerifier< - CheatEngine.SDK.Analyzers.Usage.UnmanagedCallersOnlyGuardAnalyzer>; + CheatEngine.SDK.Analyzers.Usage.UnmanagedCallersOnlyGuardAnalyzer>; namespace CheatEngine.SDK.Analyzers.Tests.Usage; @@ -11,586 +13,586 @@ namespace CheatEngine.SDK.Analyzers.Tests.Usage; /// public sealed class UnmanagedCallersOnlyGuardTests { - [Theory] - [InlineData("catch (Exception) { return 0; }")] - [InlineData("catch (System.Exception) { return 0; }")] - [InlineData("catch (Exception exception) { Log(exception); return 0; }")] - [InlineData("catch { return 0; }")] - [InlineData("catch (InvalidOperationException) { return -1; } catch (Exception) { return 0; }")] - [InlineData("catch (Exception) { return 0; } finally { Cleanup(); }")] - [InlineData("catch (Exception exception) { Environment.FailFast(exception.Message); return 0; }")] - [InlineData("catch (Exception) { Environment.Exit(1); return 0; }")] - public async Task Body_made_of_one_try_with_a_catch_all_reports_nothing(string handlers) - { - await Verifier.VerifyAsync(Callbacks($$""" - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - try - { - return Work(state); - } - {{handlers}} - } - """)); - } - - [Fact] - public async Task Trivial_declarations_before_and_trivial_return_after_the_try_report_nothing() - { - await Verifier.VerifyAsync(Callbacks(""" - private const int Failure = -1; - - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - int result = Failure, other; - nint copy = state; - long wide = 0; - object? nothing = null; - Guid id = default; - ; - try - { - other = Work(copy); - result = other + (int)wide + id.GetHashCode() + (nothing?.GetHashCode() ?? 0); - } - catch (Exception) - { - result = Failure; - } - - return result; - } - """)); - } - - [Theory] - [InlineData("private static int OnCall(nint state) => 0;")] - [InlineData("private static int OnCall(nint state) => -1;")] - [InlineData("private static nint OnCall(nint state) => default;")] - [InlineData("private static nint OnCall(nint state) => state;")] - [InlineData("private static long OnCall(int state) => state;")] - [InlineData("private static void OnCall(nint state) { }")] - [InlineData("private static int OnCall(nint state) { return 1; }")] - public async Task Body_that_cannot_throw_reports_nothing(string method) - { - await Verifier.VerifyAsync(Callbacks($$""" - [UnmanagedCallersOnly] - {{method}} - """)); - } - - [Fact] - public async Task Lambdas_and_local_functions_that_throw_inside_the_try_report_nothing() - { - await Verifier.VerifyAsync(Callbacks(""" - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - try - { - Func lambda = () => throw new InvalidOperationException(); - return lambda() + Local(); - - int Local() => throw new NotSupportedException(); - } - catch (Exception) - { - return 0; - } - - static int Unused() => throw new NotSupportedException(); - } - """)); - } - - [Fact] - public async Task Nested_block_and_consecutive_guard_tries_report_nothing() - { - await Verifier.VerifyAsync(Callbacks(""" - [UnmanagedCallersOnly] - private static void OnCall(nint state) - { - { - try - { - Work(state); - } - catch - { - } - } - - try - { - Work(state); - } - catch (Exception exception) - { - Log(exception); - } - } - """)); - } - - [Theory] - [InlineData("unsafe")] - [InlineData("checked")] - [InlineData("unchecked")] - public async Task Unsafe_and_checked_blocks_count_as_nested_blocks(string keyword) - { - await Verifier.VerifyAsync(Callbacks($$""" - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - {{keyword}} - { - try - { - return Work(state); - } - catch (Exception) - { - return 0; - } - } - } - """)); - } - - [Fact] - public async Task Attribute_with_arguments_and_full_name_is_recognised() - { - await Verifier.VerifyAsync( - Callbacks(""" - [System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] - private static int {|#0:OnCall|}(nint state) - { - return Work(state); - } - """), - Unguarded(0, "OnCall")); - } - - [Fact] - public async Task Method_without_the_attribute_is_not_analysed() - { - await Verifier.VerifyAsync(Callbacks(""" - private static int OnCall(nint state) - { - return Work(state); - } - """)); - } - - [Fact] - public async Task Method_in_generated_code_is_not_analysed() - { - await Verifier.VerifyAsync("// \n" + Callbacks(""" - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - return Work(state); - } - """)); - } - - [Fact] - public async Task Project_without_a_cheatengine_sdk_reference_is_not_analysed() - { - await Verifier.VerifyWithoutCheatEngineSdkAsync(Callbacks(""" - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - return Work(state); - } - """)); - } - - [Fact] - public async Task Body_without_a_try_reports() - { - await Verifier.VerifyAsync( - Callbacks(""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - return Work(state); - } - """), - Unguarded(0, "OnCall")); - } - - [Theory] - [InlineData("finally { Cleanup(); }")] - [InlineData("catch (InvalidOperationException) { return 0; }")] - [InlineData("catch (Exception) { throw; }")] - [InlineData("catch (Exception exception) { throw new InvalidOperationException(\"wrapped\", exception); }")] - [InlineData("catch (Exception exception) when (exception is not OutOfMemoryException) { return 0; }")] - [InlineData("catch (Exception) when (true) { return 0; }")] - [InlineData("catch (InvalidOperationException) { throw; } catch (Exception) { return 0; }")] - [InlineData("catch (Exception) { return 0; } finally { if (state == 0) throw new InvalidOperationException(); }")] - [InlineData( - "catch (Exception exception) { return exception.Message.Length > 0 ? 0 : throw new InvalidOperationException(); }")] - [InlineData("catch (Exception) { Func rethrow = () => throw new InvalidOperationException(); return 0; }")] - [InlineData( - "catch (Exception exception) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception).Throw(); return 0; }")] - [InlineData( - "catch (Exception exception) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(exception); return 0; }")] - [InlineData("catch (Exception) { Fail(); return 0; }")] - [InlineData("catch (Exception) { return 0; } finally { Fail(); }")] - public async Task Try_that_does_not_swallow_everything_reports(string handlers) - { - await Verifier.VerifyAsync( - Callbacks($$""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - try - { - return Work(state); - } - {{handlers}} - } - """), - Unguarded(0, "OnCall")); - } - - [Theory] - [InlineData("Cleanup();")] - [InlineData("int before = Work(state);")] - [InlineData("using IDisposable scope = Scope();")] - [InlineData("if (state == 0) { return 0; }")] - [InlineData("int[] buffer = new int[4];")] - [InlineData("string text = $\"{state}\";")] - [InlineData("dynamic late = null; int bound = late;")] - [InlineData("dynamic late = null; string bound = late;")] - [InlineData("(Source, int) from = default; (Target, int) to = from;")] - [InlineData("(Source, int) from = default; (Target, int)? to = from;")] - [InlineData("Source from = default; Target to = from;")] - [InlineData("object boxed = state;")] - [InlineData("object boxed = null; int unboxed = (int)boxed;")] - [InlineData("int? maybe = null; int value = (int)maybe;")] - [InlineData("decimal money = 0; int value = (int)money;")] - [InlineData("long wide = 0; int value = checked((int)wide);")] - [InlineData("int number = 0; int value = checked(-number);")] - [InlineData("int number = 0; Index fromEnd = ^number;")] - [InlineData("int value = Counter;")] - [InlineData("bool ok = false; int value = ok ? Work(state) : 0;")] - public async Task Statement_that_can_throw_in_front_of_the_try_reports(string statement) - { - await Verifier.VerifyAsync( - Callbacks($$""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - {{statement}} - try - { - return Work(state); - } - catch (Exception) - { - return 0; - } - } - """), - Unguarded(0, "OnCall")); - } - - [Fact] - public async Task Return_that_can_throw_after_the_try_reports() - { - await Verifier.VerifyAsync( - Callbacks(""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - try - { - Cleanup(); - } - catch (Exception) - { - } - - return Work(state); - } - """), - Unguarded(0, "OnCall")); - } - - [Fact] - public async Task Body_without_a_try_whose_only_risk_is_a_dynamic_conversion_reports() - { - await Verifier.VerifyAsync( - Callbacks(""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - dynamic late = default; - int bound = late; - return bound; - } - - [UnmanagedCallersOnly] - private static int {|#1:OnReturn|}(nint state) - { - dynamic late = default; - return late; - } - """), - Unguarded(0, "OnCall"), - Unguarded(1, "OnReturn")); - } - - [Theory] - [InlineData("return ok ? 1 : 0;")] - [InlineData("return !ok ? 1 : 0;")] - [InlineData("return -result;")] - [InlineData("return ~result;")] - [InlineData("return (int)wide;")] - [InlineData("return unchecked((int)wide);")] - [InlineData("return (int)status;")] - [InlineData("return (int)(ok ? wide : -wide);")] - public async Task Return_of_a_value_that_cannot_throw_after_the_try_reports_nothing(string tail) - { - await Verifier.VerifyAsync(Callbacks($$""" - [UnmanagedCallersOnly] - private static int OnCall(nint state) - { - bool ok = false; - int result = 0; - long wide = 0; - DayOfWeek status = default; - try - { - result = Work(state); - wide = result; - status = (DayOfWeek)result; - ok = true; - } - catch (Exception) - { - } - - {{tail}} - } - """)); - } - - [Theory] - [InlineData("IntPtr.Zero")] - [InlineData("nint.Zero")] - [InlineData("(nint)narrow")] - [InlineData("unchecked((nint)wide)")] - public async Task Handle_value_that_cannot_throw_reports_nothing(string value) - { - await Verifier.VerifyAsync(Callbacks($$""" - [UnmanagedCallersOnly] - private static nint OnCall(nint state) - { - int narrow = 0; - ulong wide = 0; - nint handle = {{value}}; - try - { - narrow = Work(state); - wide = (ulong)narrow; - handle = narrow; - } - catch (Exception) - { - } - - return {{value}}; - } - """)); - } - - [Fact] - public async Task Built_in_conversions_that_cannot_throw_report_nothing() - { - await Verifier.VerifyAsync(Callbacks(""" - [UnmanagedCallersOnly] - private static unsafe void* OnCall(int* state, int count) - { - long wide = count; - double real = wide; - decimal money = count; - int? maybe = count; - long? maybeWide = count; - long? stillWide = maybe; - Guid? id = default(Guid); - string text = null; - object same = text; - IComparable comparable = text; - (int Left, int Right) named = default; - (int, int) unnamed = named; - void* untyped = state; - try - { - Work((nint)(real + (double)money) + (maybeWide.HasValue ? 1 : 0) + (stillWide.HasValue ? 1 : 0) + (id.HasValue ? 1 : 0)); - Work(same == comparable ? unnamed.Item1 : 0); - } - catch (Exception) - { - return null; - } - - return untyped; - } - """)); - } - - [Theory] - [InlineData("lock (Gate) { try { return Work(state); } catch (Exception) { return 0; } }")] - [InlineData("using (Scope()) { try { return Work(state); } catch (Exception) { return 0; } }")] - [InlineData( - "unsafe { fixed (int* pointer = Numbers) { try { return Work(state) + *pointer; } catch (Exception) { return 0; } } }")] - [InlineData("checked { return Work(state); }")] - [InlineData("unsafe { return Work(state); }")] - public async Task Guard_hidden_inside_another_statement_reports(string body) - { - await Verifier.VerifyAsync( - Callbacks($$""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - {{body}} - } - """), - Unguarded(0, "OnCall")); - } - - [Theory] - [InlineData("private static int {|#0:OnCall|}(nint state) => Work(state);")] - [InlineData("private static void {|#0:OnCall|}(nint state) => Cleanup();")] - [InlineData("private static int {|#0:OnCall|}(nint state) => checked((int)state);")] - [InlineData("private static int {|#0:OnCall|}(nint state) => Shared;")] - public async Task Expression_body_that_can_throw_reports(string method) - { - await Verifier.VerifyAsync( - Callbacks($$""" - [UnmanagedCallersOnly] - {{method}} - """), - Unguarded(0, "OnCall")); - } - - [Fact] - public async Task Local_function_with_the_attribute_is_analysed_on_its_own() - { - await Verifier.VerifyAsync( - Callbacks(""" - private static unsafe nint Register() - { - delegate* unmanaged guarded = &Guarded; - delegate* unmanaged unguarded = &Unguarded; - delegate* unmanaged expression = &Expression; - return (nint)guarded + (nint)unguarded + (nint)expression; - - [UnmanagedCallersOnly] - static int Guarded(nint state) - { - try - { - return Work(state); - } - catch (Exception) - { - return 0; - } - } - - [UnmanagedCallersOnly] - static int {|#0:Unguarded|}(nint state) - { - return Work(state); - } - - [UnmanagedCallersOnly] - static int {|#1:Expression|}(nint state) => Work(state); - } - """), - Unguarded(0, "Unguarded"), - Unguarded(1, "Expression")); - } - - [Fact] - public async Task Unguarded_method_containing_a_guarded_local_function_still_reports() - { - await Verifier.VerifyAsync( - Callbacks(""" - [UnmanagedCallersOnly] - private static int {|#0:OnCall|}(nint state) - { - return Inner(state); - - static int Inner(nint value) - { - try - { - return Work(value); - } - catch (Exception) - { - return 0; - } - } - } - """), - Unguarded(0, "OnCall")); - } - - // A static class with the helpers the bodies above call; every helper may throw as far as the rule knows. - private static string Callbacks(string members) - { - return $$""" - using System; - using System.Runtime.InteropServices; - - namespace MyPlugin; - - internal static class Callbacks - { - private static readonly object Gate = new(); - - private static readonly int[] Numbers = [1]; - - private static readonly int Counter = Work(1); - - private static int Shared { get; set; } - - {{members}} - - private static int Work(nint state) => state == 0 ? throw new InvalidOperationException() : 1; - - private static void Cleanup() { } - - private static void Log(Exception exception) { } - - private static IDisposable Scope() => throw new NotSupportedException(); - - [System.Diagnostics.CodeAnalysis.DoesNotReturn] - private static void Fail() => throw new InvalidOperationException(); - - private struct Source - { - } - - private struct Target - { - public static implicit operator Target(Source source) => throw new InvalidOperationException(); - } - } - """; - } - - private static DiagnosticResult Unguarded(int location, string methodName) - { - return Verifier.Diagnostic(DiagnosticDescriptors.UnguardedUnmanagedCallersOnly).WithLocation(location) - .WithArguments(methodName); - } + [Theory] + [InlineData("catch (Exception) { return 0; }")] + [InlineData("catch (System.Exception) { return 0; }")] + [InlineData("catch (Exception exception) { Log(exception); return 0; }")] + [InlineData("catch { return 0; }")] + [InlineData("catch (InvalidOperationException) { return -1; } catch (Exception) { return 0; }")] + [InlineData("catch (Exception) { return 0; } finally { Cleanup(); }")] + [InlineData("catch (Exception exception) { Environment.FailFast(exception.Message); return 0; }")] + [InlineData("catch (Exception) { Environment.Exit(1); return 0; }")] + public async Task Body_made_of_one_try_with_a_catch_all_reports_nothing(string handlers) + { + await Verifier.VerifyAsync(Callbacks($$""" + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + try + { + return Work(state); + } + {{handlers}} + } + """)); + } + + [Fact] + public async Task Trivial_declarations_before_and_trivial_return_after_the_try_report_nothing() + { + await Verifier.VerifyAsync(Callbacks(""" + private const int Failure = -1; + + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + int result = Failure, other; + nint copy = state; + long wide = 0; + object? nothing = null; + Guid id = default; + ; + try + { + other = Work(copy); + result = other + (int)wide + id.GetHashCode() + (nothing?.GetHashCode() ?? 0); + } + catch (Exception) + { + result = Failure; + } + + return result; + } + """)); + } + + [Theory] + [InlineData("private static int OnCall(nint state) => 0;")] + [InlineData("private static int OnCall(nint state) => -1;")] + [InlineData("private static nint OnCall(nint state) => default;")] + [InlineData("private static nint OnCall(nint state) => state;")] + [InlineData("private static long OnCall(int state) => state;")] + [InlineData("private static void OnCall(nint state) { }")] + [InlineData("private static int OnCall(nint state) { return 1; }")] + public async Task Body_that_cannot_throw_reports_nothing(string method) + { + await Verifier.VerifyAsync(Callbacks($$""" + [UnmanagedCallersOnly] + {{method}} + """)); + } + + [Fact] + public async Task Lambdas_and_local_functions_that_throw_inside_the_try_report_nothing() + { + await Verifier.VerifyAsync(Callbacks(""" + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + try + { + Func lambda = () => throw new InvalidOperationException(); + return lambda() + Local(); + + int Local() => throw new NotSupportedException(); + } + catch (Exception) + { + return 0; + } + + static int Unused() => throw new NotSupportedException(); + } + """)); + } + + [Fact] + public async Task Nested_block_and_consecutive_guard_tries_report_nothing() + { + await Verifier.VerifyAsync(Callbacks(""" + [UnmanagedCallersOnly] + private static void OnCall(nint state) + { + { + try + { + Work(state); + } + catch + { + } + } + + try + { + Work(state); + } + catch (Exception exception) + { + Log(exception); + } + } + """)); + } + + [Theory] + [InlineData("unsafe")] + [InlineData("checked")] + [InlineData("unchecked")] + public async Task Unsafe_and_checked_blocks_count_as_nested_blocks(string keyword) + { + await Verifier.VerifyAsync(Callbacks($$""" + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + {{keyword}} + { + try + { + return Work(state); + } + catch (Exception) + { + return 0; + } + } + } + """)); + } + + [Fact] + public async Task Attribute_with_arguments_and_full_name_is_recognised() + { + await Verifier.VerifyAsync( + Callbacks(""" + [System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + private static int {|#0:OnCall|}(nint state) + { + return Work(state); + } + """), + Unguarded(0, "OnCall")); + } + + [Fact] + public async Task Method_without_the_attribute_is_not_analysed() + { + await Verifier.VerifyAsync(Callbacks(""" + private static int OnCall(nint state) + { + return Work(state); + } + """)); + } + + [Fact] + public async Task Method_in_generated_code_is_not_analysed() + { + await Verifier.VerifyAsync("// \n" + Callbacks(""" + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + return Work(state); + } + """)); + } + + [Fact] + public async Task Project_without_a_cheatengine_sdk_reference_is_not_analysed() + { + await Verifier.VerifyWithoutCheatEngineSdkAsync(Callbacks(""" + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + return Work(state); + } + """)); + } + + [Fact] + public async Task Body_without_a_try_reports() + { + await Verifier.VerifyAsync( + Callbacks(""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + return Work(state); + } + """), + Unguarded(0, "OnCall")); + } + + [Theory] + [InlineData("finally { Cleanup(); }")] + [InlineData("catch (InvalidOperationException) { return 0; }")] + [InlineData("catch (Exception) { throw; }")] + [InlineData("catch (Exception exception) { throw new InvalidOperationException(\"wrapped\", exception); }")] + [InlineData("catch (Exception exception) when (exception is not OutOfMemoryException) { return 0; }")] + [InlineData("catch (Exception) when (true) { return 0; }")] + [InlineData("catch (InvalidOperationException) { throw; } catch (Exception) { return 0; }")] + [InlineData("catch (Exception) { return 0; } finally { if (state == 0) throw new InvalidOperationException(); }")] + [InlineData( + "catch (Exception exception) { return exception.Message.Length > 0 ? 0 : throw new InvalidOperationException(); }")] + [InlineData("catch (Exception) { Func rethrow = () => throw new InvalidOperationException(); return 0; }")] + [InlineData( + "catch (Exception exception) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception).Throw(); return 0; }")] + [InlineData( + "catch (Exception exception) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(exception); return 0; }")] + [InlineData("catch (Exception) { Fail(); return 0; }")] + [InlineData("catch (Exception) { return 0; } finally { Fail(); }")] + public async Task Try_that_does_not_swallow_everything_reports(string handlers) + { + await Verifier.VerifyAsync( + Callbacks($$""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + try + { + return Work(state); + } + {{handlers}} + } + """), + Unguarded(0, "OnCall")); + } + + [Theory] + [InlineData("Cleanup();")] + [InlineData("int before = Work(state);")] + [InlineData("using IDisposable scope = Scope();")] + [InlineData("if (state == 0) { return 0; }")] + [InlineData("int[] buffer = new int[4];")] + [InlineData("string text = $\"{state}\";")] + [InlineData("dynamic late = null; int bound = late;")] + [InlineData("dynamic late = null; string bound = late;")] + [InlineData("(Source, int) from = default; (Target, int) to = from;")] + [InlineData("(Source, int) from = default; (Target, int)? to = from;")] + [InlineData("Source from = default; Target to = from;")] + [InlineData("object boxed = state;")] + [InlineData("object boxed = null; int unboxed = (int)boxed;")] + [InlineData("int? maybe = null; int value = (int)maybe;")] + [InlineData("decimal money = 0; int value = (int)money;")] + [InlineData("long wide = 0; int value = checked((int)wide);")] + [InlineData("int number = 0; int value = checked(-number);")] + [InlineData("int number = 0; Index fromEnd = ^number;")] + [InlineData("int value = Counter;")] + [InlineData("bool ok = false; int value = ok ? Work(state) : 0;")] + public async Task Statement_that_can_throw_in_front_of_the_try_reports(string statement) + { + await Verifier.VerifyAsync( + Callbacks($$""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + {{statement}} + try + { + return Work(state); + } + catch (Exception) + { + return 0; + } + } + """), + Unguarded(0, "OnCall")); + } + + [Fact] + public async Task Return_that_can_throw_after_the_try_reports() + { + await Verifier.VerifyAsync( + Callbacks(""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + try + { + Cleanup(); + } + catch (Exception) + { + } + + return Work(state); + } + """), + Unguarded(0, "OnCall")); + } + + [Fact] + public async Task Body_without_a_try_whose_only_risk_is_a_dynamic_conversion_reports() + { + await Verifier.VerifyAsync( + Callbacks(""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + dynamic late = default; + int bound = late; + return bound; + } + + [UnmanagedCallersOnly] + private static int {|#1:OnReturn|}(nint state) + { + dynamic late = default; + return late; + } + """), + Unguarded(0, "OnCall"), + Unguarded(1, "OnReturn")); + } + + [Theory] + [InlineData("return ok ? 1 : 0;")] + [InlineData("return !ok ? 1 : 0;")] + [InlineData("return -result;")] + [InlineData("return ~result;")] + [InlineData("return (int)wide;")] + [InlineData("return unchecked((int)wide);")] + [InlineData("return (int)status;")] + [InlineData("return (int)(ok ? wide : -wide);")] + public async Task Return_of_a_value_that_cannot_throw_after_the_try_reports_nothing(string tail) + { + await Verifier.VerifyAsync(Callbacks($$""" + [UnmanagedCallersOnly] + private static int OnCall(nint state) + { + bool ok = false; + int result = 0; + long wide = 0; + DayOfWeek status = default; + try + { + result = Work(state); + wide = result; + status = (DayOfWeek)result; + ok = true; + } + catch (Exception) + { + } + + {{tail}} + } + """)); + } + + [Theory] + [InlineData("IntPtr.Zero")] + [InlineData("nint.Zero")] + [InlineData("(nint)narrow")] + [InlineData("unchecked((nint)wide)")] + public async Task Handle_value_that_cannot_throw_reports_nothing(string value) + { + await Verifier.VerifyAsync(Callbacks($$""" + [UnmanagedCallersOnly] + private static nint OnCall(nint state) + { + int narrow = 0; + ulong wide = 0; + nint handle = {{value}}; + try + { + narrow = Work(state); + wide = (ulong)narrow; + handle = narrow; + } + catch (Exception) + { + } + + return {{value}}; + } + """)); + } + + [Fact] + public async Task Built_in_conversions_that_cannot_throw_report_nothing() + { + await Verifier.VerifyAsync(Callbacks(""" + [UnmanagedCallersOnly] + private static unsafe void* OnCall(int* state, int count) + { + long wide = count; + double real = wide; + decimal money = count; + int? maybe = count; + long? maybeWide = count; + long? stillWide = maybe; + Guid? id = default(Guid); + string text = null; + object same = text; + IComparable comparable = text; + (int Left, int Right) named = default; + (int, int) unnamed = named; + void* untyped = state; + try + { + Work((nint)(real + (double)money) + (maybeWide.HasValue ? 1 : 0) + (stillWide.HasValue ? 1 : 0) + (id.HasValue ? 1 : 0)); + Work(same == comparable ? unnamed.Item1 : 0); + } + catch (Exception) + { + return null; + } + + return untyped; + } + """)); + } + + [Theory] + [InlineData("lock (Gate) { try { return Work(state); } catch (Exception) { return 0; } }")] + [InlineData("using (Scope()) { try { return Work(state); } catch (Exception) { return 0; } }")] + [InlineData( + "unsafe { fixed (int* pointer = Numbers) { try { return Work(state) + *pointer; } catch (Exception) { return 0; } } }")] + [InlineData("checked { return Work(state); }")] + [InlineData("unsafe { return Work(state); }")] + public async Task Guard_hidden_inside_another_statement_reports(string body) + { + await Verifier.VerifyAsync( + Callbacks($$""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + {{body}} + } + """), + Unguarded(0, "OnCall")); + } + + [Theory] + [InlineData("private static int {|#0:OnCall|}(nint state) => Work(state);")] + [InlineData("private static void {|#0:OnCall|}(nint state) => Cleanup();")] + [InlineData("private static int {|#0:OnCall|}(nint state) => checked((int)state);")] + [InlineData("private static int {|#0:OnCall|}(nint state) => Shared;")] + public async Task Expression_body_that_can_throw_reports(string method) + { + await Verifier.VerifyAsync( + Callbacks($$""" + [UnmanagedCallersOnly] + {{method}} + """), + Unguarded(0, "OnCall")); + } + + [Fact] + public async Task Local_function_with_the_attribute_is_analysed_on_its_own() + { + await Verifier.VerifyAsync( + Callbacks(""" + private static unsafe nint Register() + { + delegate* unmanaged guarded = &Guarded; + delegate* unmanaged unguarded = &Unguarded; + delegate* unmanaged expression = &Expression; + return (nint)guarded + (nint)unguarded + (nint)expression; + + [UnmanagedCallersOnly] + static int Guarded(nint state) + { + try + { + return Work(state); + } + catch (Exception) + { + return 0; + } + } + + [UnmanagedCallersOnly] + static int {|#0:Unguarded|}(nint state) + { + return Work(state); + } + + [UnmanagedCallersOnly] + static int {|#1:Expression|}(nint state) => Work(state); + } + """), + Unguarded(0, "Unguarded"), + Unguarded(1, "Expression")); + } + + [Fact] + public async Task Unguarded_method_containing_a_guarded_local_function_still_reports() + { + await Verifier.VerifyAsync( + Callbacks(""" + [UnmanagedCallersOnly] + private static int {|#0:OnCall|}(nint state) + { + return Inner(state); + + static int Inner(nint value) + { + try + { + return Work(value); + } + catch (Exception) + { + return 0; + } + } + } + """), + Unguarded(0, "OnCall")); + } + + // A static class with the helpers the bodies above call; every helper may throw as far as the rule knows. + private static string Callbacks(string members) + { + return $$""" + using System; + using System.Runtime.InteropServices; + + namespace MyPlugin; + + internal static class Callbacks + { + private static readonly object Gate = new(); + + private static readonly int[] Numbers = [1]; + + private static readonly int Counter = Work(1); + + private static int Shared { get; set; } + + {{members}} + + private static int Work(nint state) => state == 0 ? throw new InvalidOperationException() : 1; + + private static void Cleanup() { } + + private static void Log(Exception exception) { } + + private static IDisposable Scope() => throw new NotSupportedException(); + + [System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void Fail() => throw new InvalidOperationException(); + + private struct Source + { + } + + private struct Target + { + public static implicit operator Target(Source source) => throw new InvalidOperationException(); + } + } + """; + } + + private static DiagnosticResult Unguarded(int location, string methodName) + { + return Verifier.Diagnostic(DiagnosticDescriptors.UnguardedUnmanagedCallersOnly).WithLocation(location) + .WithArguments(methodName); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/WellKnown/SdkSymbolResolverTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/WellKnown/SdkSymbolResolverTests.cs index 4110912c..86f7267b 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/WellKnown/SdkSymbolResolverTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/WellKnown/SdkSymbolResolverTests.cs @@ -1,7 +1,11 @@ +using System.Collections.Immutable; + using CheatEngine.SDK.Analyzers.Tests.Infrastructure; using CheatEngine.SDK.Analyzers.WellKnown; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; namespace CheatEngine.SDK.Analyzers.Tests.WellKnown; @@ -11,73 +15,80 @@ namespace CheatEngine.SDK.Analyzers.Tests.WellKnown; /// public sealed class SdkSymbolResolverTests { - private const string RequiresPluginEnabledAttribute = - "CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute"; - - private const string LookalikeAttributeSource = """ - namespace CheatEngine.SDK.Annotations.Lifetime - { - public sealed class RequiresPluginEnabledAttribute : global::System.Attribute - { - } - } - """; - - private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); - - private static readonly CSharpCompilationOptions CompilationOptions = new( - OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable); - - [Fact] - public void Annotation_source_lookalike_does_not_hide_the_sdk_contract() - { - var compilation = CreateCompilation(LookalikeAttributeSource); - - var resolved = SdkSymbolResolver.Annotation(compilation, RequiresPluginEnabledAttribute); - - Assert.NotNull(resolved); - Assert.Equal("CheatEngine.SDK.Annotations", resolved!.ContainingAssembly.Identity.Name); - } - - [Fact] - public void Annotation_duplicate_referenced_lookalike_does_not_hide_the_sdk_contract() - { - var foreignLookalike = CreateReference("Foreign.Annotations", LookalikeAttributeSource); - var compilation = CreateCompilation(string.Empty, foreignLookalike); - - var resolved = SdkSymbolResolver.Annotation(compilation, RequiresPluginEnabledAttribute); - - Assert.NotNull(resolved); - Assert.Equal("CheatEngine.SDK.Annotations", resolved!.ContainingAssembly.Identity.Name); - } - - private static CSharpCompilation CreateCompilation(string source, params MetadataReference[] additionalReferences) - { - var references = LocalFrameworkReferences.References.AddRange(ContractStubs.References); - foreach (var reference in additionalReferences) - references = references.Add(reference); - - return CSharpCompilation.Create( - "SdkSymbolResolverTestAssembly", - [CSharpSyntaxTree.ParseText(source, ParseOptions, "Test.cs", - cancellationToken: TestContext.Current.CancellationToken)], - references, - CompilationOptions); - } - - private static PortableExecutableReference CreateReference(string assemblyName, string source) - { - var compilation = CSharpCompilation.Create( - assemblyName, - [CSharpSyntaxTree.ParseText(source, ParseOptions, assemblyName + ".cs", - cancellationToken: TestContext.Current.CancellationToken)], - LocalFrameworkReferences.References, - CompilationOptions); - using MemoryStream image = new(); - var result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(result.Success, "The foreign lookalike did not compile:\n" + string.Join('\n', result.Diagnostics)); - - return MetadataReference.CreateFromImage([.. image.ToArray()], filePath: assemblyName + ".dll"); - } + private const string RequiresPluginEnabledAttribute = + "CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute"; + + private const string LookalikeAttributeSource = """ + namespace CheatEngine.SDK.Annotations.Lifetime + { + public sealed class RequiresPluginEnabledAttribute : global::System.Attribute + { + } + } + """; + + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + + private static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable); + + [Fact] + public void Annotation_source_lookalike_does_not_hide_the_sdk_contract() + { + CSharpCompilation compilation = CreateCompilation(LookalikeAttributeSource); + + INamedTypeSymbol? resolved = SdkSymbolResolver.Annotation(compilation, RequiresPluginEnabledAttribute); + + Assert.NotNull(resolved); + Assert.Equal("CheatEngine.SDK.Annotations", resolved!.ContainingAssembly.Identity.Name); + } + + [Fact] + public void Annotation_duplicate_referenced_lookalike_does_not_hide_the_sdk_contract() + { + PortableExecutableReference foreignLookalike = CreateReference("Foreign.Annotations", LookalikeAttributeSource); + CSharpCompilation compilation = CreateCompilation(string.Empty, foreignLookalike); + + INamedTypeSymbol? resolved = SdkSymbolResolver.Annotation(compilation, RequiresPluginEnabledAttribute); + + Assert.NotNull(resolved); + Assert.Equal("CheatEngine.SDK.Annotations", resolved!.ContainingAssembly.Identity.Name); + } + + private static CSharpCompilation CreateCompilation(string source, params MetadataReference[] additionalReferences) + { + ImmutableArray references = + LocalFrameworkReferences.References.AddRange(ContractStubs.References); + foreach (MetadataReference reference in additionalReferences) + { + references = references.Add(reference); + } + + return CSharpCompilation.Create( + "SdkSymbolResolverTestAssembly", + [ + CSharpSyntaxTree.ParseText(source, ParseOptions, "Test.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + references, + CompilationOptions); + } + + private static PortableExecutableReference CreateReference(string assemblyName, string source) + { + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName, + [ + CSharpSyntaxTree.ParseText(source, ParseOptions, assemblyName + ".cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + LocalFrameworkReferences.References, + CompilationOptions); + using MemoryStream image = new(); + EmitResult result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, "The foreign lookalike did not compile:\n" + string.Join('\n', result.Diagnostics)); + + return MetadataReference.CreateFromImage([.. image.ToArray()], filePath: assemblyName + ".dll"); + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/BenchFunctions.cs b/tests/CheatEngine.SDK.Benchmarks/BenchFunctions.cs index 5b80aad8..89f2770b 100644 --- a/tests/CheatEngine.SDK.Benchmarks/BenchFunctions.cs +++ b/tests/CheatEngine.SDK.Benchmarks/BenchFunctions.cs @@ -8,12 +8,12 @@ namespace CheatEngine.SDK.Benchmarks; /// internal static partial class BenchFunctions { - /// Lua: cheatengine_sdk_bench_touch(x). Returns plus one. - /// The running value. - /// + 1. - [LuaFunction("cheatengine_sdk_bench_touch")] - public static long Touch(long x) - { - return x + 1; - } + /// Lua: cheatengine_sdk_bench_touch(x). Returns plus one. + /// The running value. + /// + 1. + [LuaFunction("cheatengine_sdk_bench_touch")] + public static long Touch(long x) + { + return x + 1; + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/BenchGlobals.cs b/tests/CheatEngine.SDK.Benchmarks/BenchGlobals.cs index 66185793..51771d62 100644 --- a/tests/CheatEngine.SDK.Benchmarks/BenchGlobals.cs +++ b/tests/CheatEngine.SDK.Benchmarks/BenchGlobals.cs @@ -9,11 +9,11 @@ namespace CheatEngine.SDK.Benchmarks; /// internal static partial class BenchGlobals { - /// Calls the Lua global cheatengine_sdk_bench_add: two arguments, one result. - /// First addend. - /// Second addend. - /// The sum. - /// The global is missing, the call raised, or the result is not an integer. - [LuaGlobal("cheatengine_sdk_bench_add")] - public static partial long Add(long a, long b); + /// Calls the Lua global cheatengine_sdk_bench_add: two arguments, one result. + /// First addend. + /// Second addend. + /// The sum. + /// The global is missing, the call raised, or the result is not an integer. + [LuaGlobal("cheatengine_sdk_bench_add")] + public static partial long Add(long a, long b); } diff --git a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs index 34931ca7..b8d47b6f 100644 --- a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.Benchmarks.Support; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; @@ -17,62 +18,66 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("Callbacks")] public class CallbackBenchmarks : IDisposable { - /// Lua-side calls per invocation of . - private const int LoopCount = 1000; + /// Lua-side calls per invocation of . + private const int LoopCount = 1000; - private LuaState _l; + private LuaState _l; - private NativeLuaState? _state; + private NativeLuaState? _state; - /// - public void Dispose() - { - _ = BenchFunctions.UnregisterLuaFunctions(_l); - LuaRuntime.Detach(); - _state?.Dispose(); - GC.SuppressFinalize(this); - } + /// + public void Dispose() + { + _ = BenchFunctions.UnregisterLuaFunctions(_l); + LuaRuntime.Detach(); + _state?.Dispose(); + GC.SuppressFinalize(this); + } - /// Opens a state, registers the thunk and defines the Lua-side loop that calls it times. - [GlobalSetup] - public void Setup() - { - NativeLuaLibrary.ThrowIfUnavailable(); - _state = new NativeLuaState(); - _l = FakeHostRuntime.Attach(_state, false); + /// Opens a state, registers the thunk and defines the Lua-side loop that calls it times. + [GlobalSetup] + public void Setup() + { + NativeLuaLibrary.ThrowIfUnavailable(); + _state = new NativeLuaState(); + _l = FakeHostRuntime.Attach(_state, false); - var registered = BenchFunctions.RegisterLuaFunctions(_l); - if (!registered.IsOk) - throw new InvalidOperationException("RegisterLuaFunctions failed: " + LuaError.FromStack(_l, registered)); + var registered = BenchFunctions.RegisterLuaFunctions(_l); + if (!registered.IsOk) + { + throw new InvalidOperationException("RegisterLuaFunctions failed: " + LuaError.FromStack(_l, registered)); + } - var defined = _l.TryExecute( - "function cheatengine_sdk_bench_loop(n) local s = 0 for i = 1, n do s = cheatengine_sdk_bench_touch(s) end return s end"u8, - 0); - if (!defined.IsOk) - throw new InvalidOperationException("Defining cheatengine_sdk_bench_loop failed: " + - LuaError.FromStack(_l, defined)); - } + LuaStatus defined = _l.TryExecute( + "function cheatengine_sdk_bench_loop(n) local s = 0 for i = 1, n do s = cheatengine_sdk_bench_touch(s) end return s end"u8, + 0); + if (!defined.IsOk) + { + throw new InvalidOperationException("Defining cheatengine_sdk_bench_loop failed: " + + LuaError.FromStack(_l, defined)); + } + } - /// - /// Unregisters the thunk, detaches the ambient runtime and closes the state. BenchmarkDotNet does not call - /// itself; this is what [GlobalCleanup] is for. - /// - [GlobalCleanup] - public void Cleanup() - { - Dispose(); - } + /// + /// Unregisters the thunk, detaches the ambient runtime and closes the state. BenchmarkDotNet does not call + /// itself; this is what [GlobalCleanup] is for. + /// + [GlobalCleanup] + public void Cleanup() + { + Dispose(); + } - /// One Lua loop of calls to the registered thunk, inside one protected call. - [Benchmark(OperationsPerInvoke = LoopCount)] - public long RoundTrip() - { - var top = _l.Top; - _ = _l.TryGetGlobal("cheatengine_sdk_bench_loop"u8); - _l.PushInteger(LoopCount); - _ = _l.TryCall(1, 1); - _ = _l.TryReadInteger(-1, out var result); - _l.SetTop(top); - return result; - } + /// One Lua loop of calls to the registered thunk, inside one protected call. + [Benchmark(OperationsPerInvoke = LoopCount)] + public long RoundTrip() + { + int top = _l.Top; + _ = _l.TryGetGlobal("cheatengine_sdk_bench_loop"u8); + _l.PushInteger(LoopCount); + _ = _l.TryCall(1, 1); + _ = _l.TryReadInteger(-1, out long result); + _l.SetTop(top); + return result; + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs index 509d063c..dc3560c6 100644 --- a/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs @@ -1,6 +1,9 @@ using System.Text; + using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.SourceGenerators.EngineApi; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; @@ -22,183 +25,186 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("SourceGenerator", "Incremental", "EngineApi")] public class EngineApiIncrementalBenchmarks { - private const string FirstSpec = """ - namespace: Bench.Engine - type: MemoryScalars - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - fixed: boolean:true - result: value:int32 - doc: Reads a 32-bit value. - - global: writeInteger - method: WriteInt32 - form: throwing - arg: address:address - arg: value:int32 - return: boolean - doc: Writes a 32-bit value. - - global: readQword - method: TryReadInt64 - form: try - arg: address:address - result: value:int64 - doc: Reads a 64-bit value. - - global: writeQword - method: WriteInt64 - form: throwing - arg: address:address - arg: value:int64 - return: boolean - doc: Writes a 64-bit value. - """; - - private const string EditedFirstSpec = """ - namespace: Bench.Engine - type: MemoryScalars - - global: readInteger - method: TryReadInt32AfterEdit - form: try - arg: address:address - fixed: boolean:true - result: value:int32 - doc: Reads a 32-bit value after a spec edit. - - global: writeInteger - method: WriteInt32 - form: throwing - arg: address:address - arg: value:int32 - return: boolean - doc: Writes a 32-bit value. - - global: readQword - method: TryReadInt64 - form: try - arg: address:address - result: value:int64 - doc: Reads a 64-bit value. - - global: writeQword - method: WriteInt64 - form: throwing - arg: address:address - arg: value:int64 - return: boolean - doc: Writes a 64-bit value. - """; - - private const string SecondSpec = """ - namespace: Bench.Runtime - type: RuntimeInfo - - global: getCEVersion - method: GetVersion - form: throwing - return: int32 - doc: Reads a runtime version value. - """; - - private CSharpCompilation? _compilation; - private BenchmarkAdditionalText? _editedFirstSpec; - - private BenchmarkAdditionalText? _firstSpec; - - private BenchmarkAdditionalText? _secondSpec; - - private GeneratorDriver? _warmDriver; - - /// Constructs and warms the driver that the incremental cases reuse. - [GlobalSetup] - public void Setup() - { - _compilation = CSharpCompilation.Create("CheatEngine.SDK.Benchmarks.EngineApiWorkload"); - _firstSpec = new BenchmarkAdditionalText("memory-a.cheatengine-sdk-api.txt", FirstSpec); - _secondSpec = new BenchmarkAdditionalText("memory-b.cheatengine-sdk-api.txt", SecondSpec); - _editedFirstSpec = new BenchmarkAdditionalText("memory-a.cheatengine-sdk-api.txt", EditedFirstSpec); - _warmDriver = CreateDriver(_firstSpec, _secondSpec).RunGenerators(_compilation); - } - - /// Creates a fresh driver and generates both curated-spec shapes. - [Benchmark(Baseline = true)] - public int ColdTwoSpecs() - { - return GeneratedSourceCount(CreateDriver(First(), Second()).RunGenerators(Compilation())); - } - - /// Re-runs an already warmed driver without changing either additional file. - [Benchmark] - public int CachedNoInputChange() - { - return GeneratedSourceCount(WarmDriver().RunGenerators(Compilation())); - } - - /// Replaces one spec in the warmed driver and generates the two resulting files. - [Benchmark] - public int OneSpecModified() - { - var updated = WarmDriver().ReplaceAdditionalText(First(), EditedFirst()); - return GeneratedSourceCount(updated.RunGenerators(Compilation())); - } - - private static CSharpGeneratorDriver CreateDriver(params AdditionalText[] specs) - { - return CSharpGeneratorDriver.Create( - [new EngineApiGenerator().AsSourceGenerator()], - specs, - new CSharpParseOptions(LanguageVersion.CSharp14), - null, - new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, false)); - } - - private static int GeneratedSourceCount(GeneratorDriver driver) - { - return driver.GetRunResult().Results[0].GeneratedSources.Length; - } - - private CSharpCompilation Compilation() - { - return _compilation ?? throw new InvalidOperationException("Benchmark setup did not create a compilation."); - } - - private BenchmarkAdditionalText First() - { - return _firstSpec ?? throw new InvalidOperationException("Benchmark setup did not create the first spec."); - } - - private BenchmarkAdditionalText Second() - { - return _secondSpec ?? throw new InvalidOperationException("Benchmark setup did not create the second spec."); - } - - private BenchmarkAdditionalText EditedFirst() - { - return _editedFirstSpec ?? - throw new InvalidOperationException("Benchmark setup did not create the edited spec."); - } - - private GeneratorDriver WarmDriver() - { - return _warmDriver ?? throw new InvalidOperationException("Benchmark setup did not warm the generator driver."); - } - - private sealed class BenchmarkAdditionalText(string path, string text) : AdditionalText - { - private readonly SourceText _text = SourceText.From(text, Encoding.UTF8); - - /// - public override string Path { get; } = path; - - /// - public override SourceText GetText(CancellationToken cancellationToken = default) - { - return _text; - } - } + private const string FirstSpec = """ + namespace: Bench.Engine + type: MemoryScalars + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + fixed: boolean:true + result: value:int32 + doc: Reads a 32-bit value. + + global: writeInteger + method: WriteInt32 + form: throwing + arg: address:address + arg: value:int32 + return: boolean + doc: Writes a 32-bit value. + + global: readQword + method: TryReadInt64 + form: try + arg: address:address + result: value:int64 + doc: Reads a 64-bit value. + + global: writeQword + method: WriteInt64 + form: throwing + arg: address:address + arg: value:int64 + return: boolean + doc: Writes a 64-bit value. + """; + + private const string EditedFirstSpec = """ + namespace: Bench.Engine + type: MemoryScalars + + global: readInteger + method: TryReadInt32AfterEdit + form: try + arg: address:address + fixed: boolean:true + result: value:int32 + doc: Reads a 32-bit value after a spec edit. + + global: writeInteger + method: WriteInt32 + form: throwing + arg: address:address + arg: value:int32 + return: boolean + doc: Writes a 32-bit value. + + global: readQword + method: TryReadInt64 + form: try + arg: address:address + result: value:int64 + doc: Reads a 64-bit value. + + global: writeQword + method: WriteInt64 + form: throwing + arg: address:address + arg: value:int64 + return: boolean + doc: Writes a 64-bit value. + """; + + private const string SecondSpec = """ + namespace: Bench.Runtime + type: RuntimeInfo + + global: getCEVersion + method: GetVersion + form: throwing + return: int32 + doc: Reads a runtime version value. + """; + + private CSharpCompilation? _compilation; + private BenchmarkAdditionalText? _editedFirstSpec; + + private BenchmarkAdditionalText? _firstSpec; + + private BenchmarkAdditionalText? _secondSpec; + + private GeneratorDriver? _warmDriver; + + /// Constructs and warms the driver that the incremental cases reuse. + [GlobalSetup] + public void Setup() + { + _compilation = CSharpCompilation.Create("CheatEngine.SDK.Benchmarks.EngineApiWorkload"); + _firstSpec = new BenchmarkAdditionalText("memory-a.cheatengine-sdk-api.txt", FirstSpec); + _secondSpec = new BenchmarkAdditionalText("memory-b.cheatengine-sdk-api.txt", SecondSpec); + _editedFirstSpec = new BenchmarkAdditionalText("memory-a.cheatengine-sdk-api.txt", EditedFirstSpec); + _warmDriver = CreateDriver(_firstSpec, _secondSpec).RunGenerators(_compilation); + } + + /// Creates a fresh driver and generates both curated-spec shapes. + [Benchmark(Baseline = true)] + public int ColdTwoSpecs() + { + return GeneratedSourceCount(CreateDriver(First(), Second()).RunGenerators(Compilation())); + } + + /// Re-runs an already warmed driver without changing either additional file. + [Benchmark] + public int CachedNoInputChange() + { + return GeneratedSourceCount(WarmDriver().RunGenerators(Compilation())); + } + + /// Replaces one spec in the warmed driver and generates the two resulting files. + [Benchmark] + public int OneSpecModified() + { + GeneratorDriver updated = WarmDriver().ReplaceAdditionalText(First(), EditedFirst()); + return GeneratedSourceCount(updated.RunGenerators(Compilation())); + } + + private static CSharpGeneratorDriver CreateDriver(params AdditionalText[] specs) + { + return CSharpGeneratorDriver.Create( + [new EngineApiGenerator().AsSourceGenerator()], + specs, + new CSharpParseOptions(LanguageVersion.CSharp14), + null, + new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, false)); + } + + private static int GeneratedSourceCount(GeneratorDriver driver) + { + return driver.GetRunResult().Results[0].GeneratedSources.Length; + } + + private CSharpCompilation Compilation() + { + return _compilation ?? throw new InvalidOperationException("Benchmark setup did not create a compilation."); + } + + private BenchmarkAdditionalText First() + { + return _firstSpec ?? throw new InvalidOperationException("Benchmark setup did not create the first spec."); + } + + private BenchmarkAdditionalText Second() + { + return _secondSpec ?? throw new InvalidOperationException("Benchmark setup did not create the second spec."); + } + + private BenchmarkAdditionalText EditedFirst() + { + return _editedFirstSpec ?? + throw new InvalidOperationException("Benchmark setup did not create the edited spec."); + } + + private GeneratorDriver WarmDriver() + { + return _warmDriver ?? throw new InvalidOperationException("Benchmark setup did not warm the generator driver."); + } + + private sealed class BenchmarkAdditionalText(string path, string text) : AdditionalText + { + private readonly SourceText _text = SourceText.From(text, Encoding.UTF8); + + /// + public override string Path + { + get; + } = path; + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs index 7315f192..394c262d 100644 --- a/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs @@ -1,7 +1,9 @@ using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.Benchmarks.Support; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Benchmarks; @@ -14,46 +16,48 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("GlobalCall")] public class GlobalCallBenchmarks : IDisposable { - private NativeLuaState? _state; + private NativeLuaState? _state; - /// - public void Dispose() - { - LuaRuntime.Detach(); - _state?.Dispose(); - GC.SuppressFinalize(this); - } + /// + public void Dispose() + { + LuaRuntime.Detach(); + _state?.Dispose(); + GC.SuppressFinalize(this); + } - /// Opens a state, attaches the ambient runtime to it and defines the Lua-side cheatengine_sdk_bench_add. - [GlobalSetup] - public void Setup() - { - NativeLuaLibrary.ThrowIfUnavailable(); - _state = new NativeLuaState(); - var l = FakeHostRuntime.Attach(_state, false); - var defined = l.TryExecute("function cheatengine_sdk_bench_add(a, b) return a + b end"u8, 0); - if (!defined.IsOk) - throw new InvalidOperationException("Defining cheatengine_sdk_bench_add failed: " + - LuaError.FromStack(l, defined)); - } + /// Opens a state, attaches the ambient runtime to it and defines the Lua-side cheatengine_sdk_bench_add. + [GlobalSetup] + public void Setup() + { + NativeLuaLibrary.ThrowIfUnavailable(); + _state = new NativeLuaState(); + LuaState l = FakeHostRuntime.Attach(_state, false); + LuaStatus defined = l.TryExecute("function cheatengine_sdk_bench_add(a, b) return a + b end"u8, 0); + if (!defined.IsOk) + { + throw new InvalidOperationException("Defining cheatengine_sdk_bench_add failed: " + + LuaError.FromStack(l, defined)); + } + } - /// - /// Detaches the ambient runtime and closes the state. BenchmarkDotNet does not call - /// itself; this is what [GlobalCleanup] is for. - /// - [GlobalCleanup] - public void Cleanup() - { - Dispose(); - } + /// + /// Detaches the ambient runtime and closes the state. BenchmarkDotNet does not call + /// itself; this is what [GlobalCleanup] is for. + /// + [GlobalCleanup] + public void Cleanup() + { + Dispose(); + } - /// - /// The generated wrapper's hot path: provider, gettop, cached-global push, two argument pushes, - /// pcallk, read, settop. - /// - [Benchmark] - public long ProtectedGlobalCall() - { - return BenchGlobals.Add(19, 23); - } + /// + /// The generated wrapper's hot path: provider, gettop, cached-global push, two argument pushes, + /// pcallk, read, settop. + /// + [Benchmark] + public long ProtectedGlobalCall() + { + return BenchGlobals.Add(19, 23); + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/MarshallerBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/MarshallerBenchmarks.cs index 1df6ea2a..d1005f5e 100644 --- a/tests/CheatEngine.SDK.Benchmarks/MarshallerBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/MarshallerBenchmarks.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -16,127 +17,127 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("Transition", "Strings")] public class MarshallerBenchmarks : IDisposable { - private LuaState _l; - private NativeLuaState? _state; + private LuaState _l; + private NativeLuaState? _state; - /// - public void Dispose() - { - _state?.Dispose(); - GC.SuppressFinalize(this); - } + /// + public void Dispose() + { + _state?.Dispose(); + GC.SuppressFinalize(this); + } - /// Opens a fresh, independent Lua state. - [GlobalSetup] - public void Setup() - { - NativeLuaLibrary.ThrowIfUnavailable(); - _state = new NativeLuaState(); - _l = new LuaState(_state.Pointer); - } + /// Opens a fresh, independent Lua state. + [GlobalSetup] + public void Setup() + { + NativeLuaLibrary.ThrowIfUnavailable(); + _state = new NativeLuaState(); + _l = new LuaState(_state.Pointer); + } - /// - /// Closes the state. BenchmarkDotNet does not call itself; this is what - /// [GlobalCleanup] is for. - /// - [GlobalCleanup] - public void Cleanup() - { - Dispose(); - } + /// + /// Closes the state. BenchmarkDotNet does not call itself; this is what + /// [GlobalCleanup] is for. + /// + [GlobalCleanup] + public void Cleanup() + { + Dispose(); + } - /// Push+read of . - [Benchmark] - public int PushReadInt32() - { - var top = _l.Top; - Int32Marshaller.Push(_l, 42); - _ = Int32Marshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value; - } + /// Push+read of . + [Benchmark] + public int PushReadInt32() + { + int top = _l.Top; + Int32Marshaller.Push(_l, 42); + _ = Int32Marshaller.TryRead(_l, -1, out int value); + _l.SetTop(top); + return value; + } - /// Push+read of . - [Benchmark] - public long PushReadInt64() - { - var top = _l.Top; - Int64Marshaller.Push(_l, 42L); - _ = Int64Marshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value; - } + /// Push+read of . + [Benchmark] + public long PushReadInt64() + { + int top = _l.Top; + Int64Marshaller.Push(_l, 42L); + _ = Int64Marshaller.TryRead(_l, -1, out long value); + _l.SetTop(top); + return value; + } - /// Push+read of . - [Benchmark] - public float PushReadSingle() - { - var top = _l.Top; - SingleMarshaller.Push(_l, 4.2f); - _ = SingleMarshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value; - } + /// Push+read of . + [Benchmark] + public float PushReadSingle() + { + int top = _l.Top; + SingleMarshaller.Push(_l, 4.2f); + _ = SingleMarshaller.TryRead(_l, -1, out float value); + _l.SetTop(top); + return value; + } - /// Push+read of . - [Benchmark] - public double PushReadDouble() - { - var top = _l.Top; - DoubleMarshaller.Push(_l, 4.2); - _ = DoubleMarshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value; - } + /// Push+read of . + [Benchmark] + public double PushReadDouble() + { + int top = _l.Top; + DoubleMarshaller.Push(_l, 4.2); + _ = DoubleMarshaller.TryRead(_l, -1, out double value); + _l.SetTop(top); + return value; + } - /// Push+read of . - [Benchmark] - public bool PushReadBoolean() - { - var top = _l.Top; - BooleanMarshaller.Push(_l, true); - _ = BooleanMarshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value; - } + /// Push+read of . + [Benchmark] + public bool PushReadBoolean() + { + int top = _l.Top; + BooleanMarshaller.Push(_l, true); + _ = BooleanMarshaller.TryRead(_l, -1, out bool value); + _l.SetTop(top); + return value; + } - /// Push+read of (, numbers only). - [Benchmark] - public nuint PushReadAddress() - { - var top = _l.Top; - AddressMarshaller.Push(_l, 0x00400000); - _ = AddressMarshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value; - } + /// Push+read of (, numbers only). + [Benchmark] + public nuint PushReadAddress() + { + int top = _l.Top; + AddressMarshaller.Push(_l, 0x00400000); + _ = AddressMarshaller.TryRead(_l, -1, out UIntPtr value); + _l.SetTop(top); + return value; + } - /// - /// Push+read of ( of UTF-8 bytes; allocation-free - /// both ways). - /// - [Benchmark] - public int PushReadUtf8() - { - var top = _l.Top; - Utf8Marshaller.Push(_l, "cheatengine.sdk"u8); - _ = Utf8Marshaller.TryRead(_l, -1, out var value); - var length = value.Length; - _l.SetTop(top); - return length; - } + /// + /// Push+read of ( of UTF-8 bytes; allocation-free + /// both ways). + /// + [Benchmark] + public int PushReadUtf8() + { + int top = _l.Top; + Utf8Marshaller.Push(_l, "cheatengine.sdk"u8); + _ = Utf8Marshaller.TryRead(_l, -1, out ReadOnlySpan value); + int length = value.Length; + _l.SetTop(top); + return length; + } - /// - /// Push+read of (): the one marshaller documented to - /// allocate on read (a new managed string); the odd one out in the MemoryDiagnoser column on purpose. - /// - [Benchmark] - public int PushReadString() - { - var top = _l.Top; - StringMarshaller.Push(_l, "cheatengine.sdk"); - _ = StringMarshaller.TryRead(_l, -1, out var value); - _l.SetTop(top); - return value?.Length ?? 0; - } + /// + /// Push+read of (): the one marshaller documented to + /// allocate on read (a new managed string); the odd one out in the MemoryDiagnoser column on purpose. + /// + [Benchmark] + public int PushReadString() + { + int top = _l.Top; + StringMarshaller.Push(_l, "cheatengine.sdk"); + _ = StringMarshaller.TryRead(_l, -1, out string? value); + _l.SetTop(top); + return value?.Length ?? 0; + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs index 36405f34..815c97b1 100644 --- a/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs @@ -1,9 +1,11 @@ using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.Benchmarks.Support; using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Benchmarks; @@ -22,156 +24,174 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("EngineApi", "TargetMemory", "Fixture")] public class MemoryScalarBenchmarks : IDisposable { - private static readonly Address Address32 = Address.FromUInt64(0x0000_0000_00CE_7700); - - private static readonly Address Address64 = Address.FromUInt64(0x0000_0001_00CE_7700); - - private static readonly HostAddress HostAddress32 = new(0x0000_0000_00CE_7700); - - // The plugin host is x64, but this project still compiles its source without an x64-only constant evaluator. - private static readonly HostAddress HostAddress64 = new(unchecked((nuint)0x0000_0001_00CE_7700UL)); - - private NativeLuaState? _state; - - // Stand-ins intentionally mirror the CE Lua return shapes only. They have no authorization to read another process. - private static ReadOnlySpan ScalarStandIns => """ - local mem32 = {} - local mem64 = {} - function readInteger(address, signed) - local value = mem32[address] - if value == nil or signed then return value end - return value < 0 and value + 4294967296 or value - end - function writeInteger(address, value) mem32[address] = value; return true end - function readQword(address) return mem64[address] end - function writeQword(address, value) mem64[address] = value; return true end - local host32 = {} - local host64 = {} - function readIntegerLocal(address, signed) - local value = host32[address] - if value == nil or signed then return value end - return value < 0 and value + 4294967296 or value - end - function writeIntegerLocal(address, value) host32[address] = value; return true end - function readQwordLocal(address) return host64[address] end - function writeQwordLocal(address, value) host64[address] = value; return true end - """u8; - - /// - public void Dispose() - { - LuaRuntime.Detach(); - _state?.Dispose(); - GC.SuppressFinalize(this); - } - - /// - /// Opens a fixture state with stand-ins for CE 7.7's target and local scalar globals and warms every global - /// cache used below. - /// - [GlobalSetup] - public void Setup() - { - NativeLuaLibrary.ThrowIfUnavailable(); - _state = new NativeLuaState(); - var state = FakeHostRuntime.Attach(_state, false); - var defined = state.TryExecute(ScalarStandIns, 0); - if (!defined.IsOk) - throw new InvalidOperationException("Defining scalar-memory fixture globals failed: " + - LuaError.FromStack(state, defined)); - - if (!TargetMemory.TryWriteInt32(Address32, -42, out _)) - throw new InvalidOperationException("Warming writeInteger failed."); - - if (!TargetMemory.TryWriteInt64(Address64, 0x1_0000_0000L, out _)) - throw new InvalidOperationException("Warming writeQword failed."); - - if (!HostMemory.TryWriteInt32(HostAddress32, -42, out _) || - !HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _)) - throw new InvalidOperationException("Warming local scalar writes failed."); - - if (!TargetMemory.TryReadInt32(Address32, out _, out _) || - !TargetMemory.TryReadInt64(Address64, out _, out _) || - !HostMemory.TryReadInt32(HostAddress32, out _, out _) || - !HostMemory.TryReadInt64(HostAddress64, out _, out _)) - throw new InvalidOperationException("Warming scalar reads failed."); - } - - /// - /// Detaches the ambient runtime and closes the fixture state. BenchmarkDotNet invokes this, not - /// , after the measurements. - /// - [GlobalCleanup] - public void Cleanup() - { - Dispose(); - } - - /// Reads a signed 32-bit target scalar through . - [Benchmark(Baseline = true)] - public int TargetReadInt32() - { - if (!TargetMemory.TryReadInt32(Address32, out var value, out _)) - throw new InvalidOperationException("Fixture readInteger unexpectedly returned nil."); - - return value; - } - - /// Writes a signed 32-bit target scalar through . - [Benchmark] - public bool TargetWriteInt32() - { - return TargetMemory.TryWriteInt32(Address32, -42, out _); - } - - /// Reads a signed 64-bit target scalar through . - [Benchmark] - public long TargetReadInt64() - { - if (!TargetMemory.TryReadInt64(Address64, out var value, out _)) - throw new InvalidOperationException("Fixture readQword unexpectedly returned nil."); - - return value; - } - - /// Writes a signed 64-bit target scalar through . - [Benchmark] - public bool TargetWriteInt64() - { - return TargetMemory.TryWriteInt64(Address64, 0x1_0000_0000L, out _); - } - - /// Reads a signed 32-bit Cheat Engine host scalar through . - [Benchmark] - public int HostReadInt32() - { - if (!HostMemory.TryReadInt32(HostAddress32, out var value, out _)) - throw new InvalidOperationException("Fixture readIntegerLocal unexpectedly returned nil."); - - return value; - } - - /// Writes a signed 32-bit Cheat Engine host scalar through . - [Benchmark] - public bool HostWriteInt32() - { - return HostMemory.TryWriteInt32(HostAddress32, -42, out _); - } - - /// Reads a signed 64-bit Cheat Engine host scalar through . - [Benchmark] - public long HostReadInt64() - { - if (!HostMemory.TryReadInt64(HostAddress64, out var value, out _)) - throw new InvalidOperationException("Fixture readQwordLocal unexpectedly returned nil."); - - return value; - } - - /// Writes a signed 64-bit Cheat Engine host scalar through . - [Benchmark] - public bool HostWriteInt64() - { - return HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _); - } + private static readonly Address Address32 = Address.FromUInt64(0x0000_0000_00CE_7700); + + private static readonly Address Address64 = Address.FromUInt64(0x0000_0001_00CE_7700); + + private static readonly HostAddress HostAddress32 = new(0x0000_0000_00CE_7700); + + // The plugin host is x64, but this project still compiles its source without an x64-only constant evaluator. + private static readonly HostAddress HostAddress64 = new(unchecked((nuint) 0x0000_0001_00CE_7700UL)); + + private NativeLuaState? _state; + + // Stand-ins intentionally mirror the CE Lua return shapes only. They have no authorization to read another process. + private static ReadOnlySpan ScalarStandIns => """ + local mem32 = {} + local mem64 = {} + function readInteger(address, signed) + local value = mem32[address] + if value == nil or signed then return value end + return value < 0 and value + 4294967296 or value + end + function writeInteger(address, value) mem32[address] = value; return true end + function readQword(address) return mem64[address] end + function writeQword(address, value) mem64[address] = value; return true end + local host32 = {} + local host64 = {} + function readIntegerLocal(address, signed) + local value = host32[address] + if value == nil or signed then return value end + return value < 0 and value + 4294967296 or value + end + function writeIntegerLocal(address, value) host32[address] = value; return true end + function readQwordLocal(address) return host64[address] end + function writeQwordLocal(address, value) host64[address] = value; return true end + """u8; + + /// + public void Dispose() + { + LuaRuntime.Detach(); + _state?.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Opens a fixture state with stand-ins for CE 7.7's target and local scalar globals and warms every global + /// cache used below. + /// + [GlobalSetup] + public void Setup() + { + NativeLuaLibrary.ThrowIfUnavailable(); + _state = new NativeLuaState(); + LuaState state = FakeHostRuntime.Attach(_state, false); + LuaStatus defined = state.TryExecute(ScalarStandIns, 0); + if (!defined.IsOk) + { + throw new InvalidOperationException("Defining scalar-memory fixture globals failed: " + + LuaError.FromStack(state, defined)); + } + + if (!TargetMemory.TryWriteInt32(Address32, -42, out _)) + { + throw new InvalidOperationException("Warming writeInteger failed."); + } + + if (!TargetMemory.TryWriteInt64(Address64, 0x1_0000_0000L, out _)) + { + throw new InvalidOperationException("Warming writeQword failed."); + } + + if (!HostMemory.TryWriteInt32(HostAddress32, -42, out _) || + !HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _)) + { + throw new InvalidOperationException("Warming local scalar writes failed."); + } + + if (!TargetMemory.TryReadInt32(Address32, out _, out _) || + !TargetMemory.TryReadInt64(Address64, out _, out _) || + !HostMemory.TryReadInt32(HostAddress32, out _, out _) || + !HostMemory.TryReadInt64(HostAddress64, out _, out _)) + { + throw new InvalidOperationException("Warming scalar reads failed."); + } + } + + /// + /// Detaches the ambient runtime and closes the fixture state. BenchmarkDotNet invokes this, not + /// , after the measurements. + /// + [GlobalCleanup] + public void Cleanup() + { + Dispose(); + } + + /// Reads a signed 32-bit target scalar through . + [Benchmark(Baseline = true)] + public int TargetReadInt32() + { + if (!TargetMemory.TryReadInt32(Address32, out int value, out _)) + { + throw new InvalidOperationException("Fixture readInteger unexpectedly returned nil."); + } + + return value; + } + + /// Writes a signed 32-bit target scalar through . + [Benchmark] + public bool TargetWriteInt32() + { + return TargetMemory.TryWriteInt32(Address32, -42, out _); + } + + /// Reads a signed 64-bit target scalar through . + [Benchmark] + public long TargetReadInt64() + { + if (!TargetMemory.TryReadInt64(Address64, out long value, out _)) + { + throw new InvalidOperationException("Fixture readQword unexpectedly returned nil."); + } + + return value; + } + + /// Writes a signed 64-bit target scalar through . + [Benchmark] + public bool TargetWriteInt64() + { + return TargetMemory.TryWriteInt64(Address64, 0x1_0000_0000L, out _); + } + + /// Reads a signed 32-bit Cheat Engine host scalar through . + [Benchmark] + public int HostReadInt32() + { + if (!HostMemory.TryReadInt32(HostAddress32, out int value, out _)) + { + throw new InvalidOperationException("Fixture readIntegerLocal unexpectedly returned nil."); + } + + return value; + } + + /// Writes a signed 32-bit Cheat Engine host scalar through . + [Benchmark] + public bool HostWriteInt32() + { + return HostMemory.TryWriteInt32(HostAddress32, -42, out _); + } + + /// Reads a signed 64-bit Cheat Engine host scalar through . + [Benchmark] + public long HostReadInt64() + { + if (!HostMemory.TryReadInt64(HostAddress64, out long value, out _)) + { + throw new InvalidOperationException("Fixture readQwordLocal unexpectedly returned nil."); + } + + return value; + } + + /// Writes a signed 64-bit Cheat Engine host scalar through . + [Benchmark] + public bool HostWriteInt64() + { + return HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _); + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/ObjectPropertyBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/ObjectPropertyBenchmarks.cs index 2b1314e0..85278199 100644 --- a/tests/CheatEngine.SDK.Benchmarks/ObjectPropertyBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/ObjectPropertyBenchmarks.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.Benchmarks.Support; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.Marshalling; @@ -18,45 +19,45 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("ObjectAccess")] public class ObjectPropertyBenchmarks : IDisposable { - private CEObject _object; - private NativeLuaState? _state; + private CEObject _object; + private NativeLuaState? _state; - /// - public void Dispose() - { - LuaRuntime.Detach(); - _state?.Dispose(); - GC.SuppressFinalize(this); - } + /// + public void Dispose() + { + LuaRuntime.Detach(); + _state?.Dispose(); + GC.SuppressFinalize(this); + } - /// Opens a state, attaches the ambient runtime with a pusher and a one-property fake object model. - [GlobalSetup] - public void Setup() - { - NativeLuaLibrary.ThrowIfUnavailable(); - _state = new NativeLuaState(); - _ = FakeHostRuntime.Attach(_state, true); - _object = new CEObject(0x0010_0000); - } + /// Opens a state, attaches the ambient runtime with a pusher and a one-property fake object model. + [GlobalSetup] + public void Setup() + { + NativeLuaLibrary.ThrowIfUnavailable(); + _state = new NativeLuaState(); + _ = FakeHostRuntime.Attach(_state, true); + _object = new CEObject(0x0010_0000); + } - /// - /// Detaches the ambient runtime and closes the state. BenchmarkDotNet does not call - /// itself; this is what [GlobalCleanup] is for. - /// - [GlobalCleanup] - public void Cleanup() - { - Dispose(); - } + /// + /// Detaches the ambient runtime and closes the state. BenchmarkDotNet does not call + /// itself; this is what [GlobalCleanup] is for. + /// + [GlobalCleanup] + public void Cleanup() + { + Dispose(); + } - /// - /// : provider, push - /// the object, protected __index, read, settop (nine transitions with a one-call marshaller). - /// - [Benchmark] - public int PropertyGet() - { - _ = _object.TryGetProperty("Count"u8, out var value); - return value; - } + /// + /// : provider, push + /// the object, protected __index, read, settop (nine transitions with a one-call marshaller). + /// + [Benchmark] + public int PropertyGet() + { + _ = _object.TryGetProperty("Count"u8, out int value); + return value; + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/Program.cs b/tests/CheatEngine.SDK.Benchmarks/Program.cs index 4495fc74..c65a7926 100644 --- a/tests/CheatEngine.SDK.Benchmarks/Program.cs +++ b/tests/CheatEngine.SDK.Benchmarks/Program.cs @@ -8,10 +8,10 @@ namespace CheatEngine.SDK.Benchmarks; /// internal static class Program { - private static int Main(string[] args) - { - // Discovers every benchmark class of this assembly; with no --filter the switcher asks which ones to run. - _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); - return 0; - } + private static int Main(string[] args) + { + // Discovers every benchmark class of this assembly; with no --filter the switcher asks which ones to run. + _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + return 0; + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs b/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs index fee18341..71d2a5f9 100644 --- a/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs +++ b/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs @@ -1,10 +1,12 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Benchmarks.Support; @@ -27,68 +29,77 @@ namespace CheatEngine.SDK.Benchmarks.Support; /// internal static unsafe class FakeHostRuntime { - // Registry key for the one-property metatable the pusher attaches; stable for the process (a light userdata whose - // value is a type-stable address, the registry-key idiom of the Lua manual, section 4.5). - private static readonly nint s_metatableKey = - RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(FakeHostRuntime), 1); + // Registry key for the one-property metatable the pusher attaches; stable for the process (a light userdata whose + // value is a type-stable address, the registry-key idiom of the Lua manual, section 4.5). + private static readonly nint s_metatableKey = + RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(FakeHostRuntime), 1); - private static lua_State* s_state; + private static lua_State* s_state; - /// - /// Attaches to on the calling thread and returns a view of - /// it. - /// - /// The Lua state to serve as the ambient runtime's only thread. - /// - /// Whether to install the host-object pusher and its one-property ("Count", always 42) metatable, needed only - /// by CheatEngine.SDK.Engine.Objects.CEObject's property-get benchmark. - /// - /// - /// A view of , for the caller's own setup (defining globals, - /// registering functions). - /// - public static LuaState Attach(NativeLuaState state, bool withPusher) - { - s_state = state.L; - LuaState view = new(state.Pointer); + /// + /// Attaches to on the calling thread and returns a view of + /// it. + /// + /// The Lua state to serve as the ambient runtime's only thread. + /// + /// Whether to install the host-object pusher and its one-property ("Count", always 42) metatable, needed only + /// by CheatEngine.SDK.Engine.Objects.CEObject's property-get benchmark. + /// + /// + /// A view of , for the caller's own setup (defining globals, + /// registering functions). + /// + public static LuaState Attach(NativeLuaState state, bool withPusher) + { + s_state = state.L; + LuaState view = new(state.Pointer); - if (withPusher) InstallMetatable(view); + if (withPusher) + { + InstallMetatable(view); + } - delegate* unmanaged[Stdcall] provider = &Provide; - delegate* unmanaged[Stdcall] pusher = withPusher ? &PushObject : null; - LuaRuntime.Attach(new LuaHostBinding(provider, pusher, Environment.CurrentManagedThreadId)); - return view; - } + delegate* unmanaged[Stdcall] provider = &Provide; + delegate* unmanaged[Stdcall] pusher = withPusher ? &PushObject : null; + LuaRuntime.Attach(new LuaHostBinding(provider, pusher, Environment.CurrentManagedThreadId)); + return view; + } - private static void InstallMetatable(LuaState state) - { - using LuaFrame frame = new(state); - var status = state.TryExecute("local mt = {} mt.__index = function(_, _) return 42 end return mt"u8, 1); - if (!status.IsOk) - throw new InvalidOperationException("Installing the fake host metatable failed: " + - LuaError.FromStack(state, status)); + private static void InstallMetatable(LuaState state) + { + using LuaFrame frame = new(state); + LuaStatus status = state.TryExecute("local mt = {} mt.__index = function(_, _) return 42 end return mt"u8, 1); + if (!status.IsOk) + { + throw new InvalidOperationException("Installing the fake host metatable failed: " + + LuaError.FromStack(state, status)); + } - state.RawSetPointer(LuaState.RegistryIndex, s_metatableKey); - } + state.RawSetPointer(LuaState.RegistryIndex, s_metatableKey); + } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* Provide() - { - return s_state; - } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* Provide() + { + return s_state; + } - // What LuaPushClassInstance is assumed to do: a full userdata whose first field is the object pointer, with a - // metatable attached. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void PushObject(void* l, void* nativeObject) - { - var state = (lua_State*)l; - var block = lua_newuserdata(state, (nuint)sizeof(nint)); - *(nint*)block = (nint)nativeObject; + // What LuaPushClassInstance is assumed to do: a full userdata whose first field is the object pointer, with a + // metatable attached. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void PushObject(void* l, void* nativeObject) + { + lua_State* state = (lua_State*) l; + void* block = lua_newuserdata(state, (nuint) sizeof(nint)); + *(nint*) block = (nint) nativeObject; - if (lua_rawgetp(state, LUA_REGISTRYINDEX, (void*)s_metatableKey) == LUA_TTABLE) - _ = lua_setmetatable(state, -2); - else - lua_settop(state, -2); - } + if (lua_rawgetp(state, LUA_REGISTRYINDEX, (void*) s_metatableKey) == LUA_TTABLE) + { + _ = lua_setmetatable(state, -2); + } + else + { + lua_settop(state, -2); + } + } } diff --git a/tests/CheatEngine.SDK.Benchmarks/Utf8MarshallerBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/Utf8MarshallerBenchmarks.cs index e86ecf58..2333df05 100644 --- a/tests/CheatEngine.SDK.Benchmarks/Utf8MarshallerBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/Utf8MarshallerBenchmarks.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Attributes; + using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -19,56 +20,64 @@ namespace CheatEngine.SDK.Benchmarks; [BenchmarkCategory("Transition", "Utf8")] public class Utf8MarshallerBenchmarks : IDisposable { - private NativeLuaState? _nativeState; - private LuaState _state; + private NativeLuaState? _nativeState; + private LuaState _state; - /// Number of UTF-8 bytes in the valid, non-ASCII payload. - [Params(16, 64, 1024)] - public int ByteCount { get; set; } + /// Number of UTF-8 bytes in the valid, non-ASCII payload. + [Params(16, 64, 1024)] + public int ByteCount + { + get; + set; + } - private byte[] Payload { get; set; } = []; + private byte[] Payload + { + get; + set; + } = []; - /// - public void Dispose() - { - _nativeState?.Dispose(); - GC.SuppressFinalize(this); - } + /// + public void Dispose() + { + _nativeState?.Dispose(); + GC.SuppressFinalize(this); + } - /// Builds a deterministic valid UTF-8 payload and opens an independent fixture state. - [GlobalSetup] - public void Setup() - { - NativeLuaLibrary.ThrowIfUnavailable(); - _nativeState = new NativeLuaState(); - _state = new LuaState(_nativeState.Pointer); - Payload = new byte[ByteCount]; + /// Builds a deterministic valid UTF-8 payload and opens an independent fixture state. + [GlobalSetup] + public void Setup() + { + NativeLuaLibrary.ThrowIfUnavailable(); + _nativeState = new NativeLuaState(); + _state = new LuaState(_nativeState.Pointer); + Payload = new byte[ByteCount]; - for (var index = 0; index < Payload.Length; index += 4) - { - Payload[index] = 0xF0; - Payload[index + 1] = 0x9F; - Payload[index + 2] = 0xA7; - Payload[index + 3] = 0xAA; - } - } + for (int index = 0; index < Payload.Length; index += 4) + { + Payload[index] = 0xF0; + Payload[index + 1] = 0x9F; + Payload[index + 2] = 0xA7; + Payload[index + 3] = 0xAA; + } + } - /// Closes the fixture state after the parameter case completes. - [GlobalCleanup] - public void Cleanup() - { - Dispose(); - } + /// Closes the fixture state after the parameter case completes. + [GlobalCleanup] + public void Cleanup() + { + Dispose(); + } - /// Pushes and reads the current valid UTF-8 payload, returning the borrowed span length. - [Benchmark(Baseline = true)] - public int PushReadUtf8() - { - var top = _state.Top; - Utf8Marshaller.Push(_state, Payload); - _ = Utf8Marshaller.TryRead(_state, -1, out var value); - var length = value.Length; - _state.SetTop(top); - return length; - } + /// Pushes and reads the current valid UTF-8 payload, returning the borrowed span length. + [Benchmark(Baseline = true)] + public int PushReadUtf8() + { + int top = _state.Top; + Utf8Marshaller.Push(_state, Payload); + _ = Utf8Marshaller.TryRead(_state, -1, out ReadOnlySpan value); + int length = value.Length; + _state.SetTop(top); + return length; + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListLuaTests.cs b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListLuaTests.cs index 7a198deb..ac26e917 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListLuaTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListLuaTests.cs @@ -2,9 +2,12 @@ using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; + using EngineAddressList = CheatEngine.SDK.Engine.AddressList.AddressList; namespace CheatEngine.SDK.Engine.Tests.AddressList; @@ -13,209 +16,228 @@ namespace CheatEngine.SDK.Engine.Tests.AddressList; [Trait("Category", "NativeLua")] public sealed class AddressListLuaTests { - [Fact] - public void Current_address_list_and_created_records_are_borrowed_handles() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var listObject = FakeHost.CreateObject(L, "Probe", "o.props.Count = 2"); - var recordObject = FakeHost.CreateObject(L, "Probe", "o.props.ID = 81; o.props.Index = 0"); - EngineAddressList list = new(listObject); - MemoryRecord expected = new(recordObject); - - SetGlobal(L, "list"u8, list); - SetGlobal(L, "record"u8, expected); - EngineTest.Run(L, """ - function getAddressList() return list end - list.getMemoryRecord = function(index) - if index == 0 then return record end - return nil - end - list.getMemoryRecordByID = function(id) - if id == 81 then return record end - return nil - end - list.getSelectedRecord = function() return record end - list.createMemoryRecord = function() return record end - list.setSelectedRecord = function(value) list.SelectedRecordSet = value end - """u8); - - Assert.True(AddressListAccess.TryGetCurrent(out var current)); - Assert.Equal(list, current); - Assert.True(current.TryGetCount(out var count)); - Assert.Equal(2, count); - Assert.True(current.TryGetMemoryRecord(0, out var byIndex)); - Assert.Equal(expected, byIndex); - Assert.False(current.TryGetMemoryRecord(1, out var missingByIndex)); - Assert.True(missingByIndex.IsNull); - Assert.True(current.TryGetMemoryRecordById(new MemoryRecordId(81), out var byId)); - Assert.Equal(expected, byId); - Assert.False(current.TryGetMemoryRecordById(new MemoryRecordId(99), out var missingById)); - Assert.True(missingById.IsNull); - Assert.True(current.TryGetSelectedRecord(out var selected)); - Assert.Equal(expected, selected); - Assert.True(current.TryCreateMemoryRecord(out var created)); - Assert.Equal(expected, created); - Assert.True(current.TrySetSelectedRecord(expected)); - Assert.True(listObject.TryGetProperty("SelectedRecordSet"u8, out var set)); - Assert.Equal(expected, set); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Memory_record_properties_methods_and_children_follow_the_documented_shapes() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var recordObject = FakeHost.CreateObject(L, "Probe", - "o.props.ID = 81; o.props.Index = 3; o.props.Description = 'health'; o.props.Address = 'game+10'; o.props.Value = '100'; o.props.Type = 2"); - var parentObject = FakeHost.CreateObject(L, "Probe"); - var childObject = FakeHost.CreateObject(L, "Probe"); - MemoryRecord record = new(recordObject); - MemoryRecord parent = new(parentObject); - MemoryRecord child = new(childObject); - - SetGlobal(L, "record"u8, record); - SetGlobal(L, "parent"u8, parent); - SetGlobal(L, "child"u8, child); - EngineTest.Run(L, """ - record.Parent = parent - record[0] = child - record.getCurrentAddress = function() return -1 end - """u8); - - AssertRecordReads(record, parent, child); - AssertRecordWrites(record, recordObject); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Negative_object_indices_throw_before_the_host_is_contacted() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineAddressList list = new(FakeHost.CreateObject(scope.State, "Probe")); - MemoryRecord record = new(FakeHost.CreateObject(scope.State, "Probe")); - var providerCalls = FakeHost.ProviderCalls; - var pusherCalls = FakeHost.PusherCalls; - - Assert.Throws(() => list.TryGetMemoryRecord(-1, out _)); - Assert.Throws(() => record.TryGetChild(-1, out _)); - Assert.Equal(providerCalls, FakeHost.ProviderCalls); - Assert.Equal(pusherCalls, FakeHost.PusherCalls); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Protected_errors_and_wrong_results_return_false_and_restore_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var listObject = FakeHost.CreateObject(L, "Probe"); - EngineAddressList list = new(listObject); - - SetGlobal(L, "list"u8, list); - EngineTest.Run(L, """ - function getAddressList() error('no list') end - list.getMemoryRecord = function() return 42 end - """u8); - - Assert.False(AddressListAccess.TryGetCurrent(out var absent)); - Assert.True(absent.IsNull); - Assert.False(list.TryGetMemoryRecord(0, out var wrong)); - Assert.True(wrong.IsNull); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Warm_address_list_lookup_and_record_access_allocate_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineAddressList list = new(FakeHost.CreateObject(L, "Probe", "o.props.Count = 2")); - MemoryRecord record = new(FakeHost.CreateObject(L, "Probe", "o.props.ID = 81")); - long sink = 0; - - SetGlobal(L, "list"u8, list); - SetGlobal(L, "record"u8, record); - EngineTest.Run(L, """ - function getAddressList() return list end - list.getMemoryRecord = function(index) return record end - """u8); - - Assert.True(AddressListAccess.TryGetCurrent(out _)); - AllocationGate.AssertZero(() => - { - if (!AddressListAccess.TryGetCurrent(out var current)) Assert.Fail("getAddressList"); - if (!current.TryGetCount(out var count)) Assert.Fail("getCount"); - if (!current.TryGetMemoryRecord(0, out var found)) Assert.Fail("getMemoryRecord"); - if (!found.TryGetId(out var id)) Assert.Fail("ID"); - sink += count + id.Value; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - private static void SetGlobal(LuaState state, ReadOnlySpan name, T value) - where T : struct, ILuaMarshaller - { - using LuaFrame frame = new(state); - T.Push(state, value); - var status = state.TrySetGlobal(name); - if (!status.IsOk) Assert.Fail("Setting the test global failed: " + EngineTest.ErrorMessage(state, status)); - } - - private static void AssertRecordReads(MemoryRecord record, MemoryRecord parent, MemoryRecord child) - { - Assert.True(record.TryGetId(out var id)); - Assert.Equal(new MemoryRecordId(81), id); - Assert.True(record.TryGetIndex(out var index)); - Assert.Equal(3, index); - Assert.True(record.TryGetDescription(out var description)); - Assert.Equal("health", description); - Assert.True(record.TryGetAddressExpression(out var expression)); - Assert.Equal("game+10", expression); - Assert.True(record.TryGetValue(out var value)); - Assert.Equal("100", value); - Assert.True(record.TryGetVariableType(out var variableType)); - Assert.Equal(VariableType.Dword, variableType); - Assert.True(record.TryGetCurrentAddress(out var address)); - Assert.Equal(ulong.MaxValue, address.Value); - Assert.True(record.TryGetParent(out var actualParent)); - Assert.Equal(parent, actualParent); - Assert.True(record.TryGetChild(0, out var actualChild)); - Assert.Equal(child, actualChild); - Assert.False(record.TryGetChild(1, out var missing)); - Assert.True(missing.IsNull); - } - - private static void AssertRecordWrites(MemoryRecord record, CEObject recordObject) - { - Assert.True(record.TrySetDescription("mana")); - Assert.True(record.TrySetAddressExpression("game+20")); - Assert.True(record.TrySetValue("101")); - Assert.True(record.TrySetVariableType(VariableType.Qword)); - Assert.True(recordObject.TryGetProperty("Description"u8, - out var changedDescription)); - Assert.Equal("mana", changedDescription); - Assert.True(recordObject.TryGetProperty("Address"u8, - out var changedAddress)); - Assert.Equal("game+20", changedAddress); - Assert.True(recordObject.TryGetProperty("Value"u8, - out var changedValue)); - Assert.Equal("101", changedValue); - Assert.True(recordObject.TryGetProperty, VariableType>( - "Type"u8, out var changedType)); - Assert.Equal(VariableType.Qword, changedType); - } + [Fact] + public void Current_address_list_and_created_records_are_borrowed_handles() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject listObject = FakeHost.CreateObject(L, "Probe", "o.props.Count = 2"); + CEObject recordObject = FakeHost.CreateObject(L, "Probe", "o.props.ID = 81; o.props.Index = 0"); + EngineAddressList list = new(listObject); + MemoryRecord expected = new(recordObject); + + SetGlobal(L, "list"u8, list); + SetGlobal(L, "record"u8, expected); + EngineTest.Run(L, """ + function getAddressList() return list end + list.getMemoryRecord = function(index) + if index == 0 then return record end + return nil + end + list.getMemoryRecordByID = function(id) + if id == 81 then return record end + return nil + end + list.getSelectedRecord = function() return record end + list.createMemoryRecord = function() return record end + list.setSelectedRecord = function(value) list.SelectedRecordSet = value end + """u8); + + Assert.True(AddressListAccess.TryGetCurrent(out EngineAddressList current)); + Assert.Equal(list, current); + Assert.True(current.TryGetCount(out int count)); + Assert.Equal(2, count); + Assert.True(current.TryGetMemoryRecord(0, out MemoryRecord byIndex)); + Assert.Equal(expected, byIndex); + Assert.False(current.TryGetMemoryRecord(1, out MemoryRecord missingByIndex)); + Assert.True(missingByIndex.IsNull); + Assert.True(current.TryGetMemoryRecordById(new MemoryRecordId(81), out MemoryRecord byId)); + Assert.Equal(expected, byId); + Assert.False(current.TryGetMemoryRecordById(new MemoryRecordId(99), out MemoryRecord missingById)); + Assert.True(missingById.IsNull); + Assert.True(current.TryGetSelectedRecord(out MemoryRecord selected)); + Assert.Equal(expected, selected); + Assert.True(current.TryCreateMemoryRecord(out MemoryRecord created)); + Assert.Equal(expected, created); + Assert.True(current.TrySetSelectedRecord(expected)); + Assert.True(listObject.TryGetProperty("SelectedRecordSet"u8, out MemoryRecord set)); + Assert.Equal(expected, set); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Memory_record_properties_methods_and_children_follow_the_documented_shapes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject recordObject = FakeHost.CreateObject(L, "Probe", + "o.props.ID = 81; o.props.Index = 3; o.props.Description = 'health'; o.props.Address = 'game+10'; o.props.Value = '100'; o.props.Type = 2"); + CEObject parentObject = FakeHost.CreateObject(L, "Probe"); + CEObject childObject = FakeHost.CreateObject(L, "Probe"); + MemoryRecord record = new(recordObject); + MemoryRecord parent = new(parentObject); + MemoryRecord child = new(childObject); + + SetGlobal(L, "record"u8, record); + SetGlobal(L, "parent"u8, parent); + SetGlobal(L, "child"u8, child); + EngineTest.Run(L, """ + record.Parent = parent + record[0] = child + record.getCurrentAddress = function() return -1 end + """u8); + + AssertRecordReads(record, parent, child); + AssertRecordWrites(record, recordObject); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Negative_object_indices_throw_before_the_host_is_contacted() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineAddressList list = new(FakeHost.CreateObject(scope.State, "Probe")); + MemoryRecord record = new(FakeHost.CreateObject(scope.State, "Probe")); + int providerCalls = FakeHost.ProviderCalls; + int pusherCalls = FakeHost.PusherCalls; + + Assert.Throws(() => list.TryGetMemoryRecord(-1, out _)); + Assert.Throws(() => record.TryGetChild(-1, out _)); + Assert.Equal(providerCalls, FakeHost.ProviderCalls); + Assert.Equal(pusherCalls, FakeHost.PusherCalls); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Protected_errors_and_wrong_results_return_false_and_restore_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject listObject = FakeHost.CreateObject(L, "Probe"); + EngineAddressList list = new(listObject); + + SetGlobal(L, "list"u8, list); + EngineTest.Run(L, """ + function getAddressList() error('no list') end + list.getMemoryRecord = function() return 42 end + """u8); + + Assert.False(AddressListAccess.TryGetCurrent(out EngineAddressList absent)); + Assert.True(absent.IsNull); + Assert.False(list.TryGetMemoryRecord(0, out MemoryRecord wrong)); + Assert.True(wrong.IsNull); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Warm_address_list_lookup_and_record_access_allocate_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineAddressList list = new(FakeHost.CreateObject(L, "Probe", "o.props.Count = 2")); + MemoryRecord record = new(FakeHost.CreateObject(L, "Probe", "o.props.ID = 81")); + long sink = 0; + + SetGlobal(L, "list"u8, list); + SetGlobal(L, "record"u8, record); + EngineTest.Run(L, """ + function getAddressList() return list end + list.getMemoryRecord = function(index) return record end + """u8); + + Assert.True(AddressListAccess.TryGetCurrent(out _)); + AllocationGate.AssertZero(() => + { + if (!AddressListAccess.TryGetCurrent(out EngineAddressList current)) + { + Assert.Fail("getAddressList"); + } + + if (!current.TryGetCount(out int count)) + { + Assert.Fail("getCount"); + } + + if (!current.TryGetMemoryRecord(0, out MemoryRecord found)) + { + Assert.Fail("getMemoryRecord"); + } + + if (!found.TryGetId(out MemoryRecordId id)) + { + Assert.Fail("ID"); + } + + sink += count + id.Value; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + private static void SetGlobal(LuaState state, ReadOnlySpan name, T value) + where T : struct, ILuaMarshaller + { + using LuaFrame frame = new(state); + T.Push(state, value); + LuaStatus status = state.TrySetGlobal(name); + if (!status.IsOk) + { + Assert.Fail("Setting the test global failed: " + EngineTest.ErrorMessage(state, status)); + } + } + + private static void AssertRecordReads(MemoryRecord record, MemoryRecord parent, MemoryRecord child) + { + Assert.True(record.TryGetId(out MemoryRecordId id)); + Assert.Equal(new MemoryRecordId(81), id); + Assert.True(record.TryGetIndex(out int index)); + Assert.Equal(3, index); + Assert.True(record.TryGetDescription(out string? description)); + Assert.Equal("health", description); + Assert.True(record.TryGetAddressExpression(out string? expression)); + Assert.Equal("game+10", expression); + Assert.True(record.TryGetValue(out string? value)); + Assert.Equal("100", value); + Assert.True(record.TryGetVariableType(out VariableType variableType)); + Assert.Equal(VariableType.Dword, variableType); + Assert.True(record.TryGetCurrentAddress(out Address address)); + Assert.Equal(ulong.MaxValue, address.Value); + Assert.True(record.TryGetParent(out MemoryRecord actualParent)); + Assert.Equal(parent, actualParent); + Assert.True(record.TryGetChild(0, out MemoryRecord actualChild)); + Assert.Equal(child, actualChild); + Assert.False(record.TryGetChild(1, out MemoryRecord missing)); + Assert.True(missing.IsNull); + } + + private static void AssertRecordWrites(MemoryRecord record, CEObject recordObject) + { + Assert.True(record.TrySetDescription("mana")); + Assert.True(record.TrySetAddressExpression("game+20")); + Assert.True(record.TrySetValue("101")); + Assert.True(record.TrySetVariableType(VariableType.Qword)); + Assert.True(recordObject.TryGetProperty("Description"u8, + out string? changedDescription)); + Assert.Equal("mana", changedDescription); + Assert.True(recordObject.TryGetProperty("Address"u8, + out string? changedAddress)); + Assert.Equal("game+20", changedAddress); + Assert.True(recordObject.TryGetProperty("Value"u8, + out string? changedValue)); + Assert.Equal("101", changedValue); + Assert.True(recordObject.TryGetProperty, VariableType>( + "Type"u8, out VariableType changedType)); + Assert.Equal(VariableType.Qword, changedType); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListMutationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListMutationsTests.cs index 53687c38..ee290090 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListMutationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListMutationsTests.cs @@ -1,11 +1,13 @@ -using System; using System.Text; + using CheatEngine.SDK.Engine.AddressList; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; + using EngineAddressList = CheatEngine.SDK.Engine.AddressList.AddressList; namespace CheatEngine.SDK.Engine.Tests.AddressList; @@ -14,215 +16,226 @@ namespace CheatEngine.SDK.Engine.Tests.AddressList; [Trait("Category", "NativeLua")] public sealed class AddressListMutationsTests { - [Fact] - public void Default_outcome_is_not_classified_as_a_completed_command() - { - MemoryRecordMutationOutcome outcome = default; - - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect); - Assert.Equal(MemoryRecordMutationProblem.Uninitialized, outcome.Problem); - Assert.False(outcome.IsCompleted); - } - - [Fact] - public void Delete_reports_each_address_list_preflight_failure_without_starting_a_mutation() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var top = L.Top; - - var missingGlobal = AddressListMutations.Delete(new MemoryRecordId(1)); - - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, missingGlobal.Effect); - Assert.Equal(MemoryRecordMutationProblem.GlobalUnavailable, missingGlobal.Problem); - Assert.Equal(top, L.Top); - - AssertDeleteAddressListPreflight("getAddressList = function() error('address list lookup failed') end", - MemoryRecordMutationProblem.LuaFailure); - AssertDeleteAddressListPreflight("getAddressList = function() return nil end", - MemoryRecordMutationProblem.AddressListUnavailable); - AssertDeleteAddressListPreflight("getAddressList = function() return 42 end", - MemoryRecordMutationProblem.InvalidResult); - } - - [Fact] - public void Delete_distinguishes_missing_record_from_success_and_restores_stack() - { - using MutationFixture fixture = new(); - var top = fixture.State.Top; - - var missing = AddressListMutations.Delete(new MemoryRecordId(99)); - - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, missing.Effect); - Assert.Equal(MemoryRecordMutationProblem.RecordNotFound, missing.Problem); - Assert.Equal(0, FakeHost.DestroyedCount(fixture.State)); - Assert.Equal(top, fixture.State.Top); - - var success = AddressListMutations.Delete(new MemoryRecordId(1)); - - Assert.True(success.IsCompleted); - Assert.Equal(MemoryRecordMutationProblem.None, success.Problem); - Assert.Equal(1, FakeHost.DestroyedCount(fixture.State)); - Assert.Equal(top, fixture.State.Top); - } - - [Fact] - public void Delete_preserves_a_non_callable_record_lookup_failure() - { - using MutationFixture fixture = new(); - var top = fixture.State.Top; - fixture.Execute("list.getMemoryRecordByID = 42"); - - var result = AddressListMutations.Delete(new MemoryRecordId(1)); - - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, result.Effect); - Assert.Equal(MemoryRecordMutationProblem.LuaFailure, result.Problem); - Assert.False(result.LuaStatus.IsOk); - Assert.Equal(0, FakeHost.DestroyedCount(fixture.State)); - Assert.Equal(top, fixture.State.Top); - } - - [Fact] - public void Set_parent_rejects_self_missing_and_cyclic_hierarchies_before_assignment() - { - using MutationFixture fixture = new(); - var top = fixture.State.Top; - - var self = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(1)); - var missing = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(99)); - fixture.Execute("parent.Parent = child"); - var cycle = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); - - Assert.Equal(MemoryRecordMutationProblem.SelfParent, self.Problem); - Assert.Equal(MemoryRecordMutationProblem.ParentNotFound, missing.Problem); - Assert.Equal(MemoryRecordMutationProblem.CycleDetected, cycle.Problem); - Assert.All(new[] { self, missing, cycle }, outcome => - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect)); - fixture.Execute("assert(assigned == 0)"); - Assert.Equal(top, fixture.State.Top); - } - - [Fact] - public void Set_parent_obeys_an_explicit_parent_walk_bound() - { - using MutationFixture fixture = new(); - fixture.Execute("parent.Parent = grandparent"); - - var result = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2), - new MemoryRecordParentTraversalLimit(1)); - - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, result.Effect); - Assert.Equal(MemoryRecordMutationProblem.TraversalLimitReached, result.Problem); - fixture.Execute("assert(assigned == 0)"); - } - - [Fact] - public void Set_parent_assigns_a_typed_parent_or_lua_nil_for_root() - { - using MutationFixture fixture = new(); - var top = fixture.State.Top; - - var parent = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); - fixture.Execute("assert(assigned == 1)"); - var root = AddressListMutations.SetParent(new MemoryRecordId(1), parentId: null); - - Assert.True(parent.IsCompleted); - Assert.True(root.IsCompleted); - fixture.Execute("assert(assigned == 2)"); - Assert.Equal(top, fixture.State.Top); - } - - [Fact] - public void Started_destroy_or_parent_assignment_error_is_indeterminate_and_not_retried() - { - using MutationFixture failingDestroy = new(destroyFails: true); - var destroy = AddressListMutations.Delete(new MemoryRecordId(1)); - - Assert.Equal(MemoryRecordMutationEffect.Indeterminate, destroy.Effect); - Assert.Equal(MemoryRecordMutationProblem.LuaFailure, destroy.Problem); - Assert.Equal(0, FakeHost.DestroyedCount(failingDestroy.State)); - - using MutationFixture failingSetter = new(parentSetterFails: true); - var assignment = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); - - Assert.Equal(MemoryRecordMutationEffect.Indeterminate, assignment.Effect); - Assert.Equal(MemoryRecordMutationProblem.LuaFailure, assignment.Problem); - failingSetter.Execute("assert(assigned == 1)"); - } - - private sealed class MutationFixture : IDisposable - { - private readonly NativeLuaState _nativeState; - private readonly HostScope _scope; - - public MutationFixture(bool parentSetterFails = false, bool destroyFails = false) - { - EngineTest.RequireNativeLua(); - _nativeState = new NativeLuaState(); - _scope = new HostScope(_nativeState); - State = _scope.State; - var listObject = FakeHost.CreateObject(State, "Probe"); - var childInitializer = parentSetterFails - ? "o.props.ID = 1; o.setters.Parent = function(o, v) o.props.Parent = v; assigned = assigned + 1; error('set after side effect') end" - : "o.props.ID = 1; o.setters.Parent = function(o, v) o.props.Parent = v; assigned = assigned + 1 end"; - var childObject = FakeHost.CreateObject(State, destroyFails ? "Stubborn" : "Probe", childInitializer); - var parentObject = FakeHost.CreateObject(State, "Probe", "o.props.ID = 2"); - var grandparentObject = FakeHost.CreateObject(State, "Probe", "o.props.ID = 3"); - SetGlobal(State, "list"u8, new EngineAddressList(listObject)); - SetGlobal(State, "child"u8, new MemoryRecord(childObject)); - SetGlobal(State, "parent"u8, new MemoryRecord(parentObject)); - SetGlobal(State, "grandparent"u8, new MemoryRecord(grandparentObject)); - EngineTest.Run(State, """ - destroyed = 0 - assigned = 0 - function getAddressList() return list end - list.getMemoryRecordByID = function(id) - if id == 1 then return child end - if id == 2 then return parent end - if id == 3 then return grandparent end - return nil - end - child.destroy = function() destroyed = destroyed + 1 end - """u8); - } - - public LuaState State { get; } - - public void Execute(string source) - { - EngineTest.Run(State, Encoding.UTF8.GetBytes(source)); - } - - public void Dispose() - { - _scope.Dispose(); - _nativeState.Dispose(); - } - - private static void SetGlobal(LuaState state, ReadOnlySpan name, T value) - where T : struct, ILuaMarshaller - { - using LuaFrame frame = new(state); - T.Push(state, value); - var status = state.TrySetGlobal(name); - if (!status.IsOk) Assert.Fail("Setting a test global failed: " + EngineTest.ErrorMessage(state, status)); - } - } - - private static void AssertDeleteAddressListPreflight(string source, MemoryRecordMutationProblem expectedProblem) - { - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, Encoding.UTF8.GetBytes(source)); - var top = L.Top; - - var outcome = AddressListMutations.Delete(new MemoryRecordId(1)); - - Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect); - Assert.Equal(expectedProblem, outcome.Problem); - Assert.Equal(top, L.Top); - } + [Fact] + public void Default_outcome_is_not_classified_as_a_completed_command() + { + MemoryRecordMutationOutcome outcome = default; + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.Uninitialized, outcome.Problem); + Assert.False(outcome.IsCompleted); + } + + [Fact] + public void Delete_reports_each_address_list_preflight_failure_without_starting_a_mutation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + int top = L.Top; + + MemoryRecordMutationOutcome missingGlobal = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, missingGlobal.Effect); + Assert.Equal(MemoryRecordMutationProblem.GlobalUnavailable, missingGlobal.Problem); + Assert.Equal(top, L.Top); + + AssertDeleteAddressListPreflight("getAddressList = function() error('address list lookup failed') end", + MemoryRecordMutationProblem.LuaFailure); + AssertDeleteAddressListPreflight("getAddressList = function() return nil end", + MemoryRecordMutationProblem.AddressListUnavailable); + AssertDeleteAddressListPreflight("getAddressList = function() return 42 end", + MemoryRecordMutationProblem.InvalidResult); + } + + [Fact] + public void Delete_distinguishes_missing_record_from_success_and_restores_stack() + { + using MutationFixture fixture = new(); + int top = fixture.State.Top; + + MemoryRecordMutationOutcome missing = AddressListMutations.Delete(new MemoryRecordId(99)); + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, missing.Effect); + Assert.Equal(MemoryRecordMutationProblem.RecordNotFound, missing.Problem); + Assert.Equal(0, FakeHost.DestroyedCount(fixture.State)); + Assert.Equal(top, fixture.State.Top); + + MemoryRecordMutationOutcome success = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.True(success.IsCompleted); + Assert.Equal(MemoryRecordMutationProblem.None, success.Problem); + Assert.Equal(1, FakeHost.DestroyedCount(fixture.State)); + Assert.Equal(top, fixture.State.Top); + } + + [Fact] + public void Delete_preserves_a_non_callable_record_lookup_failure() + { + using MutationFixture fixture = new(); + int top = fixture.State.Top; + fixture.Execute("list.getMemoryRecordByID = 42"); + + MemoryRecordMutationOutcome result = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, result.Effect); + Assert.Equal(MemoryRecordMutationProblem.LuaFailure, result.Problem); + Assert.False(result.LuaStatus.IsOk); + Assert.Equal(0, FakeHost.DestroyedCount(fixture.State)); + Assert.Equal(top, fixture.State.Top); + } + + [Fact] + public void Set_parent_rejects_self_missing_and_cyclic_hierarchies_before_assignment() + { + using MutationFixture fixture = new(); + int top = fixture.State.Top; + + MemoryRecordMutationOutcome self = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(1)); + MemoryRecordMutationOutcome missing = + AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(99)); + fixture.Execute("parent.Parent = child"); + MemoryRecordMutationOutcome + cycle = AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); + + Assert.Equal(MemoryRecordMutationProblem.SelfParent, self.Problem); + Assert.Equal(MemoryRecordMutationProblem.ParentNotFound, missing.Problem); + Assert.Equal(MemoryRecordMutationProblem.CycleDetected, cycle.Problem); + Assert.All(new[] { self, missing, cycle }, outcome => + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect)); + fixture.Execute("assert(assigned == 0)"); + Assert.Equal(top, fixture.State.Top); + } + + [Fact] + public void Set_parent_obeys_an_explicit_parent_walk_bound() + { + using MutationFixture fixture = new(); + fixture.Execute("parent.Parent = grandparent"); + + MemoryRecordMutationOutcome result = AddressListMutations.SetParent(new MemoryRecordId(1), + new MemoryRecordId(2), + new MemoryRecordParentTraversalLimit(1)); + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, result.Effect); + Assert.Equal(MemoryRecordMutationProblem.TraversalLimitReached, result.Problem); + fixture.Execute("assert(assigned == 0)"); + } + + [Fact] + public void Set_parent_assigns_a_typed_parent_or_lua_nil_for_root() + { + using MutationFixture fixture = new(); + int top = fixture.State.Top; + + MemoryRecordMutationOutcome parent = + AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); + fixture.Execute("assert(assigned == 1)"); + MemoryRecordMutationOutcome root = AddressListMutations.SetParent(new MemoryRecordId(1), null); + + Assert.True(parent.IsCompleted); + Assert.True(root.IsCompleted); + fixture.Execute("assert(assigned == 2)"); + Assert.Equal(top, fixture.State.Top); + } + + [Fact] + public void Started_destroy_or_parent_assignment_error_is_indeterminate_and_not_retried() + { + using MutationFixture failingDestroy = new(destroyFails: true); + MemoryRecordMutationOutcome destroy = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.Equal(MemoryRecordMutationEffect.Indeterminate, destroy.Effect); + Assert.Equal(MemoryRecordMutationProblem.LuaFailure, destroy.Problem); + Assert.Equal(0, FakeHost.DestroyedCount(failingDestroy.State)); + + using MutationFixture failingSetter = new(true); + MemoryRecordMutationOutcome assignment = + AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); + + Assert.Equal(MemoryRecordMutationEffect.Indeterminate, assignment.Effect); + Assert.Equal(MemoryRecordMutationProblem.LuaFailure, assignment.Problem); + failingSetter.Execute("assert(assigned == 1)"); + } + + private static void AssertDeleteAddressListPreflight(string source, MemoryRecordMutationProblem expectedProblem) + { + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, Encoding.UTF8.GetBytes(source)); + int top = L.Top; + + MemoryRecordMutationOutcome outcome = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect); + Assert.Equal(expectedProblem, outcome.Problem); + Assert.Equal(top, L.Top); + } + + private sealed class MutationFixture : IDisposable + { + private readonly NativeLuaState _nativeState; + private readonly HostScope _scope; + + public MutationFixture(bool parentSetterFails = false, bool destroyFails = false) + { + EngineTest.RequireNativeLua(); + _nativeState = new NativeLuaState(); + _scope = new HostScope(_nativeState); + State = _scope.State; + CEObject listObject = FakeHost.CreateObject(State, "Probe"); + string childInitializer = parentSetterFails + ? "o.props.ID = 1; o.setters.Parent = function(o, v) o.props.Parent = v; assigned = assigned + 1; error('set after side effect') end" + : "o.props.ID = 1; o.setters.Parent = function(o, v) o.props.Parent = v; assigned = assigned + 1 end"; + CEObject childObject = FakeHost.CreateObject(State, destroyFails ? "Stubborn" : "Probe", childInitializer); + CEObject parentObject = FakeHost.CreateObject(State, "Probe", "o.props.ID = 2"); + CEObject grandparentObject = FakeHost.CreateObject(State, "Probe", "o.props.ID = 3"); + SetGlobal(State, "list"u8, new EngineAddressList(listObject)); + SetGlobal(State, "child"u8, new MemoryRecord(childObject)); + SetGlobal(State, "parent"u8, new MemoryRecord(parentObject)); + SetGlobal(State, "grandparent"u8, new MemoryRecord(grandparentObject)); + EngineTest.Run(State, """ + destroyed = 0 + assigned = 0 + function getAddressList() return list end + list.getMemoryRecordByID = function(id) + if id == 1 then return child end + if id == 2 then return parent end + if id == 3 then return grandparent end + return nil + end + child.destroy = function() destroyed = destroyed + 1 end + """u8); + } + + public LuaState State + { + get; + } + + public void Dispose() + { + _scope.Dispose(); + _nativeState.Dispose(); + } + + public void Execute(string source) + { + EngineTest.Run(State, Encoding.UTF8.GetBytes(source)); + } + + private static void SetGlobal(LuaState state, ReadOnlySpan name, T value) + where T : struct, ILuaMarshaller + { + using LuaFrame frame = new(state); + T.Push(state, value); + LuaStatus status = state.TrySetGlobal(name); + if (!status.IsOk) + { + Assert.Fail("Setting a test global failed: " + EngineTest.ErrorMessage(state, status)); + } + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListValueTests.cs b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListValueTests.cs index d36a440a..9be48aea 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListValueTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListValueTests.cs @@ -1,8 +1,10 @@ using System.Reflection; + using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.AddressList; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Values; + using EngineAddressList = CheatEngine.SDK.Engine.AddressList.AddressList; namespace CheatEngine.SDK.Engine.Tests.AddressList; @@ -10,64 +12,64 @@ namespace CheatEngine.SDK.Engine.Tests.AddressList; /// Pure value and ownership-shape tests for the address-list handles. public sealed class AddressListValueTests { - [Fact] - public void Borrowed_handles_wrap_identity_without_becoming_owners() - { - CEObject raw = new(0x1234); - EngineAddressList list = new(raw); - MemoryRecord record = new(raw); + [Fact] + public void Borrowed_handles_wrap_identity_without_becoming_owners() + { + CEObject raw = new(0x1234); + EngineAddressList list = new(raw); + MemoryRecord record = new(raw); - Assert.Equal(raw, list.Handle); - Assert.Equal(raw, record.Handle); - Assert.Equal(list, EngineAddressList.FromHandle(raw)); - Assert.Equal(record, MemoryRecord.FromHandle(raw)); - Assert.Equal("AddressList(CEObject@0x1234)", list.ToString()); - Assert.Equal("MemoryRecord(CEObject@0x1234)", record.ToString()); - Assert.False(typeof(EngineAddressList).IsAssignableTo(typeof(IDisposable))); - Assert.False(typeof(MemoryRecord).IsAssignableTo(typeof(IDisposable))); - } + Assert.Equal(raw, list.Handle); + Assert.Equal(raw, record.Handle); + Assert.Equal(list, EngineAddressList.FromHandle(raw)); + Assert.Equal(record, MemoryRecord.FromHandle(raw)); + Assert.Equal("AddressList(CEObject@0x1234)", list.ToString()); + Assert.Equal("MemoryRecord(CEObject@0x1234)", record.ToString()); + Assert.False(typeof(EngineAddressList).IsAssignableTo(typeof(IDisposable))); + Assert.False(typeof(MemoryRecord).IsAssignableTo(typeof(IDisposable))); + } - [Fact] - public void Default_handles_are_null_and_compare_by_native_identity() - { - EngineAddressList first = new(new CEObject(0x1)); - EngineAddressList same = new(new CEObject(0x1)); - EngineAddressList other = new(new CEObject(0x2)); + [Fact] + public void Default_handles_are_null_and_compare_by_native_identity() + { + EngineAddressList first = new(new CEObject(0x1)); + EngineAddressList same = new(new CEObject(0x1)); + EngineAddressList other = new(new CEObject(0x2)); - Assert.True(default(EngineAddressList).IsNull); - Assert.True(default(MemoryRecord).IsNull); - Assert.Equal("AddressList(null)", EngineAddressList.Null.ToString()); - Assert.Equal("MemoryRecord(null)", MemoryRecord.Null.ToString()); - Assert.True(first == same); - Assert.True(first != other); - } + Assert.True(default(EngineAddressList).IsNull); + Assert.True(default(MemoryRecord).IsNull); + Assert.Equal("AddressList(null)", EngineAddressList.Null.ToString()); + Assert.Equal("MemoryRecord(null)", MemoryRecord.Null.ToString()); + Assert.True(first == same); + Assert.True(first != other); + } - [Fact] - public void Memory_record_id_keeps_identity_distinct_from_a_list_index() - { - MemoryRecordId id = new(-12); + [Fact] + public void Memory_record_id_keeps_identity_distinct_from_a_list_index() + { + MemoryRecordId id = new(-12); - Assert.Equal(-12, id.Value); - Assert.Equal("-12", id.ToString()); - Assert.True(id == new MemoryRecordId(-12)); - Assert.True(id != new MemoryRecordId(0)); - Assert.True(id.CompareTo(new MemoryRecordId(4)) < 0); - } + Assert.Equal(-12, id.Value); + Assert.Equal("-12", id.ToString()); + Assert.True(id == new MemoryRecordId(-12)); + Assert.True(id != new MemoryRecordId(0)); + Assert.True(id.CompareTo(new MemoryRecordId(4)) < 0); + } - [Fact] - public void Gui_handle_members_do_not_claim_main_thread_affinity_before_the_live_dispatcher_probe() - { - Assert.False(HasMainThreadOnly(typeof(AddressListAccess).GetMethod(nameof(AddressListAccess.TryGetCurrent)))); - Assert.False(HasMainThreadOnly(typeof(EngineAddressList).GetMethod(nameof(EngineAddressList.TryGetCount)))); - Assert.False(HasMainThreadOnly(typeof(MemoryRecord).GetMethod(nameof(MemoryRecord.TryGetId)))); - Assert.Null(typeof(MemScan).GetMethod("TryGetFoundCount")); - Assert.False(HasMainThreadOnly(typeof(FoundList) - .GetMethod(nameof(FoundList.TryGetCount)))); - } + [Fact] + public void Gui_handle_members_do_not_claim_main_thread_affinity_before_the_live_dispatcher_probe() + { + Assert.False(HasMainThreadOnly(typeof(AddressListAccess).GetMethod(nameof(AddressListAccess.TryGetCurrent)))); + Assert.False(HasMainThreadOnly(typeof(EngineAddressList).GetMethod(nameof(EngineAddressList.TryGetCount)))); + Assert.False(HasMainThreadOnly(typeof(MemoryRecord).GetMethod(nameof(MemoryRecord.TryGetId)))); + Assert.Null(typeof(MemScan).GetMethod("TryGetFoundCount")); + Assert.False(HasMainThreadOnly(typeof(FoundList) + .GetMethod(nameof(FoundList.TryGetCount)))); + } - private static bool HasMainThreadOnly(MethodInfo? method) - { - Assert.NotNull(method); - return Attribute.IsDefined(method!, typeof(MainThreadOnlyAttribute)); - } + private static bool HasMainThreadOnly(MethodInfo? method) + { + Assert.NotNull(method); + return Attribute.IsDefined(method!, typeof(MainThreadOnlyAttribute)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs index 174d1673..506d047b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs @@ -11,194 +11,194 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// public sealed class AllocatedRegionTests { - [Fact] - public void Dispose_releases_the_original_target_address_and_size_exactly_once() - { - AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_3000_0000) }; - var region = Allocate(operations, 12288); - - region.Dispose(); - region.Dispose(); - - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - Assert.Equal(new Address(0x7FF6_3000_0000), operations.LastDeallocatedAddress); - Assert.Equal(new TargetAllocationSize(12288), operations.LastDeallocatedSize); - Assert.Throws(() => _ = region.Address); - Assert.Throws(() => _ = region.Size); - } - - [Fact] - public void Dispose_when_CE_reports_failure_is_no_throw_and_consumes_ownership() - { - AllocationOperationsFake operations = new() { DeallocationResult = false }; - var region = Allocate(operations, 4096); - - region.Dispose(); - region.Dispose(); - - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Release_when_CE_reports_failure_throws_the_expected_failure_and_never_retries() - { - AllocationOperationsFake operations = new() { DeallocationResult = false }; - var region = Allocate(operations, 4096); - - var exception = Assert.Throws(region.Release); - - Assert.Equal("TargetMemoryDeallocate", exception.Operation); - Assert.True(region.IsDisposed); - region.Dispose(); - Assert.Equal(1, operations.DeallocateCalls); - Assert.Throws(region.Release); - } - - [Fact] - public void Release_when_the_protected_lua_call_fails_preserves_the_failure_and_consumes_ownership() - { - EngineLuaException failure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = failure }; - var region = Allocate(operations, 4096); - - var thrown = Assert.Throws(region.Release); - - Assert.Same(failure, thrown); - Assert.True(region.IsDisposed); - region.Dispose(); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Release_when_the_required_global_is_unavailable_preserves_the_distinct_failure() - { - EngineGlobalUnavailableException failure = new("TargetMemoryDeallocate"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; - var region = Allocate(operations, 4096); - - var thrown = Assert.Throws(region.Release); - - Assert.Same(failure, thrown); - Assert.Equal(EngineFailureKind.GlobalUnavailable, thrown.Kind); - Assert.True(region.IsDisposed); - Assert.Throws(region.Release); - } - - [Fact] - public void Release_when_the_binding_contract_fails_preserves_the_failure_and_consumes_ownership() - { - EngineBindingException failure = new("TargetMemoryDeallocate", - "the generated binding returned an incompatible result"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; - var region = Allocate(operations, 4096); - - var thrown = Assert.Throws(region.Release); - - Assert.Same(failure, thrown); - Assert.True(region.IsDisposed); - Assert.Throws(region.Release); - } - - [Fact] - public void Release_when_the_result_cannot_be_marshalled_preserves_the_failure_and_consumes_ownership() - { - EngineMarshallingException failure = new("TargetMemoryDeallocate", EngineMarshallingDirection.Result, - "a boolean", "a table"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; - var region = Allocate(operations, 4096); - - var thrown = Assert.Throws(region.Release); - - Assert.Same(failure, thrown); - Assert.True(region.IsDisposed); - Assert.Throws(region.Release); - } - - [Fact] - public void Dispose_when_the_binding_fails_is_no_throw_and_consumes_ownership() - { - AllocationOperationsFake operations = - new() - { - DeallocationException = new EngineBindingException("TargetMemoryDeallocate", - "the generated binding returned an incompatible result"), - }; - var region = Allocate(operations, 4096); - - region.Dispose(); - - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Dispose_when_an_EngineException_occurs_preserves_the_structured_failure_kind_and_consumes_ownership() - { - EngineException failure = new EngineLuaException("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = failure }; - var region = Allocate(operations, 4096); - - region.Dispose(); - region.Dispose(); - - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, region.LastReleaseOutcome.Status); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, region.LastReleaseOutcome.FailureKind); - } - - [Fact] - public void Dispose_when_the_result_cannot_be_marshalled_is_no_throw_and_consumes_ownership() - { - AllocationOperationsFake operations = - new() - { - DeallocationException = new EngineMarshallingException("TargetMemoryDeallocate", - EngineMarshallingDirection.Result, "a boolean", "a table"), - }; - var region = Allocate(operations, 4096); - - region.Dispose(); - - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Release_when_a_non_engine_deallocator_exception_occurs_records_an_unconfirmed_outcome_without_retrying() - { - AllocationOperationsFake operations = new() - { - DeallocationException = new InvalidOperationException("injected non-Engine deallocation failure"), - }; - var region = Allocate(operations, 4096); - - Assert.Throws(region.Release); - - Assert.True(region.IsDisposed); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, region.LastReleaseOutcome.Status); - Assert.Null(region.LastReleaseOutcome.FailureKind); - region.Dispose(); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Concurrent_dispose_attempts_call_the_deallocator_once() - { - AllocationOperationsFake operations = new(); - var region = Allocate(operations, 4096); - - Parallel.Invoke(region.Dispose, region.Dispose); - - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - } - - private static AllocatedRegion Allocate(AllocationOperationsFake operations, long size) - { - TargetMemoryAllocator allocator = new(operations); - return allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(size))); - } + [Fact] + public void Dispose_releases_the_original_target_address_and_size_exactly_once() + { + AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_3000_0000) }; + AllocatedRegion region = Allocate(operations, 12288); + + region.Dispose(); + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + Assert.Equal(new Address(0x7FF6_3000_0000), operations.LastDeallocatedAddress); + Assert.Equal(new TargetAllocationSize(12288), operations.LastDeallocatedSize); + Assert.Throws(() => _ = region.Address); + Assert.Throws(() => _ = region.Size); + } + + [Fact] + public void Dispose_when_CE_reports_failure_is_no_throw_and_consumes_ownership() + { + AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocatedRegion region = Allocate(operations, 4096); + + region.Dispose(); + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Release_when_CE_reports_failure_throws_the_expected_failure_and_never_retries() + { + AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocatedRegion region = Allocate(operations, 4096); + + EngineOperationFailedException exception = Assert.Throws(region.Release); + + Assert.Equal("TargetMemoryDeallocate", exception.Operation); + Assert.True(region.IsDisposed); + region.Dispose(); + Assert.Equal(1, operations.DeallocateCalls); + Assert.Throws(region.Release); + } + + [Fact] + public void Release_when_the_protected_lua_call_fails_preserves_the_failure_and_consumes_ownership() + { + EngineLuaException failure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); + AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocatedRegion region = Allocate(operations, 4096); + + EngineLuaException thrown = Assert.Throws(region.Release); + + Assert.Same(failure, thrown); + Assert.True(region.IsDisposed); + region.Dispose(); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Release_when_the_required_global_is_unavailable_preserves_the_distinct_failure() + { + EngineGlobalUnavailableException failure = new("TargetMemoryDeallocate"); + AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocatedRegion region = Allocate(operations, 4096); + + EngineGlobalUnavailableException thrown = Assert.Throws(region.Release); + + Assert.Same(failure, thrown); + Assert.Equal(EngineFailureKind.GlobalUnavailable, thrown.Kind); + Assert.True(region.IsDisposed); + Assert.Throws(region.Release); + } + + [Fact] + public void Release_when_the_binding_contract_fails_preserves_the_failure_and_consumes_ownership() + { + EngineBindingException failure = new("TargetMemoryDeallocate", + "the generated binding returned an incompatible result"); + AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocatedRegion region = Allocate(operations, 4096); + + EngineBindingException thrown = Assert.Throws(region.Release); + + Assert.Same(failure, thrown); + Assert.True(region.IsDisposed); + Assert.Throws(region.Release); + } + + [Fact] + public void Release_when_the_result_cannot_be_marshalled_preserves_the_failure_and_consumes_ownership() + { + EngineMarshallingException failure = new("TargetMemoryDeallocate", EngineMarshallingDirection.Result, + "a boolean", "a table"); + AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocatedRegion region = Allocate(operations, 4096); + + EngineMarshallingException thrown = Assert.Throws(region.Release); + + Assert.Same(failure, thrown); + Assert.True(region.IsDisposed); + Assert.Throws(region.Release); + } + + [Fact] + public void Dispose_when_the_binding_fails_is_no_throw_and_consumes_ownership() + { + AllocationOperationsFake operations = + new() + { + DeallocationException = new EngineBindingException("TargetMemoryDeallocate", + "the generated binding returned an incompatible result") + }; + AllocatedRegion region = Allocate(operations, 4096); + + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Dispose_when_an_EngineException_occurs_preserves_the_structured_failure_kind_and_consumes_ownership() + { + EngineException failure = new EngineLuaException("TargetMemoryDeallocate", LuaStatus.RuntimeError); + AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocatedRegion region = Allocate(operations, 4096); + + region.Dispose(); + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, region.LastReleaseOutcome.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, region.LastReleaseOutcome.FailureKind); + } + + [Fact] + public void Dispose_when_the_result_cannot_be_marshalled_is_no_throw_and_consumes_ownership() + { + AllocationOperationsFake operations = + new() + { + DeallocationException = new EngineMarshallingException("TargetMemoryDeallocate", + EngineMarshallingDirection.Result, "a boolean", "a table") + }; + AllocatedRegion region = Allocate(operations, 4096); + + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Release_when_a_non_engine_deallocator_exception_occurs_records_an_unconfirmed_outcome_without_retrying() + { + AllocationOperationsFake operations = new() + { + DeallocationException = new InvalidOperationException("injected non-Engine deallocation failure") + }; + AllocatedRegion region = Allocate(operations, 4096); + + Assert.Throws(region.Release); + + Assert.True(region.IsDisposed); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, region.LastReleaseOutcome.Status); + Assert.Null(region.LastReleaseOutcome.FailureKind); + region.Dispose(); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Concurrent_dispose_attempts_call_the_deallocator_once() + { + AllocationOperationsFake operations = new(); + AllocatedRegion region = Allocate(operations, 4096); + + Parallel.Invoke(region.Dispose, region.Dispose); + + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + } + + private static AllocatedRegion Allocate(AllocationOperationsFake operations, long size) + { + TargetMemoryAllocator allocator = new(operations); + return allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(size))); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs index d1b49008..21349b3f 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs @@ -9,111 +9,176 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// Deterministic managed implementation of the allocation boundary. It deliberately models only the public /// contract, not a Lua fixture or a live Cheat Engine process. /// -internal sealed class AllocationOperationsFake : ITargetMemoryAllocationOperations, ITargetBoundMemoryAllocationOperations +internal sealed class AllocationOperationsFake : ITargetMemoryAllocationOperations, + ITargetBoundMemoryAllocationOperations { - private static readonly TargetProcessIncarnation STarget = new(4242, 1); - - public Address AllocatedAddress { get; set; } = new(0x7FF6_1000_0000); - - public bool AllocationResult { get; set; } = true; - - public Exception? AllocationException { get; set; } - - public bool DeallocationResult { get; set; } = true; - - public Exception? DeallocationException { get; set; } - - public int AllocateCalls { get; private set; } - - public int DeallocateCalls { get; private set; } - - public TargetAllocationRequest LastRequest { get; private set; } - - public Address LastDeallocatedAddress { get; private set; } - - public TargetAllocationSize LastDeallocatedSize { get; private set; } - - public TargetSelectionObservation TargetObservation { get; set; } = TargetSelectionObservation.Qualified(STarget); - - public TargetMemoryAllocationOutcome? BoundAllocationOutcomeOverride { get; set; } - - public bool TryAllocate(TargetAllocationRequest request, out Address address) - { - AllocateCalls++; - LastRequest = request; - if (AllocationException is not null) throw AllocationException; - - address = AllocatedAddress; - return AllocationResult; - } - - public bool TryDeallocate(Address address, TargetAllocationSize size) - { - DeallocateCalls++; - LastDeallocatedAddress = address; - LastDeallocatedSize = size; - if (DeallocationException is not null) throw DeallocationException; - - return DeallocationResult; - } - - public TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, - out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) - { - observation = TargetObservation; - incarnation = observation.Incarnation.GetValueOrDefault(); - if (!observation.IsQualified) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); - - if (BoundAllocationOutcomeOverride.HasValue) - return BoundAllocationOutcomeOverride.GetValueOrDefault(); - - var allocated = TryAllocate(request, out var address); - - // Preserve caller-provided shapes so boundary tests can deliberately exercise - // malformed native results which public factories rightly reject. - return allocated - ? new TargetMemoryAllocationOutcome(TargetMemoryOperationOutcome.Succeeded(), address) - : new TargetMemoryAllocationOutcome( - TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure), - address); - } - - public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, TargetAllocationSize size, - out TargetIdentityCheck targetCheck) - { - var outcome = DeallocateBoundWithOutcome(expected, address, size, out targetCheck); - return targetCheck.IsCurrent && outcome.IsSuccess; - } - - public TargetMemoryOperationOutcome DeallocateBoundWithOutcome(TargetProcessIncarnation expected, Address address, - TargetAllocationSize size, out TargetIdentityCheck targetCheck) - { - targetCheck = GetTargetCheck(expected, TargetObservation); - if (!targetCheck.IsCurrent) - return TargetMemoryOperationOutcome.Failed(targetCheck.Kind is TargetIdentityCheckKind.TargetChanged - or TargetIdentityCheckKind.ProcessReused - ? EngineFailureKind.TargetIdentityMismatch - : EngineFailureKind.TargetIdentityUnavailable); - - return TryDeallocate(address, size) - ? TargetMemoryOperationOutcome.Succeeded() - : TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); - } - - private static TargetIdentityCheck GetTargetCheck(TargetProcessIncarnation expected, - TargetSelectionObservation observation) - { - if (!observation.IsQualified) - return TargetSelection.CreateUnavailableCheck(observation); - - var current = observation.Incarnation.GetValueOrDefault(); - if (current.ProcessId != expected.ProcessId) - return new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observation); - - return current.StartedAtUtcTicks == expected.StartedAtUtcTicks - ? new TargetIdentityCheck(TargetIdentityCheckKind.Current, observation) - : new TargetIdentityCheck(TargetIdentityCheckKind.ProcessReused, observation); - } + private static readonly TargetProcessIncarnation STarget = new(4242, 1); + + public Address AllocatedAddress + { + get; + set; + } = new(0x7FF6_1000_0000); + + public bool AllocationResult + { + get; + set; + } = true; + + public Exception? AllocationException + { + get; + set; + } + + public bool DeallocationResult + { + get; + set; + } = true; + + public Exception? DeallocationException + { + get; + set; + } + + public int AllocateCalls + { + get; + private set; + } + + public int DeallocateCalls + { + get; + private set; + } + + public TargetAllocationRequest LastRequest + { + get; + private set; + } + + public Address LastDeallocatedAddress + { + get; + private set; + } + + public TargetAllocationSize LastDeallocatedSize + { + get; + private set; + } + + public TargetSelectionObservation TargetObservation + { + get; + set; + } = TargetSelectionObservation.Qualified(STarget); + + public TargetMemoryAllocationOutcome? BoundAllocationOutcomeOverride + { + get; + set; + } + + public TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, + out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) + { + observation = TargetObservation; + incarnation = observation.Incarnation.GetValueOrDefault(); + if (!observation.IsQualified) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable)); + } + + if (BoundAllocationOutcomeOverride.HasValue) + { + return BoundAllocationOutcomeOverride.GetValueOrDefault(); + } + + bool allocated = TryAllocate(request, out Address address); + + // Preserve caller-provided shapes so boundary tests can deliberately exercise + // malformed native results which public factories rightly reject. + return allocated + ? new TargetMemoryAllocationOutcome(TargetMemoryOperationOutcome.Succeeded(), address) + : new TargetMemoryAllocationOutcome( + TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure), + address); + } + + public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, TargetAllocationSize size, + out TargetIdentityCheck targetCheck) + { + TargetMemoryOperationOutcome outcome = DeallocateBoundWithOutcome(expected, address, size, out targetCheck); + return targetCheck.IsCurrent && outcome.IsSuccess; + } + + public TargetMemoryOperationOutcome DeallocateBoundWithOutcome(TargetProcessIncarnation expected, Address address, + TargetAllocationSize size, out TargetIdentityCheck targetCheck) + { + targetCheck = GetTargetCheck(expected, TargetObservation); + if (!targetCheck.IsCurrent) + { + return TargetMemoryOperationOutcome.Failed(targetCheck.Kind is TargetIdentityCheckKind.TargetChanged + or TargetIdentityCheckKind.ProcessReused + ? EngineFailureKind.TargetIdentityMismatch + : EngineFailureKind.TargetIdentityUnavailable); + } + + return TryDeallocate(address, size) + ? TargetMemoryOperationOutcome.Succeeded() + : TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); + } + + public bool TryAllocate(TargetAllocationRequest request, out Address address) + { + AllocateCalls++; + LastRequest = request; + if (AllocationException is not null) + { + throw AllocationException; + } + + address = AllocatedAddress; + return AllocationResult; + } + + public bool TryDeallocate(Address address, TargetAllocationSize size) + { + DeallocateCalls++; + LastDeallocatedAddress = address; + LastDeallocatedSize = size; + if (DeallocationException is not null) + { + throw DeallocationException; + } + + return DeallocationResult; + } + + private static TargetIdentityCheck GetTargetCheck(TargetProcessIncarnation expected, + TargetSelectionObservation observation) + { + if (!observation.IsQualified) + { + return TargetSelection.CreateUnavailableCheck(observation); + } + + TargetProcessIncarnation current = observation.Incarnation.GetValueOrDefault(); + if (current.ProcessId != expected.ProcessId) + { + return new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observation); + } + + return current.StartedAtUtcTicks == expected.StartedAtUtcTicks + ? new TargetIdentityCheck(TargetIdentityCheckKind.Current, observation) + : new TargetIdentityCheck(TargetIdentityCheckKind.ProcessReused, observation); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/HostAddressTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/HostAddressTests.cs index e7deb52f..70797504 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/HostAddressTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/HostAddressTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Reflection; + using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Values; @@ -10,30 +11,35 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// public sealed class HostAddressTests { - [Fact] - public void HostAddress_wraps_only_a_native_host_value() - { - var value = unchecked((nuint)0x7FF6_4000_0000UL); - HostAddress address = new(value); + [Fact] + public void HostAddress_wraps_only_a_native_host_value() + { + UIntPtr value = unchecked((nuint) 0x7FF6_4000_0000UL); + HostAddress address = new(value); - Assert.Equal(value, address.Value); - Assert.False(address.IsZero); - Assert.Equal("00007FF640000000", address.ToString("X16", CultureInfo.InvariantCulture)); - Assert.True(HostAddress.Zero.IsZero); - Assert.NotEqual(typeof(HostAddress), typeof(Address)); - } + Assert.Equal(value, address.Value); + Assert.False(address.IsZero); + Assert.Equal("00007FF640000000", address.ToString("X16", CultureInfo.InvariantCulture)); + Assert.True(HostAddress.Zero.IsZero); + Assert.NotEqual(typeof(HostAddress), typeof(Address)); + } - [Fact] - public void HostAddress_declares_no_conversion_operator_to_or_from_target_Address() - { - var methods = typeof(HostAddress).GetMethods(BindingFlags.Public | BindingFlags.Static); - foreach (var method in methods) - { - if (method.Name is not "op_Implicit" and not "op_Explicit") continue; + [Fact] + public void HostAddress_declares_no_conversion_operator_to_or_from_target_Address() + { + MethodInfo[] methods = typeof(HostAddress).GetMethods(BindingFlags.Public | BindingFlags.Static); + foreach (MethodInfo method in methods) + { + if (method.Name is not "op_Implicit" and not "op_Explicit") + { + continue; + } - Assert.NotEqual(typeof(Address), method.ReturnType); - foreach (var parameter in method.GetParameters()) - Assert.NotEqual(typeof(Address), parameter.ParameterType); - } - } + Assert.NotEqual(typeof(Address), method.ReturnType); + foreach (ParameterInfo parameter in method.GetParameters()) + { + Assert.NotEqual(typeof(Address), parameter.ParameterType); + } + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs index 19c59b06..40c41ce9 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs @@ -1,13 +1,14 @@ -using System; using System.Text; + using CheatEngine.SDK.Engine.Allocation; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Errors; -using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Allocation; @@ -16,421 +17,427 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; [Trait("Category", "NativeLua")] public sealed class LuaTargetMemoryAllocationOperationsTests { - [Fact] - public void Default_allocator_passes_all_documented_allocation_arguments_and_releases_the_owned_region() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallAllocationGlobals(L); - TargetMemoryAllocator allocator = new(); - var request = new TargetAllocationRequest(new TargetAllocationSize(4096), new Address(0x7000), - MemoryProtection.ExecuteReadWrite); - - using var region = allocator.Allocate(request); - - Assert.Equal(new Address(0x7FF6_1000_0000), region.Address); - Assert.Equal(new TargetAllocationSize(4096), region.Size); - AssertLuaInteger(L, "allocation_argument_count", 3); - AssertLuaInteger(L, "allocation_size", 4096); - AssertLuaInteger(L, "allocation_preferred", 0x7000); - AssertLuaInteger(L, "allocation_protection", (long)MemoryProtection.ExecuteReadWrite); - - region.Release(); - - AssertLuaInteger(L, "deallocation_address", 0x7FF6_1000_0000); - AssertLuaInteger(L, "deallocation_size", 4096); - Assert.True(region.IsDisposed); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryAllocate_with_protection_but_no_preferred_address_preserves_the_nil_optional_slot() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallAllocationGlobals(L); - var operations = LuaTargetMemoryAllocationOperations.Instance; - var request = new TargetAllocationRequest(new TargetAllocationSize(8192), null, MemoryProtection.ReadWrite); - - Assert.True(operations.TryAllocate(request, out var address)); - Assert.Equal(new Address(0x7FF6_1000_0000), address); - AssertLuaInteger(L, "allocation_argument_count", 3); - AssertLuaBoolean(L, "allocation_preferred_is_nil", true); - AssertLuaInteger(L, "allocation_protection", (long)MemoryProtection.ReadWrite); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryAllocate_nil_result_is_an_expected_failure_and_does_not_strand_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "function allocateMemory() return nil end"u8); - - Assert.False(LuaTargetMemoryAllocationOperations.Instance.TryAllocate( - new TargetAllocationRequest(new TargetAllocationSize(4096)), out var address)); - Assert.Equal(Address.Zero, address); - Assert.Equal(0, L.Top); - } - - [Theory] - [InlineData("function allocateMemory() return nil end", TargetMemoryOperationOutcomeKind.ExpectedFailure, 0)] - [InlineData("function allocateMemory() error('fixture allocation failure') end", - TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, 2)] - [InlineData("function allocateMemory() return true end", TargetMemoryOperationOutcomeKind.MarshallingFailure, 0)] - public void AllocateWithOutcome_classifies_documented_result_and_execution_categories_without_error_text( - string fixture, TargetMemoryOperationOutcomeKind expectedKind, int expectedLuaStatus) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, Encoding.UTF8.GetBytes(fixture)); - - var outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( - new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(expectedKind, outcome.Operation.Kind); - Assert.Equal(new LuaStatus(expectedLuaStatus), outcome.Operation.LuaStatus); - Assert.Equal(Address.Zero, outcome.Address); - Assert.Equal(0, L.Top); - } - - [Fact] - public void AllocateWithOutcome_missing_global_is_distinct_from_a_present_global_that_throws() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - - var outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( - new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(TargetMemoryOperationOutcomeKind.GlobalUnavailable, outcome.Operation.Kind); - Assert.Equal(EngineFailureKind.GlobalUnavailable, outcome.Operation.FailureKind); - Assert.Equal(LuaStatus.Ok, outcome.Operation.LuaStatus); - Assert.Equal(Address.Zero, outcome.Address); - Assert.Equal(0, L.Top); - } - - [Fact] - public void AllocateWithOutcome_global_resolution_failure_preserves_the_protected_status() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - setmetatable(_G, { - __index = function(_, name) - if name == "allocateMemory" then error("fixture global lookup failure") end - end - }) - """u8); - - var outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( - new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Operation.Kind); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.Operation.FailureKind); - Assert.Equal(LuaStatus.RuntimeError, outcome.Operation.LuaStatus); - Assert.Equal(Address.Zero, outcome.Address); - Assert.Equal(0, L.Top); - } - - [Fact] - public void AllocateWithOutcome_expected_nil_result_is_allocation_free_after_warmup() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "function allocateMemory() return nil end"u8); - var request = new TargetAllocationRequest(new TargetAllocationSize(4096)); - var kind = TargetMemoryOperationOutcomeKind.Unspecified; - var address = Address.Zero; - - AllocationGate.AssertZero(() => - { - var outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome(request); - kind = outcome.Operation.Kind; - address = outcome.Address; - }); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, kind); - Assert.Equal(Address.Zero, address); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Allocate_when_the_global_is_missing_throws_the_stable_global_unavailable_error() - { - EngineTest.RequireNativeLua(); - using NativeLuaState missingState = new(); - using (HostScope missingScope = new(missingState)) - { - InstallCurrentTarget(missingScope.State); - TargetMemoryAllocator allocator = new(); - var missing = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - Assert.Equal("TargetMemoryAllocate", missing.Operation); - Assert.Equal(0, missingScope.State.Top); - } - } - - [Fact] - public void Allocate_when_lua_raises_throws_the_stable_lua_error() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "function allocateMemory() error('fixture allocation failure') end"u8); - var lua = Assert.Throws(() => LuaTargetMemoryAllocationOperations.Instance.TryAllocate( - new TargetAllocationRequest(new TargetAllocationSize(4096)), out _)); - Assert.Equal("TargetMemoryAllocate", lua.Operation); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Allocate_when_the_result_is_not_an_address_or_nil_throws_the_stable_marshalling_error() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "function allocateMemory() return true end"u8); - var malformed = Assert.Throws(() => - LuaTargetMemoryAllocationOperations.Instance.TryAllocate( - new TargetAllocationRequest(new TargetAllocationSize(4096)), out _)); - Assert.Equal("TargetMemoryAllocate", malformed.Operation); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Release_when_deallocation_returns_false_throws_the_stable_expected_failure() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallAllocationGlobals(L); - TargetMemoryAllocator allocator = new(); - - EngineTest.Run(L, "function deAlloc() return false end"u8); - var expectedFailure = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))).Release()); - Assert.Equal("TargetMemoryDeallocate", expectedFailure.Operation); - Assert.Equal(0, L.Top); - } - - [Fact] - public void ReleaseWithOutcome_false_result_is_an_expected_failure_without_a_lua_error() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallAllocationGlobals(L); - TargetMemoryAllocator allocator = new(); - EngineTest.Run(L, "function deAlloc() return false end"u8); - var region = allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))); - - var outcome = region.ReleaseWithOutcome(); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Kind); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.FailureKind); - Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); - Assert.True(region.IsDisposed); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Release_when_deallocation_lua_raises_throws_the_stable_lua_error() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallAllocationGlobals(L); - TargetMemoryAllocator allocator = new(); - EngineTest.Run(L, "function deAlloc() error('fixture deallocation failure') end"u8); - var lua = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))).Release()); - Assert.Equal("TargetMemoryDeallocate", lua.Operation); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Release_when_deallocation_returns_a_nonboolean_throws_the_stable_marshalling_error() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallAllocationGlobals(L); - TargetMemoryAllocator allocator = new(); - EngineTest.Run(L, "function deAlloc() return 1 end"u8); - var malformed = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))).Release()); - Assert.Equal("TargetMemoryDeallocate", malformed.Operation); - Assert.Equal(0, L.Top); - } - - [Theory] - [InlineData(0UL, 4096)] - [InlineData(0x7FF610000000UL, 0)] - public void DeallocateWithOutcome_with_an_invalid_request_refuses_before_calling_the_Lua_boundary(ulong rawAddress, - int rawSize) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - deallocation_call_count = 0 - function deAlloc() - deallocation_call_count = deallocation_call_count + 1 - return true - end - """u8); - - TargetAllocationSize size = rawSize == 0 ? default : new TargetAllocationSize(rawSize); - var outcome = LuaTargetMemoryAllocationOperations.Instance.DeallocateWithOutcome(new Address(rawAddress), size); - - Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Kind); - Assert.Equal(EngineFailureKind.MarshallingFailure, outcome.FailureKind); - AssertLuaInteger(L, "deallocation_call_count", 0); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Bound_allocate_with_no_selected_target_returns_an_identity_refusal_before_allocating() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - allocation_call_count = 0 - function getOpenedProcessID() return 0 end - function allocateMemory() - allocation_call_count = allocation_call_count + 1 - return 0x7FF610000000 - end - """u8); - var operations = (ITargetBoundMemoryAllocationOperations)LuaTargetMemoryAllocationOperations.Instance; - - var outcome = operations.AllocateBoundWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096)), - out var incarnation, out var observation); - - Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, observation.Status); - Assert.Equal(default(TargetProcessIncarnation), incarnation); - Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); - Assert.Equal(Address.Zero, outcome.Address); - AssertLuaInteger(L, "allocation_call_count", 0); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Bound_deallocate_with_a_changed_target_returns_an_identity_mismatch_without_deallocating() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - InstallCurrentTarget(L); - EngineTest.Run(L, """ - deallocation_call_count = 0 - function deAlloc() - deallocation_call_count = deallocation_call_count + 1 - return true - end - """u8); - var operations = (ITargetBoundMemoryAllocationOperations)LuaTargetMemoryAllocationOperations.Instance; - TargetProcessIncarnation differentTarget = new(Environment.ProcessId + 1, 1001); - - var outcome = operations.DeallocateBoundWithOutcome(differentTarget, new Address(0x7FF610000000), - new TargetAllocationSize(4096), out var targetCheck); - - Assert.Equal(TargetIdentityCheckKind.TargetChanged, targetCheck.Kind); - Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityMismatch, outcome.Kind); - Assert.Equal(EngineFailureKind.TargetIdentityMismatch, outcome.FailureKind); - AssertLuaInteger(L, "deallocation_call_count", 0); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Bound_deallocate_with_an_invalid_request_returns_a_marshalling_failure_without_observing_a_target() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var operations = (ITargetBoundMemoryAllocationOperations)LuaTargetMemoryAllocationOperations.Instance; - TargetProcessIncarnation expected = new(4101, 1001); - - var outcome = operations.DeallocateBoundWithOutcome(expected, Address.Zero, new TargetAllocationSize(4096), - out var targetCheck); - - Assert.Equal(TargetIdentityCheckKind.Unspecified, targetCheck.Kind); - Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Kind); - Assert.Equal(EngineFailureKind.MarshallingFailure, outcome.FailureKind); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Allocation_operations_while_detached_retain_the_normal_lifecycle_failure() - { - LuaRuntime.Detach(); - - Assert.Throws(() => LuaTargetMemoryAllocationOperations.Instance.TryAllocate( - new TargetAllocationRequest(new TargetAllocationSize(4096)), out _)); - } - - private static void InstallAllocationGlobals(CheatEngine.SDK.Lua.State.LuaState state) - { - InstallCurrentTarget(state); - EngineTest.Run(state, """ - function allocateMemory(...) - allocation_argument_count = select('#', ...) - allocation_size = select(1, ...) - allocation_preferred = select(2, ...) - allocation_preferred_is_nil = allocation_preferred == nil - allocation_protection = select(3, ...) - return 0x7FF610000000 - end - function deAlloc(address, size) - deallocation_address = address - deallocation_size = size - return true - end - """u8); - } - - private static void InstallCurrentTarget(CheatEngine.SDK.Lua.State.LuaState state) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + - Environment.ProcessId + " end")); - } - - private static void AssertLuaInteger(CheatEngine.SDK.Lua.State.LuaState state, string name, long expected) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("return " + name), 1); - Assert.Equal(expected, EngineTest.ReadInteger(state, -1)); - state.Pop(1); - } - - private static void AssertLuaBoolean(CheatEngine.SDK.Lua.State.LuaState state, string name, bool expected) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("return " + name), 1); - Assert.Equal(expected, state.ToBoolean(-1)); - state.Pop(1); - } + [Fact] + public void Default_allocator_passes_all_documented_allocation_arguments_and_releases_the_owned_region() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + TargetAllocationRequest request = new(new TargetAllocationSize(4096), new Address(0x7000), + MemoryProtection.ExecuteReadWrite); + + using AllocatedRegion region = allocator.Allocate(request); + + Assert.Equal(new Address(0x7FF6_1000_0000), region.Address); + Assert.Equal(new TargetAllocationSize(4096), region.Size); + AssertLuaInteger(L, "allocation_argument_count", 3); + AssertLuaInteger(L, "allocation_size", 4096); + AssertLuaInteger(L, "allocation_preferred", 0x7000); + AssertLuaInteger(L, "allocation_protection", (long) MemoryProtection.ExecuteReadWrite); + + region.Release(); + + AssertLuaInteger(L, "deallocation_address", 0x7FF6_1000_0000); + AssertLuaInteger(L, "deallocation_size", 4096); + Assert.True(region.IsDisposed); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryAllocate_with_protection_but_no_preferred_address_preserves_the_nil_optional_slot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + LuaTargetMemoryAllocationOperations operations = LuaTargetMemoryAllocationOperations.Instance; + TargetAllocationRequest request = new(new TargetAllocationSize(8192), null, MemoryProtection.ReadWrite); + + Assert.True(operations.TryAllocate(request, out Address address)); + Assert.Equal(new Address(0x7FF6_1000_0000), address); + AssertLuaInteger(L, "allocation_argument_count", 3); + AssertLuaBoolean(L, "allocation_preferred_is_nil", true); + AssertLuaInteger(L, "allocation_protection", (long) MemoryProtection.ReadWrite); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryAllocate_nil_result_is_an_expected_failure_and_does_not_strand_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "function allocateMemory() return nil end"u8); + + Assert.False(LuaTargetMemoryAllocationOperations.Instance.TryAllocate( + new TargetAllocationRequest(new TargetAllocationSize(4096)), out Address address)); + Assert.Equal(Address.Zero, address); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData("function allocateMemory() return nil end", TargetMemoryOperationOutcomeKind.ExpectedFailure, 0)] + [InlineData("function allocateMemory() error('fixture allocation failure') end", + TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, 2)] + [InlineData("function allocateMemory() return true end", TargetMemoryOperationOutcomeKind.MarshallingFailure, 0)] + public void AllocateWithOutcome_classifies_documented_result_and_execution_categories_without_error_text( + string fixture, TargetMemoryOperationOutcomeKind expectedKind, int expectedLuaStatus) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, Encoding.UTF8.GetBytes(fixture)); + + TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( + new TargetAllocationRequest(new TargetAllocationSize(4096))); + + Assert.Equal(expectedKind, outcome.Operation.Kind); + Assert.Equal(new LuaStatus(expectedLuaStatus), outcome.Operation.LuaStatus); + Assert.Equal(Address.Zero, outcome.Address); + Assert.Equal(0, L.Top); + } + + [Fact] + public void AllocateWithOutcome_missing_global_is_distinct_from_a_present_global_that_throws() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + + TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( + new TargetAllocationRequest(new TargetAllocationSize(4096))); + + Assert.Equal(TargetMemoryOperationOutcomeKind.GlobalUnavailable, outcome.Operation.Kind); + Assert.Equal(EngineFailureKind.GlobalUnavailable, outcome.Operation.FailureKind); + Assert.Equal(LuaStatus.Ok, outcome.Operation.LuaStatus); + Assert.Equal(Address.Zero, outcome.Address); + Assert.Equal(0, L.Top); + } + + [Fact] + public void AllocateWithOutcome_global_resolution_failure_preserves_the_protected_status() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + setmetatable(_G, { + __index = function(_, name) + if name == "allocateMemory" then error("fixture global lookup failure") end + end + }) + """u8); + + TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( + new TargetAllocationRequest(new TargetAllocationSize(4096))); + + Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Operation.Kind); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.Operation.FailureKind); + Assert.Equal(LuaStatus.RuntimeError, outcome.Operation.LuaStatus); + Assert.Equal(Address.Zero, outcome.Address); + Assert.Equal(0, L.Top); + } + + [Fact] + public void AllocateWithOutcome_expected_nil_result_is_allocation_free_after_warmup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "function allocateMemory() return nil end"u8); + TargetAllocationRequest request = new(new TargetAllocationSize(4096)); + TargetMemoryOperationOutcomeKind kind = TargetMemoryOperationOutcomeKind.Unspecified; + Address address = Address.Zero; + + AllocationGate.AssertZero(() => + { + TargetMemoryAllocationOutcome outcome = + LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome(request); + kind = outcome.Operation.Kind; + address = outcome.Address; + }); + + Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, kind); + Assert.Equal(Address.Zero, address); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Allocate_when_the_global_is_missing_throws_the_stable_global_unavailable_error() + { + EngineTest.RequireNativeLua(); + using NativeLuaState missingState = new(); + using (HostScope missingScope = new(missingState)) + { + InstallCurrentTarget(missingScope.State); + TargetMemoryAllocator allocator = new(); + EngineGlobalUnavailableException missing = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + Assert.Equal("TargetMemoryAllocate", missing.Operation); + Assert.Equal(0, missingScope.State.Top); + } + } + + [Fact] + public void Allocate_when_lua_raises_throws_the_stable_lua_error() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "function allocateMemory() error('fixture allocation failure') end"u8); + EngineLuaException lua = Assert.Throws(() => + LuaTargetMemoryAllocationOperations.Instance.TryAllocate( + new TargetAllocationRequest(new TargetAllocationSize(4096)), out _)); + Assert.Equal("TargetMemoryAllocate", lua.Operation); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Allocate_when_the_result_is_not_an_address_or_nil_throws_the_stable_marshalling_error() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "function allocateMemory() return true end"u8); + EngineMarshallingException malformed = Assert.Throws(() => + LuaTargetMemoryAllocationOperations.Instance.TryAllocate( + new TargetAllocationRequest(new TargetAllocationSize(4096)), out _)); + Assert.Equal("TargetMemoryAllocate", malformed.Operation); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_when_deallocation_returns_false_throws_the_stable_expected_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + + EngineTest.Run(L, "function deAlloc() return false end"u8); + EngineOperationFailedException expectedFailure = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))).Release()); + Assert.Equal("TargetMemoryDeallocate", expectedFailure.Operation); + Assert.Equal(0, L.Top); + } + + [Fact] + public void ReleaseWithOutcome_false_result_is_an_expected_failure_without_a_lua_error() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + EngineTest.Run(L, "function deAlloc() return false end"u8); + AllocatedRegion region = allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))); + + TargetMemoryOperationOutcome outcome = region.ReleaseWithOutcome(); + + Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Kind); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.FailureKind); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Assert.True(region.IsDisposed); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_when_deallocation_lua_raises_throws_the_stable_lua_error() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + EngineTest.Run(L, "function deAlloc() error('fixture deallocation failure') end"u8); + EngineLuaException lua = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))).Release()); + Assert.Equal("TargetMemoryDeallocate", lua.Operation); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_when_deallocation_returns_a_nonboolean_throws_the_stable_marshalling_error() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + EngineTest.Run(L, "function deAlloc() return 1 end"u8); + EngineMarshallingException malformed = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))).Release()); + Assert.Equal("TargetMemoryDeallocate", malformed.Operation); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData(0UL, 4096)] + [InlineData(0x7FF610000000UL, 0)] + public void DeallocateWithOutcome_with_an_invalid_request_refuses_before_calling_the_Lua_boundary(ulong rawAddress, + int rawSize) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + deallocation_call_count = 0 + function deAlloc() + deallocation_call_count = deallocation_call_count + 1 + return true + end + """u8); + + TargetAllocationSize size = rawSize == 0 ? default : new TargetAllocationSize(rawSize); + TargetMemoryOperationOutcome outcome = + LuaTargetMemoryAllocationOperations.Instance.DeallocateWithOutcome(new Address(rawAddress), size); + + Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Kind); + Assert.Equal(EngineFailureKind.MarshallingFailure, outcome.FailureKind); + AssertLuaInteger(L, "deallocation_call_count", 0); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Bound_allocate_with_no_selected_target_returns_an_identity_refusal_before_allocating() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + allocation_call_count = 0 + function getOpenedProcessID() return 0 end + function allocateMemory() + allocation_call_count = allocation_call_count + 1 + return 0x7FF610000000 + end + """u8); + ITargetBoundMemoryAllocationOperations operations = LuaTargetMemoryAllocationOperations.Instance; + + TargetMemoryAllocationOutcome outcome = operations.AllocateBoundWithOutcome( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation); + + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, observation.Status); + Assert.Equal(default, incarnation); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); + Assert.Equal(Address.Zero, outcome.Address); + AssertLuaInteger(L, "allocation_call_count", 0); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Bound_deallocate_with_a_changed_target_returns_an_identity_mismatch_without_deallocating() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallCurrentTarget(L); + EngineTest.Run(L, """ + deallocation_call_count = 0 + function deAlloc() + deallocation_call_count = deallocation_call_count + 1 + return true + end + """u8); + ITargetBoundMemoryAllocationOperations operations = LuaTargetMemoryAllocationOperations.Instance; + TargetProcessIncarnation differentTarget = new(Environment.ProcessId + 1, 1001); + + TargetMemoryOperationOutcome outcome = operations.DeallocateBoundWithOutcome(differentTarget, + new Address(0x7FF610000000), + new TargetAllocationSize(4096), out TargetIdentityCheck targetCheck); + + Assert.Equal(TargetIdentityCheckKind.TargetChanged, targetCheck.Kind); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityMismatch, outcome.Kind); + Assert.Equal(EngineFailureKind.TargetIdentityMismatch, outcome.FailureKind); + AssertLuaInteger(L, "deallocation_call_count", 0); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Bound_deallocate_with_an_invalid_request_returns_a_marshalling_failure_without_observing_a_target() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + ITargetBoundMemoryAllocationOperations operations = LuaTargetMemoryAllocationOperations.Instance; + TargetProcessIncarnation expected = new(4101, 1001); + + TargetMemoryOperationOutcome outcome = operations.DeallocateBoundWithOutcome(expected, Address.Zero, + new TargetAllocationSize(4096), + out TargetIdentityCheck targetCheck); + + Assert.Equal(TargetIdentityCheckKind.Unspecified, targetCheck.Kind); + Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Kind); + Assert.Equal(EngineFailureKind.MarshallingFailure, outcome.FailureKind); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Allocation_operations_while_detached_retain_the_normal_lifecycle_failure() + { + LuaRuntime.Detach(); + + Assert.Throws(() => LuaTargetMemoryAllocationOperations.Instance.TryAllocate( + new TargetAllocationRequest(new TargetAllocationSize(4096)), out _)); + } + + private static void InstallAllocationGlobals(LuaState state) + { + InstallCurrentTarget(state); + EngineTest.Run(state, """ + function allocateMemory(...) + allocation_argument_count = select('#', ...) + allocation_size = select(1, ...) + allocation_preferred = select(2, ...) + allocation_preferred_is_nil = allocation_preferred == nil + allocation_protection = select(3, ...) + return 0x7FF610000000 + end + function deAlloc(address, size) + deallocation_address = address + deallocation_size = size + return true + end + """u8); + } + + private static void InstallCurrentTarget(LuaState state) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + + Environment.ProcessId + " end")); + } + + private static void AssertLuaInteger(LuaState state, string name, long expected) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("return " + name), 1); + Assert.Equal(expected, EngineTest.ReadInteger(state, -1)); + state.Pop(1); + } + + private static void AssertLuaBoolean(LuaState state, string name, bool expected) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("return " + name), 1); + Assert.Equal(expected, state.ToBoolean(-1)); + state.Pop(1); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetAllocationRequestTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetAllocationRequestTests.cs index 4189feb2..4af5aee7 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetAllocationRequestTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetAllocationRequestTests.cs @@ -9,33 +9,33 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// public sealed class TargetAllocationRequestTests { - [Fact] - public void Constructor_with_valid_input_preserves_the_target_request() - { - TargetAllocationSize size = new(4096); - Address preferred = new(0x7FF6_2000_0000); - TargetAllocationRequest request = new(size, preferred, MemoryProtection.ExecuteReadWrite); + [Fact] + public void Constructor_with_valid_input_preserves_the_target_request() + { + TargetAllocationSize size = new(4096); + Address preferred = new(0x7FF6_2000_0000); + TargetAllocationRequest request = new(size, preferred, MemoryProtection.ExecuteReadWrite); - Assert.Equal(size, request.Size); - Assert.Equal(preferred, request.PreferredBaseAddress); - Assert.Equal(MemoryProtection.ExecuteReadWrite, request.Protection); - } + Assert.Equal(size, request.Size); + Assert.Equal(preferred, request.PreferredBaseAddress); + Assert.Equal(MemoryProtection.ExecuteReadWrite, request.Protection); + } - [Theory] - [InlineData(0L)] - [InlineData(-1L)] - public void TargetAllocationSize_with_a_nonpositive_value_throws(long value) - { - Assert.Throws(() => new TargetAllocationSize(value)); - } + [Theory] + [InlineData(0L)] + [InlineData(-1L)] + public void TargetAllocationSize_with_a_nonpositive_value_throws(long value) + { + Assert.Throws(() => new TargetAllocationSize(value)); + } - [Fact] - public void Allocator_with_the_default_request_rejects_it_before_the_binding() - { - AllocationOperationsFake operations = new(); - TargetMemoryAllocator allocator = new(operations); + [Fact] + public void Allocator_with_the_default_request_rejects_it_before_the_binding() + { + AllocationOperationsFake operations = new(); + TargetMemoryAllocator allocator = new(operations); - Assert.Throws(() => allocator.Allocate(default)); - Assert.Equal(0, operations.AllocateCalls); - } + Assert.Throws(() => allocator.Allocate(default)); + Assert.Equal(0, operations.AllocateCalls); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs index 79e2aa90..90efda69 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs @@ -8,261 +8,301 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// Deterministic target-routing regressions for allocation owners without a live Cheat Engine host. public sealed class TargetBoundAllocationTests { - [Fact] - public void Release_on_the_original_qualified_target_routes_once_to_that_target() - { - TargetContext first = new(4101, 1001); - TargetContext second = new(4102, 2002); - ControlledTargetOperations operations = new(first); - TargetMemoryAllocator allocator = new(operations); - - var region = allocator.Allocate(CreateRequest()); - region.Release(); - - Assert.Equal(1, first.AllocationCalls); - Assert.Equal(1, first.DeallocationCalls); - Assert.Equal(0, second.DeallocationCalls); - Assert.Equal(TargetReleaseStatus.Released, region.LastReleaseOutcome.Status); - Assert.Equal(0, operations.ExternalSelectionTransitions); - } - - [Fact] - public void Dispose_after_an_external_target_switch_refuses_cleanup_without_selecting_or_deallocating() - { - TargetContext first = new(4101, 1001); - TargetContext second = new(4102, 2002); - ControlledTargetOperations operations = new(first); - TargetMemoryAllocator allocator = new(operations); - var region = allocator.Allocate(CreateRequest()); - - operations.SelectExternally(second); - region.Dispose(); - region.Dispose(); - - Assert.True(region.IsDisposed); - Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, region.LastReleaseOutcome.Status); - Assert.Equal(0, first.DeallocationCalls); - Assert.Equal(0, second.DeallocationCalls); - Assert.Same(second, operations.Current); - Assert.Equal(1, operations.ExternalSelectionTransitions); - } - - [Fact] - public void Release_after_an_external_target_switch_reports_the_refusal_without_deallocating_either_target() - { - TargetContext first = new(4101, 1001); - TargetContext second = new(4102, 2002); - ControlledTargetOperations operations = new(first); - TargetMemoryAllocator allocator = new(operations); - var region = allocator.Allocate(CreateRequest()); - - operations.SelectExternally(second); - var exception = Assert.Throws(region.Release); - - Assert.Equal(TargetIdentityCheckKind.TargetChanged, exception.Check.Kind); - Assert.Equal(0, first.DeallocationCalls); - Assert.Equal(0, second.DeallocationCalls); - Assert.Same(second, operations.Current); - Assert.Throws(region.Release); - } - - [Fact] - public void Dispose_after_an_external_A_to_B_to_A_switch_uses_only_the_current_original_incarnation() - { - TargetContext first = new(4101, 1001); - TargetContext second = new(4102, 2002); - ControlledTargetOperations operations = new(first); - TargetMemoryAllocator allocator = new(operations); - var region = allocator.Allocate(CreateRequest()); - - operations.SelectExternally(second); - operations.SelectExternally(first); - region.Dispose(); - - Assert.Equal(TargetReleaseStatus.Released, region.LastReleaseOutcome.Status); - Assert.Equal(1, first.DeallocationCalls); - Assert.Equal(0, second.DeallocationCalls); - Assert.Same(first, operations.Current); - Assert.Equal(2, operations.ExternalSelectionTransitions); - } - - [Fact] - public void Release_after_target_termination_refuses_cleanup_and_records_no_target() - { - TargetContext first = new(4101, 1001); - ControlledTargetOperations operations = new(first); - TargetMemoryAllocator allocator = new(operations); - var region = allocator.Allocate(CreateRequest()); - - operations.TerminateCurrent(); - var outcome = region.ReleaseWithTargetOutcome(); - - Assert.Equal(TargetReleaseStatus.RefusedNoTarget, outcome.Status); - Assert.Equal(0, first.DeallocationCalls); - Assert.Null(operations.Current); - region.Dispose(); - Assert.Equal(0, first.DeallocationCalls); - } - - [Fact] - public void Release_after_PID_reuse_refuses_the_new_incarnation() - { - TargetContext original = new(4101, 1001); - TargetContext reused = new(4101, 3003); - ControlledTargetOperations operations = new(original); - TargetMemoryAllocator allocator = new(operations); - var region = allocator.Allocate(CreateRequest()); - - operations.ReusePid(reused); - var outcome = region.ReleaseWithTargetOutcome(); - - Assert.Equal(TargetReleaseStatus.RefusedProcessReused, outcome.Status); - Assert.Equal(0, original.DeallocationCalls); - Assert.Equal(0, reused.DeallocationCalls); - Assert.Same(reused, operations.Current); - } - - [Fact] - public void Allocate_when_target_identity_is_unavailable_refuses_before_the_effectful_operation() - { - ControlledTargetOperations operations = new(current: null); - TargetMemoryAllocator allocator = new(operations); - - var exception = Assert.Throws(() => allocator.Allocate(CreateRequest())); - var outcome = allocator.AllocateWithOutcome(CreateRequest()); - - Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, exception.Check.Kind); - Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); - Assert.Equal(0, operations.TotalAllocationCalls); - Assert.Equal(0, operations.TotalDeallocationCalls); - } - - private static TargetAllocationRequest CreateRequest() - { - return new TargetAllocationRequest(new TargetAllocationSize(4096)); - } - - private sealed class ControlledTargetOperations : ITargetMemoryAllocationOperations, - ITargetBoundMemoryAllocationOperations - { - public ControlledTargetOperations(TargetContext? current) - { - Current = current; - } - - public TargetContext? Current { get; private set; } - - public int ExternalSelectionTransitions { get; private set; } - - public int TotalAllocationCalls { get; private set; } - - public int TotalDeallocationCalls { get; private set; } - - public void SelectExternally(TargetContext target) - { - Current = target; - ExternalSelectionTransitions++; - } - - public void TerminateCurrent() - { - Current = null; - ExternalSelectionTransitions++; - } - - public void ReusePid(TargetContext replacement) - { - Current = replacement; - ExternalSelectionTransitions++; - } - - public bool TryAllocate(TargetAllocationRequest request, out Address address) - { - var outcome = AllocateBoundWithOutcome(request, out _, out _); - address = outcome.Address; - return outcome.IsSuccess; - } - - public bool TryDeallocate(Address address, TargetAllocationSize size) - { - if (Current is null) return false; - - Current.DeallocationCalls++; - TotalDeallocationCalls++; - return true; - } - - public TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, - out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) - { - observation = ObserveCurrent(); - incarnation = observation.Incarnation.GetValueOrDefault(); - if (!observation.IsQualified) - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); - - Current!.AllocationCalls++; - TotalAllocationCalls++; - return TargetMemoryAllocationOutcome.Succeeded(new Address(0x7FF6_1000_0000)); - } - - public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, TargetAllocationSize size, - out TargetIdentityCheck targetCheck) - { - var outcome = DeallocateBoundWithOutcome(expected, address, size, out targetCheck); - return targetCheck.IsCurrent && outcome.IsSuccess; - } - - public TargetMemoryOperationOutcome DeallocateBoundWithOutcome(TargetProcessIncarnation expected, Address address, - TargetAllocationSize size, out TargetIdentityCheck targetCheck) - { - var observation = ObserveCurrent(); - targetCheck = Check(expected, observation); - if (!targetCheck.IsCurrent) - return TargetMemoryOperationOutcome.Failed(targetCheck.Kind is TargetIdentityCheckKind.TargetChanged - or TargetIdentityCheckKind.ProcessReused - ? EngineFailureKind.TargetIdentityMismatch - : EngineFailureKind.TargetIdentityUnavailable); - - return TryDeallocate(address, size) - ? TargetMemoryOperationOutcome.Succeeded() - : TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); - } - - private TargetSelectionObservation ObserveCurrent() - { - return Current is null - ? TargetSelectionObservation.NoTarget() - : TargetSelectionObservation.Qualified(Current.Incarnation); - } - - private static TargetIdentityCheck Check(TargetProcessIncarnation expected, - TargetSelectionObservation observation) - { - if (!observation.IsQualified) - return TargetSelection.CreateUnavailableCheck(observation); - - var current = observation.Incarnation.GetValueOrDefault(); - if (current.ProcessId != expected.ProcessId) - return new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observation); - - return current.StartedAtUtcTicks == expected.StartedAtUtcTicks - ? new TargetIdentityCheck(TargetIdentityCheckKind.Current, observation) - : new TargetIdentityCheck(TargetIdentityCheckKind.ProcessReused, observation); - } - } - - private sealed class TargetContext - { - public TargetContext(int processId, long startedAtUtcTicks) - { - Incarnation = new TargetProcessIncarnation(processId, startedAtUtcTicks); - } - - public TargetProcessIncarnation Incarnation { get; } - - public int AllocationCalls { get; set; } - - public int DeallocationCalls { get; set; } - } + [Fact] + public void Release_on_the_original_qualified_target_routes_once_to_that_target() + { + TargetContext first = new(4101, 1001); + TargetContext second = new(4102, 2002); + ControlledTargetOperations operations = new(first); + TargetMemoryAllocator allocator = new(operations); + + AllocatedRegion region = allocator.Allocate(CreateRequest()); + region.Release(); + + Assert.Equal(1, first.AllocationCalls); + Assert.Equal(1, first.DeallocationCalls); + Assert.Equal(0, second.DeallocationCalls); + Assert.Equal(TargetReleaseStatus.Released, region.LastReleaseOutcome.Status); + Assert.Equal(0, operations.ExternalSelectionTransitions); + } + + [Fact] + public void Dispose_after_an_external_target_switch_refuses_cleanup_without_selecting_or_deallocating() + { + TargetContext first = new(4101, 1001); + TargetContext second = new(4102, 2002); + ControlledTargetOperations operations = new(first); + TargetMemoryAllocator allocator = new(operations); + AllocatedRegion region = allocator.Allocate(CreateRequest()); + + operations.SelectExternally(second); + region.Dispose(); + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, region.LastReleaseOutcome.Status); + Assert.Equal(0, first.DeallocationCalls); + Assert.Equal(0, second.DeallocationCalls); + Assert.Same(second, operations.Current); + Assert.Equal(1, operations.ExternalSelectionTransitions); + } + + [Fact] + public void Release_after_an_external_target_switch_reports_the_refusal_without_deallocating_either_target() + { + TargetContext first = new(4101, 1001); + TargetContext second = new(4102, 2002); + ControlledTargetOperations operations = new(first); + TargetMemoryAllocator allocator = new(operations); + AllocatedRegion region = allocator.Allocate(CreateRequest()); + + operations.SelectExternally(second); + EngineTargetIdentityException exception = Assert.Throws(region.Release); + + Assert.Equal(TargetIdentityCheckKind.TargetChanged, exception.Check.Kind); + Assert.Equal(0, first.DeallocationCalls); + Assert.Equal(0, second.DeallocationCalls); + Assert.Same(second, operations.Current); + Assert.Throws(region.Release); + } + + [Fact] + public void Dispose_after_an_external_A_to_B_to_A_switch_uses_only_the_current_original_incarnation() + { + TargetContext first = new(4101, 1001); + TargetContext second = new(4102, 2002); + ControlledTargetOperations operations = new(first); + TargetMemoryAllocator allocator = new(operations); + AllocatedRegion region = allocator.Allocate(CreateRequest()); + + operations.SelectExternally(second); + operations.SelectExternally(first); + region.Dispose(); + + Assert.Equal(TargetReleaseStatus.Released, region.LastReleaseOutcome.Status); + Assert.Equal(1, first.DeallocationCalls); + Assert.Equal(0, second.DeallocationCalls); + Assert.Same(first, operations.Current); + Assert.Equal(2, operations.ExternalSelectionTransitions); + } + + [Fact] + public void Release_after_target_termination_refuses_cleanup_and_records_no_target() + { + TargetContext first = new(4101, 1001); + ControlledTargetOperations operations = new(first); + TargetMemoryAllocator allocator = new(operations); + AllocatedRegion region = allocator.Allocate(CreateRequest()); + + operations.TerminateCurrent(); + TargetReleaseOutcome outcome = region.ReleaseWithTargetOutcome(); + + Assert.Equal(TargetReleaseStatus.RefusedNoTarget, outcome.Status); + Assert.Equal(0, first.DeallocationCalls); + Assert.Null(operations.Current); + region.Dispose(); + Assert.Equal(0, first.DeallocationCalls); + } + + [Fact] + public void Release_after_PID_reuse_refuses_the_new_incarnation() + { + TargetContext original = new(4101, 1001); + TargetContext reused = new(4101, 3003); + ControlledTargetOperations operations = new(original); + TargetMemoryAllocator allocator = new(operations); + AllocatedRegion region = allocator.Allocate(CreateRequest()); + + operations.ReusePid(reused); + TargetReleaseOutcome outcome = region.ReleaseWithTargetOutcome(); + + Assert.Equal(TargetReleaseStatus.RefusedProcessReused, outcome.Status); + Assert.Equal(0, original.DeallocationCalls); + Assert.Equal(0, reused.DeallocationCalls); + Assert.Same(reused, operations.Current); + } + + [Fact] + public void Allocate_when_target_identity_is_unavailable_refuses_before_the_effectful_operation() + { + ControlledTargetOperations operations = new(null); + TargetMemoryAllocator allocator = new(operations); + + EngineTargetIdentityException exception = + Assert.Throws(() => allocator.Allocate(CreateRequest())); + TargetMemoryAllocationOutcome outcome = allocator.AllocateWithOutcome(CreateRequest()); + + Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, exception.Check.Kind); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); + Assert.Equal(0, operations.TotalAllocationCalls); + Assert.Equal(0, operations.TotalDeallocationCalls); + } + + private static TargetAllocationRequest CreateRequest() + { + return new TargetAllocationRequest(new TargetAllocationSize(4096)); + } + + private sealed class ControlledTargetOperations : ITargetMemoryAllocationOperations, + ITargetBoundMemoryAllocationOperations + { + public ControlledTargetOperations(TargetContext? current) + { + Current = current; + } + + public TargetContext? Current + { + get; + private set; + } + + public int ExternalSelectionTransitions + { + get; + private set; + } + + public int TotalAllocationCalls + { + get; + private set; + } + + public int TotalDeallocationCalls + { + get; + private set; + } + + public TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, + out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) + { + observation = ObserveCurrent(); + incarnation = observation.Incarnation.GetValueOrDefault(); + if (!observation.IsQualified) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable)); + } + + Current!.AllocationCalls++; + TotalAllocationCalls++; + return TargetMemoryAllocationOutcome.Succeeded(new Address(0x7FF6_1000_0000)); + } + + public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, TargetAllocationSize size, + out TargetIdentityCheck targetCheck) + { + TargetMemoryOperationOutcome outcome = DeallocateBoundWithOutcome(expected, address, size, out targetCheck); + return targetCheck.IsCurrent && outcome.IsSuccess; + } + + public TargetMemoryOperationOutcome DeallocateBoundWithOutcome(TargetProcessIncarnation expected, + Address address, + TargetAllocationSize size, out TargetIdentityCheck targetCheck) + { + TargetSelectionObservation observation = ObserveCurrent(); + targetCheck = Check(expected, observation); + if (!targetCheck.IsCurrent) + { + return TargetMemoryOperationOutcome.Failed(targetCheck.Kind is TargetIdentityCheckKind.TargetChanged + or TargetIdentityCheckKind.ProcessReused + ? EngineFailureKind.TargetIdentityMismatch + : EngineFailureKind.TargetIdentityUnavailable); + } + + return TryDeallocate(address, size) + ? TargetMemoryOperationOutcome.Succeeded() + : TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); + } + + public bool TryAllocate(TargetAllocationRequest request, out Address address) + { + TargetMemoryAllocationOutcome outcome = AllocateBoundWithOutcome(request, out _, out _); + address = outcome.Address; + return outcome.IsSuccess; + } + + public bool TryDeallocate(Address address, TargetAllocationSize size) + { + if (Current is null) + { + return false; + } + + Current.DeallocationCalls++; + TotalDeallocationCalls++; + return true; + } + + public void SelectExternally(TargetContext target) + { + Current = target; + ExternalSelectionTransitions++; + } + + public void TerminateCurrent() + { + Current = null; + ExternalSelectionTransitions++; + } + + public void ReusePid(TargetContext replacement) + { + Current = replacement; + ExternalSelectionTransitions++; + } + + private TargetSelectionObservation ObserveCurrent() + { + return Current is null + ? TargetSelectionObservation.NoTarget() + : TargetSelectionObservation.Qualified(Current.Incarnation); + } + + private static TargetIdentityCheck Check(TargetProcessIncarnation expected, + TargetSelectionObservation observation) + { + if (!observation.IsQualified) + { + return TargetSelection.CreateUnavailableCheck(observation); + } + + TargetProcessIncarnation current = observation.Incarnation.GetValueOrDefault(); + if (current.ProcessId != expected.ProcessId) + { + return new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observation); + } + + return current.StartedAtUtcTicks == expected.StartedAtUtcTicks + ? new TargetIdentityCheck(TargetIdentityCheckKind.Current, observation) + : new TargetIdentityCheck(TargetIdentityCheckKind.ProcessReused, observation); + } + } + + private sealed class TargetContext + { + public TargetContext(int processId, long startedAtUtcTicks) + { + Incarnation = new TargetProcessIncarnation(processId, startedAtUtcTicks); + } + + public TargetProcessIncarnation Incarnation + { + get; + } + + public int AllocationCalls + { + get; + set; + } + + public int DeallocationCalls + { + get; + set; + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs index 980a45e3..8464908d 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs @@ -1,4 +1,5 @@ using System.Reflection; + using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.Allocation; @@ -15,324 +16,350 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// public sealed class TargetMemoryAllocatorTests { - [Fact] - public void Allocate_when_owner_publication_fails_compensates_once_and_exposes_the_confirmed_cleanup() - { - AllocationOperationsFake operations = new(); - TargetMemoryAllocator allocator = new(operations); - var cause = new InvalidOperationException("injected region publication failure"); - - var exception = Assert.Throws(() => allocator.AllocateCore( - new TargetAllocationRequest(new TargetAllocationSize(4096)), - (_, _, _, _) => throw cause)); - - Assert.Same(cause, exception.InnerException); - Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); - Assert.Equal(1, operations.AllocateCalls); - Assert.Equal(1, operations.DeallocateCalls); - Assert.Equal(operations.AllocatedAddress, operations.LastDeallocatedAddress); - } - - [Fact] - public void Allocate_when_owner_publication_and_compensation_fail_reports_an_unconfirmed_effect_without_retrying() - { - AllocationOperationsFake operations = new() { DeallocationResult = false }; - TargetMemoryAllocator allocator = new(operations); - - var exception = Assert.Throws(() => allocator.AllocateCore( - new TargetAllocationRequest(new TargetAllocationSize(4096)), - static (_, _, _, _) => throw new InvalidOperationException("injected region publication failure"))); - - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); - Assert.Equal(1, operations.AllocateCalls); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Allocate_when_owner_publication_and_compensation_raise_keeps_the_primary_cause_and_marks_the_effect_unknown() - { - var cleanupFailure = new EngineLuaException("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = cleanupFailure }; - TargetMemoryAllocator allocator = new(operations); - var cause = new InvalidOperationException("injected region publication failure"); - - var exception = Assert.Throws(() => allocator.AllocateCore( - new TargetAllocationRequest(new TargetAllocationSize(4096)), - (_, _, _, _) => throw cause)); - - Assert.Same(cause, exception.InnerException); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.CleanupOutcome.FailureKind); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Allocate_when_owner_publication_observes_a_replacement_target_refuses_compensation_without_touching_it() - { - AllocationOperationsFake operations = new(); - TargetMemoryAllocator allocator = new(operations); - TargetProcessIncarnation replacement = new(4343, 2); - - var exception = Assert.Throws(() => allocator.AllocateCore( - new TargetAllocationRequest(new TargetAllocationSize(4096)), - (_, _, _, _) => - { - operations.TargetObservation = TargetSelectionObservation.Qualified(replacement); - throw new InvalidOperationException("injected region publication failure"); - })); - - Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, exception.CleanupOutcome.Status); - Assert.Equal(0, operations.DeallocateCalls); - } - - [Fact] - public void Allocate_on_success_returns_an_owned_region_and_forwards_the_full_request() - { - AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_1234_0000) }; - TargetMemoryAllocator allocator = new(operations); - TargetAllocationRequest request = new(new TargetAllocationSize(8192), new Address(0x7FF6_1200_0000), - MemoryProtection.ExecuteReadWrite); - - using var region = allocator.Allocate(request); - - Assert.Equal(new Address(0x7FF6_1234_0000), region.Address); - Assert.Equal(new TargetAllocationSize(8192), region.Size); - Assert.Equal(request, operations.LastRequest); - Assert.Equal(1, operations.AllocateCalls); - } - - [Fact] - public void Allocate_when_CE_reports_expected_failure_throws_the_stable_expected_failure() - { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; - TargetMemoryAllocator allocator = new(operations); - - var exception = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.Equal("TargetMemoryAllocate", exception.Operation); - Assert.Null(exception.InnerException); - Assert.Equal(1, operations.AllocateCalls); - } - - [Fact] - public void Allocate_when_CE_reports_success_without_an_address_preserves_the_unknown_effect_diagnostic() - { - AllocationOperationsFake operations = new() { AllocatedAddress = Address.Zero }; - TargetMemoryAllocator allocator = new(operations); - - var exception = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); - Assert.Equal(EngineFailureKind.MarshallingFailure, exception.CleanupOutcome.FailureKind); - Assert.IsType(exception.InnerException); - Assert.Equal(1, operations.AllocateCalls); - Assert.Equal(0, operations.DeallocateCalls); - } - - [Fact] - public void Allocate_when_CE_reports_failure_with_an_address_throws_marshalling() - { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = new Address(0x1234) }; - TargetMemoryAllocator allocator = new(operations); - - var exception = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.Equal("TargetMemoryAllocate", exception.Operation); - Assert.Equal(1, operations.AllocateCalls); - } - - [Fact] - public void Allocate_when_the_binding_fails_preserves_the_binding_exception() - { - EngineBindingException failure = new("TargetMemoryAllocate", - "the generated binding returned an incompatible result"); - AllocationOperationsFake operations = new() { AllocationException = failure }; - TargetMemoryAllocator allocator = new(operations); - - var thrown = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.Same(failure, thrown); - } - - [Fact] - public void Allocate_when_the_required_global_is_unavailable_preserves_that_distinct_failure() - { - EngineGlobalUnavailableException failure = new("TargetMemoryAllocate"); - AllocationOperationsFake operations = new() { AllocationException = failure }; - TargetMemoryAllocator allocator = new(operations); - - var thrown = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.Same(failure, thrown); - Assert.Equal(EngineFailureKind.GlobalUnavailable, thrown.Kind); - } - - [Fact] - public void Allocate_when_the_protected_lua_call_fails_preserves_the_EngineLuaException() - { - EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { AllocationException = failure }; - TargetMemoryAllocator allocator = new(operations); - - var thrown = Assert.Throws(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.Same(failure, thrown); - } - - [Fact] - public void AllocateWithOutcome_adapts_the_legacy_bool_seam_without_parsing_exception_text() - { - EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.SyntaxError, - "A deliberately irrelevant localized message."); - AllocationOperationsFake operations = new() { AllocationException = failure }; - TargetMemoryAllocator allocator = new(operations); - - var outcome = allocator.AllocateWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Operation.Kind); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.Operation.FailureKind); - Assert.Equal(LuaStatus.SyntaxError, outcome.Operation.LuaStatus); - Assert.Equal(Address.Zero, outcome.Address); - Assert.Equal(1, operations.AllocateCalls); - } - - [Fact] - public void AllocateWithOutcome_adapts_legacy_expected_failure_without_creating_an_owner() - { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; - TargetMemoryAllocator allocator = new(operations); - - var outcome = allocator.AllocateWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Operation.Kind); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.Operation.FailureKind); - Assert.Equal(Address.Zero, outcome.Address); - Assert.False(outcome.IsSuccess); - Assert.Equal(1, operations.AllocateCalls); - } - - [Fact] - public void Allocate_with_only_the_compatibility_seam_refuses_an_unqualified_owner_before_an_effectful_call() - { - DirectOnlyAllocationOperations operations = new(); - TargetMemoryAllocator allocator = new(operations); - var request = new TargetAllocationRequest(new TargetAllocationSize(4096)); - - var exception = Assert.Throws(() => allocator.Allocate(request)); - var outcome = allocator.AllocateWithOutcome(request); - - Assert.Equal(TargetIdentityCheckKind.CurrentTargetUnqualified, exception.Check.Kind); - Assert.Equal(EngineFailureKind.TargetIdentityUnavailable, exception.Kind); - Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); - Assert.Equal(0, operations.AllocateCalls); - Assert.Equal(0, operations.DeallocateCalls); - } - - [Theory] - [InlineData(EngineFailureKind.CapabilityUnavailable, typeof(EngineCapabilityUnavailableException))] - [InlineData(EngineFailureKind.ProtectedLuaFailure, typeof(EngineLuaException))] - [InlineData(EngineFailureKind.MarshallingFailure, typeof(EngineMarshallingException))] - [InlineData(EngineFailureKind.TargetIdentityUnavailable, typeof(EngineTargetIdentityException))] - [InlineData(EngineFailureKind.BindingFailure, typeof(EngineBindingException))] - public void Allocate_with_a_target_bound_boundary_failure_throws_its_stable_public_exception( - EngineFailureKind failureKind, Type expectedExceptionType) - { - AllocationOperationsFake operations = new() - { - BoundAllocationOutcomeOverride = TargetMemoryAllocationOutcome.Failed( - TargetMemoryOperationOutcome.Failed(failureKind, LuaStatus.SyntaxError)), - }; - TargetMemoryAllocator allocator = new(operations); - - var exception = Assert.ThrowsAny(() => - allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); - - Assert.IsType(expectedExceptionType, exception); - Assert.Equal(0, operations.AllocateCalls); - Assert.Equal(0, operations.DeallocateCalls); - - if (exception is EngineLuaException lua) - Assert.Equal(LuaStatus.SyntaxError, lua.Status); - if (exception is EngineMarshallingException marshalling) - Assert.Equal(EngineMarshallingDirection.Result, marshalling.Direction); - if (exception is EngineTargetIdentityException identity) - Assert.Equal(TargetIdentityCheckKind.CurrentTargetUnqualified, identity.Check.Kind); - } - - [Fact] - public void ReleaseWithOutcome_adapts_the_legacy_bool_seam_and_consumes_ownership() - { - AllocationOperationsFake operations = new() { DeallocationResult = false }; - TargetMemoryAllocator allocator = new(operations); - var region = allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))); - - var outcome = region.ReleaseWithOutcome(); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Kind); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.FailureKind); - Assert.True(region.IsDisposed); - Assert.Equal(1, operations.DeallocateCalls); - } - - [Fact] - public void Public_target_memory_operations_carry_enabled_lifecycle_metadata_without_an_unproven_thread_claim() - { - var allocate = typeof(TargetMemoryAllocator) - .GetMethod(nameof(TargetMemoryAllocator.Allocate))!; - var release = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.Release))!; - var dispose = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.Dispose))!; - var tryAllocate = typeof(ITargetMemoryAllocationOperations) - .GetMethod(nameof(ITargetMemoryAllocationOperations.TryAllocate))!; - var tryDeallocate = typeof(ITargetMemoryAllocationOperations).GetMethod( - nameof(ITargetMemoryAllocationOperations.TryDeallocate))!; - var allocateWithOutcome = typeof(ITargetMemoryAllocationOutcomeOperations).GetMethod( - nameof(ITargetMemoryAllocationOutcomeOperations.AllocateWithOutcome))!; - var deallocateWithOutcome = typeof(ITargetMemoryAllocationOutcomeOperations).GetMethod( - nameof(ITargetMemoryAllocationOutcomeOperations.DeallocateWithOutcome))!; - var facadeOutcome = typeof(TargetMemoryAllocator).GetMethod(nameof(TargetMemoryAllocator.AllocateWithOutcome))!; - var releaseWithOutcome = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.ReleaseWithOutcome))!; - - AssertHasLifecycleMetadata(allocate); - AssertHasLifecycleMetadata(release); - AssertHasLifecycleMetadata(dispose); - AssertHasLifecycleMetadata(tryAllocate); - AssertHasLifecycleMetadata(tryDeallocate); - AssertHasLifecycleMetadata(allocateWithOutcome); - AssertHasLifecycleMetadata(deallocateWithOutcome); - AssertHasLifecycleMetadata(facadeOutcome); - AssertHasLifecycleMetadata(releaseWithOutcome); - } - - private static void AssertHasLifecycleMetadata(MethodInfo method) - { - Assert.True(Attribute.IsDefined(method, typeof(RequiresPluginEnabledAttribute))); - Assert.False(Attribute.IsDefined(method, typeof(MainThreadOnlyAttribute))); - } - - private sealed class DirectOnlyAllocationOperations : ITargetMemoryAllocationOperations - { - public int AllocateCalls { get; private set; } - - public int DeallocateCalls { get; private set; } - - public bool TryAllocate(TargetAllocationRequest request, out Address address) - { - AllocateCalls++; - address = new Address(0x7FF6_1000_0000); - return true; - } - - public bool TryDeallocate(Address address, TargetAllocationSize size) - { - DeallocateCalls++; - return true; - } - } + [Fact] + public void Allocate_when_owner_publication_fails_compensates_once_and_exposes_the_confirmed_cleanup() + { + AllocationOperationsFake operations = new(); + TargetMemoryAllocator allocator = new(operations); + InvalidOperationException cause = new("injected region publication failure"); + + EngineResourceHandoffException exception = Assert.Throws(() => + allocator.AllocateCore( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + (_, _, _, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(1, operations.DeallocateCalls); + Assert.Equal(operations.AllocatedAddress, operations.LastDeallocatedAddress); + } + + [Fact] + public void Allocate_when_owner_publication_and_compensation_fail_reports_an_unconfirmed_effect_without_retrying() + { + AllocationOperationsFake operations = new() { DeallocationResult = false }; + TargetMemoryAllocator allocator = new(operations); + + EngineResourceHandoffException exception = Assert.Throws(() => + allocator.AllocateCore( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + static (_, _, _, _) => throw new InvalidOperationException("injected region publication failure"))); + + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void + Allocate_when_owner_publication_and_compensation_raise_keeps_the_primary_cause_and_marks_the_effect_unknown() + { + EngineLuaException cleanupFailure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); + AllocationOperationsFake operations = new() { DeallocationException = cleanupFailure }; + TargetMemoryAllocator allocator = new(operations); + InvalidOperationException cause = new("injected region publication failure"); + + EngineResourceHandoffException exception = Assert.Throws(() => + allocator.AllocateCore( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + (_, _, _, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.CleanupOutcome.FailureKind); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Allocate_when_owner_publication_observes_a_replacement_target_refuses_compensation_without_touching_it() + { + AllocationOperationsFake operations = new(); + TargetMemoryAllocator allocator = new(operations); + TargetProcessIncarnation replacement = new(4343, 2); + + EngineResourceHandoffException exception = Assert.Throws(() => + allocator.AllocateCore( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + (_, _, _, _) => + { + operations.TargetObservation = TargetSelectionObservation.Qualified(replacement); + throw new InvalidOperationException("injected region publication failure"); + })); + + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, exception.CleanupOutcome.Status); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Fact] + public void Allocate_on_success_returns_an_owned_region_and_forwards_the_full_request() + { + AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_1234_0000) }; + TargetMemoryAllocator allocator = new(operations); + TargetAllocationRequest request = new(new TargetAllocationSize(8192), new Address(0x7FF6_1200_0000), + MemoryProtection.ExecuteReadWrite); + + using AllocatedRegion region = allocator.Allocate(request); + + Assert.Equal(new Address(0x7FF6_1234_0000), region.Address); + Assert.Equal(new TargetAllocationSize(8192), region.Size); + Assert.Equal(request, operations.LastRequest); + Assert.Equal(1, operations.AllocateCalls); + } + + [Fact] + public void Allocate_when_CE_reports_expected_failure_throws_the_stable_expected_failure() + { + AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; + TargetMemoryAllocator allocator = new(operations); + + EngineOperationFailedException exception = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.Equal("TargetMemoryAllocate", exception.Operation); + Assert.Null(exception.InnerException); + Assert.Equal(1, operations.AllocateCalls); + } + + [Fact] + public void Allocate_when_CE_reports_success_without_an_address_preserves_the_unknown_effect_diagnostic() + { + AllocationOperationsFake operations = new() { AllocatedAddress = Address.Zero }; + TargetMemoryAllocator allocator = new(operations); + + EngineResourceHandoffException exception = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); + Assert.Equal(EngineFailureKind.MarshallingFailure, exception.CleanupOutcome.FailureKind); + Assert.IsType(exception.InnerException); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Fact] + public void Allocate_when_CE_reports_failure_with_an_address_throws_marshalling() + { + AllocationOperationsFake operations = + new() { AllocationResult = false, AllocatedAddress = new Address(0x1234) }; + TargetMemoryAllocator allocator = new(operations); + + EngineMarshallingException exception = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.Equal("TargetMemoryAllocate", exception.Operation); + Assert.Equal(1, operations.AllocateCalls); + } + + [Fact] + public void Allocate_when_the_binding_fails_preserves_the_binding_exception() + { + EngineBindingException failure = new("TargetMemoryAllocate", + "the generated binding returned an incompatible result"); + AllocationOperationsFake operations = new() { AllocationException = failure }; + TargetMemoryAllocator allocator = new(operations); + + EngineBindingException thrown = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.Same(failure, thrown); + } + + [Fact] + public void Allocate_when_the_required_global_is_unavailable_preserves_that_distinct_failure() + { + EngineGlobalUnavailableException failure = new("TargetMemoryAllocate"); + AllocationOperationsFake operations = new() { AllocationException = failure }; + TargetMemoryAllocator allocator = new(operations); + + EngineGlobalUnavailableException thrown = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.Same(failure, thrown); + Assert.Equal(EngineFailureKind.GlobalUnavailable, thrown.Kind); + } + + [Fact] + public void Allocate_when_the_protected_lua_call_fails_preserves_the_EngineLuaException() + { + EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.RuntimeError); + AllocationOperationsFake operations = new() { AllocationException = failure }; + TargetMemoryAllocator allocator = new(operations); + + EngineLuaException thrown = Assert.Throws(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.Same(failure, thrown); + } + + [Fact] + public void AllocateWithOutcome_adapts_the_legacy_bool_seam_without_parsing_exception_text() + { + EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.SyntaxError, + "A deliberately irrelevant localized message."); + AllocationOperationsFake operations = new() { AllocationException = failure }; + TargetMemoryAllocator allocator = new(operations); + + TargetMemoryAllocationOutcome outcome = + allocator.AllocateWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096))); + + Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Operation.Kind); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.Operation.FailureKind); + Assert.Equal(LuaStatus.SyntaxError, outcome.Operation.LuaStatus); + Assert.Equal(Address.Zero, outcome.Address); + Assert.Equal(1, operations.AllocateCalls); + } + + [Fact] + public void AllocateWithOutcome_adapts_legacy_expected_failure_without_creating_an_owner() + { + AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; + TargetMemoryAllocator allocator = new(operations); + + TargetMemoryAllocationOutcome outcome = + allocator.AllocateWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096))); + + Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Operation.Kind); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.Operation.FailureKind); + Assert.Equal(Address.Zero, outcome.Address); + Assert.False(outcome.IsSuccess); + Assert.Equal(1, operations.AllocateCalls); + } + + [Fact] + public void Allocate_with_only_the_compatibility_seam_refuses_an_unqualified_owner_before_an_effectful_call() + { + DirectOnlyAllocationOperations operations = new(); + TargetMemoryAllocator allocator = new(operations); + TargetAllocationRequest request = new(new TargetAllocationSize(4096)); + + EngineTargetIdentityException exception = + Assert.Throws(() => allocator.Allocate(request)); + TargetMemoryAllocationOutcome outcome = allocator.AllocateWithOutcome(request); + + Assert.Equal(TargetIdentityCheckKind.CurrentTargetUnqualified, exception.Check.Kind); + Assert.Equal(EngineFailureKind.TargetIdentityUnavailable, exception.Kind); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); + Assert.Equal(0, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Theory] + [InlineData(EngineFailureKind.CapabilityUnavailable, typeof(EngineCapabilityUnavailableException))] + [InlineData(EngineFailureKind.ProtectedLuaFailure, typeof(EngineLuaException))] + [InlineData(EngineFailureKind.MarshallingFailure, typeof(EngineMarshallingException))] + [InlineData(EngineFailureKind.TargetIdentityUnavailable, typeof(EngineTargetIdentityException))] + [InlineData(EngineFailureKind.BindingFailure, typeof(EngineBindingException))] + public void Allocate_with_a_target_bound_boundary_failure_throws_its_stable_public_exception( + EngineFailureKind failureKind, Type expectedExceptionType) + { + AllocationOperationsFake operations = new() + { + BoundAllocationOutcomeOverride = TargetMemoryAllocationOutcome.Failed( + TargetMemoryOperationOutcome.Failed(failureKind, LuaStatus.SyntaxError)) + }; + TargetMemoryAllocator allocator = new(operations); + + EngineException exception = Assert.ThrowsAny(() => + allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096)))); + + Assert.IsType(expectedExceptionType, exception); + Assert.Equal(0, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + + if (exception is EngineLuaException lua) + { + Assert.Equal(LuaStatus.SyntaxError, lua.Status); + } + + if (exception is EngineMarshallingException marshalling) + { + Assert.Equal(EngineMarshallingDirection.Result, marshalling.Direction); + } + + if (exception is EngineTargetIdentityException identity) + { + Assert.Equal(TargetIdentityCheckKind.CurrentTargetUnqualified, identity.Check.Kind); + } + } + + [Fact] + public void ReleaseWithOutcome_adapts_the_legacy_bool_seam_and_consumes_ownership() + { + AllocationOperationsFake operations = new() { DeallocationResult = false }; + TargetMemoryAllocator allocator = new(operations); + AllocatedRegion region = allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))); + + TargetMemoryOperationOutcome outcome = region.ReleaseWithOutcome(); + + Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Kind); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.FailureKind); + Assert.True(region.IsDisposed); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void Public_target_memory_operations_carry_enabled_lifecycle_metadata_without_an_unproven_thread_claim() + { + MethodInfo allocate = typeof(TargetMemoryAllocator) + .GetMethod(nameof(TargetMemoryAllocator.Allocate))!; + MethodInfo release = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.Release))!; + MethodInfo dispose = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.Dispose))!; + MethodInfo tryAllocate = typeof(ITargetMemoryAllocationOperations) + .GetMethod(nameof(ITargetMemoryAllocationOperations.TryAllocate))!; + MethodInfo tryDeallocate = typeof(ITargetMemoryAllocationOperations).GetMethod( + nameof(ITargetMemoryAllocationOperations.TryDeallocate))!; + MethodInfo allocateWithOutcome = typeof(ITargetMemoryAllocationOutcomeOperations).GetMethod( + nameof(ITargetMemoryAllocationOutcomeOperations.AllocateWithOutcome))!; + MethodInfo deallocateWithOutcome = typeof(ITargetMemoryAllocationOutcomeOperations).GetMethod( + nameof(ITargetMemoryAllocationOutcomeOperations.DeallocateWithOutcome))!; + MethodInfo facadeOutcome = + typeof(TargetMemoryAllocator).GetMethod(nameof(TargetMemoryAllocator.AllocateWithOutcome))!; + MethodInfo releaseWithOutcome = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.ReleaseWithOutcome))!; + + AssertHasLifecycleMetadata(allocate); + AssertHasLifecycleMetadata(release); + AssertHasLifecycleMetadata(dispose); + AssertHasLifecycleMetadata(tryAllocate); + AssertHasLifecycleMetadata(tryDeallocate); + AssertHasLifecycleMetadata(allocateWithOutcome); + AssertHasLifecycleMetadata(deallocateWithOutcome); + AssertHasLifecycleMetadata(facadeOutcome); + AssertHasLifecycleMetadata(releaseWithOutcome); + } + + private static void AssertHasLifecycleMetadata(MethodInfo method) + { + Assert.True(Attribute.IsDefined(method, typeof(RequiresPluginEnabledAttribute))); + Assert.False(Attribute.IsDefined(method, typeof(MainThreadOnlyAttribute))); + } + + private sealed class DirectOnlyAllocationOperations : ITargetMemoryAllocationOperations + { + public int AllocateCalls + { + get; + private set; + } + + public int DeallocateCalls + { + get; + private set; + } + + public bool TryAllocate(TargetAllocationRequest request, out Address address) + { + AllocateCalls++; + address = new Address(0x7FF6_1000_0000); + return true; + } + + public bool TryDeallocate(Address address, TargetAllocationSize size) + { + DeallocateCalls++; + return true; + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryOutcomeTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryOutcomeTests.cs index fc23a6da..e95aba99 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryOutcomeTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryOutcomeTests.cs @@ -8,82 +8,85 @@ namespace CheatEngine.SDK.Engine.Tests.Allocation; /// Public outcome factories preserve the allocation binding result invariants for independent backends. public sealed class TargetMemoryOutcomeTests { - [Fact] - public void Succeeded_creates_a_successful_operation_with_an_ok_lua_status() - { - var outcome = TargetMemoryOperationOutcome.Succeeded(); + [Fact] + public void Succeeded_creates_a_successful_operation_with_an_ok_lua_status() + { + TargetMemoryOperationOutcome outcome = TargetMemoryOperationOutcome.Succeeded(); - Assert.True(outcome.IsSuccess); - Assert.Equal(TargetMemoryOperationOutcomeKind.Succeeded, outcome.Kind); - Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); - Assert.Null(outcome.FailureKind); - } + Assert.True(outcome.IsSuccess); + Assert.Equal(TargetMemoryOperationOutcomeKind.Succeeded, outcome.Kind); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Assert.Null(outcome.FailureKind); + } - [Theory] - [InlineData(EngineFailureKind.ExpectedOperationFailure, TargetMemoryOperationOutcomeKind.ExpectedFailure)] - [InlineData(EngineFailureKind.GlobalUnavailable, TargetMemoryOperationOutcomeKind.GlobalUnavailable)] - [InlineData(EngineFailureKind.CapabilityUnavailable, TargetMemoryOperationOutcomeKind.CapabilityUnavailable)] - [InlineData(EngineFailureKind.BindingFailure, TargetMemoryOperationOutcomeKind.BindingFailure)] - [InlineData(EngineFailureKind.MarshallingFailure, TargetMemoryOperationOutcomeKind.MarshallingFailure)] - [InlineData(EngineFailureKind.TargetIdentityUnavailable, TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable)] - [InlineData(EngineFailureKind.TargetIdentityMismatch, TargetMemoryOperationOutcomeKind.TargetIdentityMismatch)] - public void Failed_creates_each_non_lua_allocation_failure_with_an_ok_lua_status(EngineFailureKind failureKind, - TargetMemoryOperationOutcomeKind expectedKind) - { - var outcome = TargetMemoryOperationOutcome.Failed(failureKind, LuaStatus.RuntimeError); + [Theory] + [InlineData(EngineFailureKind.ExpectedOperationFailure, TargetMemoryOperationOutcomeKind.ExpectedFailure)] + [InlineData(EngineFailureKind.GlobalUnavailable, TargetMemoryOperationOutcomeKind.GlobalUnavailable)] + [InlineData(EngineFailureKind.CapabilityUnavailable, TargetMemoryOperationOutcomeKind.CapabilityUnavailable)] + [InlineData(EngineFailureKind.BindingFailure, TargetMemoryOperationOutcomeKind.BindingFailure)] + [InlineData(EngineFailureKind.MarshallingFailure, TargetMemoryOperationOutcomeKind.MarshallingFailure)] + [InlineData(EngineFailureKind.TargetIdentityUnavailable, + TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable)] + [InlineData(EngineFailureKind.TargetIdentityMismatch, TargetMemoryOperationOutcomeKind.TargetIdentityMismatch)] + public void Failed_creates_each_non_lua_allocation_failure_with_an_ok_lua_status(EngineFailureKind failureKind, + TargetMemoryOperationOutcomeKind expectedKind) + { + TargetMemoryOperationOutcome outcome = TargetMemoryOperationOutcome.Failed(failureKind, LuaStatus.RuntimeError); - Assert.False(outcome.IsSuccess); - Assert.Equal(expectedKind, outcome.Kind); - Assert.Equal(failureKind, outcome.FailureKind); - Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); - } + Assert.False(outcome.IsSuccess); + Assert.Equal(expectedKind, outcome.Kind); + Assert.Equal(failureKind, outcome.FailureKind); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + } - [Fact] - public void Failed_creates_a_protected_lua_failure_only_with_its_failure_status() - { - var outcome = TargetMemoryOperationOutcome.Failed(EngineFailureKind.ProtectedLuaFailure, - LuaStatus.RuntimeError); + [Fact] + public void Failed_creates_a_protected_lua_failure_only_with_its_failure_status() + { + TargetMemoryOperationOutcome outcome = TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ProtectedLuaFailure, + LuaStatus.RuntimeError); - Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Kind); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); - Assert.Equal(LuaStatus.RuntimeError, outcome.LuaStatus); - } + Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Kind); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); + Assert.Equal(LuaStatus.RuntimeError, outcome.LuaStatus); + } - [Fact] - public void Failed_rejects_a_successful_lua_status_for_a_protected_lua_failure() - { - Assert.Throws(() => TargetMemoryOperationOutcome.Failed( - EngineFailureKind.ProtectedLuaFailure)); - } + [Fact] + public void Failed_rejects_a_successful_lua_status_for_a_protected_lua_failure() + { + Assert.Throws(() => TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ProtectedLuaFailure)); + } - [Fact] - public void Failed_rejects_an_unknown_engine_failure_kind() - { - Assert.Throws(() => TargetMemoryOperationOutcome.Failed( - (EngineFailureKind)int.MaxValue)); - } + [Fact] + public void Failed_rejects_an_unknown_engine_failure_kind() + { + Assert.Throws(() => TargetMemoryOperationOutcome.Failed( + (EngineFailureKind) int.MaxValue)); + } - [Fact] - public void Succeeded_creates_an_allocation_outcome_only_for_a_nonzero_address() - { - var outcome = TargetMemoryAllocationOutcome.Succeeded(new Address(0x7FF6_4000_0000)); + [Fact] + public void Succeeded_creates_an_allocation_outcome_only_for_a_nonzero_address() + { + TargetMemoryAllocationOutcome outcome = TargetMemoryAllocationOutcome.Succeeded(new Address(0x7FF6_4000_0000)); - Assert.True(outcome.IsSuccess); - Assert.Equal(new Address(0x7FF6_4000_0000), outcome.Address); - Assert.Throws(() => TargetMemoryAllocationOutcome.Succeeded(Address.Zero)); - } + Assert.True(outcome.IsSuccess); + Assert.Equal(new Address(0x7FF6_4000_0000), outcome.Address); + Assert.Throws(() => TargetMemoryAllocationOutcome.Succeeded(Address.Zero)); + } - [Fact] - public void Failed_creates_an_addressless_allocation_outcome_only_for_a_specified_failure() - { - var failure = TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); - var outcome = TargetMemoryAllocationOutcome.Failed(failure); + [Fact] + public void Failed_creates_an_addressless_allocation_outcome_only_for_a_specified_failure() + { + TargetMemoryOperationOutcome failure = + TargetMemoryOperationOutcome.Failed(EngineFailureKind.ExpectedOperationFailure); + TargetMemoryAllocationOutcome outcome = TargetMemoryAllocationOutcome.Failed(failure); - Assert.False(outcome.IsSuccess); - Assert.Equal(failure, outcome.Operation); - Assert.Equal(Address.Zero, outcome.Address); - Assert.Throws(() => TargetMemoryAllocationOutcome.Failed( - TargetMemoryOperationOutcome.Succeeded())); - Assert.Throws(() => TargetMemoryAllocationOutcome.Failed(default)); - } + Assert.False(outcome.IsSuccess); + Assert.Equal(failure, outcome.Operation); + Assert.Equal(Address.Zero, outcome.Address); + Assert.Throws(() => TargetMemoryAllocationOutcome.Failed( + TargetMemoryOperationOutcome.Succeeded())); + Assert.Throws(() => TargetMemoryAllocationOutcome.Failed(default)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs index 95944fb5..b3be42d0 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs @@ -1,9 +1,10 @@ using System.Reflection; using System.Text; + using CheatEngine.SDK.Engine.Assembly; using CheatEngine.SDK.Engine.Errors; -using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.State; @@ -17,520 +18,527 @@ namespace CheatEngine.SDK.Engine.Tests.Assembly; [Trait("Category", "NativeLua")] public sealed class AutoAssemblerPatcherTests { - [Fact] - public void Apply_on_success_retains_the_disable_info_until_Release_completes() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var patch = AutoAssemblerPatcher.Apply("success"); - - Assert.True(patch.IsEnabled); - Assert.False(patch.IsDisposed); - Assert.False(patch.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); - - patch.Release(); - - Assert.True(patch.IsDisposed); - Assert.False(patch.IsEnabled); - Assert.False(patch.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void TryApply_when_CE_rejects_the_script_returns_false_without_an_owner() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var applied = AutoAssemblerPatcher.TryApply("apply-false", out var patch); - - Assert.False(applied); - Assert.Null(patch); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Apply_when_CE_rejects_the_script_throws_the_stable_expected_failure() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.Apply("apply-false")); - - Assert.Equal("AutoAssemblerApply", exception.Operation); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Apply_when_the_protected_CE_call_fails_preserves_the_Lua_failure_and_restores_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.Apply("apply-raise")); - - Assert.Equal("AutoAssemblerApply", exception.Operation); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void TryApply_when_patch_publication_fails_compensates_once_with_the_rooted_disable_info() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var cause = new InvalidOperationException("injected patch publication failure"); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "success", out _, CreateDisableInfo, - (_, _, _) => throw cause)); - - Assert.Same(cause, exception.InnerException); - Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void TryApply_when_disable_info_tracking_fails_compensates_once_with_the_stack_retained_table() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "success", out _, FailDisableInfoTracking, - static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); - - Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); - Assert.IsType(exception.InnerException); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Dispose_after_a_successful_disable_is_idempotent_and_never_replays_disable() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("success"); - - patch.Dispose(); - patch.Dispose(); - - Assert.True(patch.IsDisposed); - Assert.False(patch.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ReleaseWithTargetOutcome_after_a_successful_disable_reports_released_and_consumes_the_owner() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("success"); - - var outcome = patch.ReleaseWithTargetOutcome(); - - Assert.Equal(TargetReleaseStatus.Released, outcome.Status); - Assert.False(outcome.RequiresManualRecovery); - Assert.False(patch.RequiresManualRecovery); - Assert.True(patch.IsDisposed); - Assert.Equal(outcome, patch.LastReleaseOutcome); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ReleaseWithTargetOutcome_when_the_current_target_differs_refuses_without_disabling() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - using LuaFrame frame = new(scope.State); - scope.State.CreateTable(); - var disableInfo = scope.State.CreateRef(); - var originalTargetId = Environment.ProcessId == 1 ? 2 : 1; - var patch = new AutoAssemblerPatch("success", disableInfo, - new TargetProcessIncarnation(originalTargetId, 1)); - - var outcome = patch.ReleaseWithTargetOutcome(); - - Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, outcome.Status); - Assert.Equal(TargetIdentityCheckKind.TargetChanged, outcome.TargetCheck.GetValueOrDefault().Kind); - Assert.False(outcome.FailureKind.HasValue); - Assert.True(patch.IsDisposed); - Assert.True(patch.RequiresManualRecovery); - Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ReleaseWithTargetOutcome_when_disable_fails_reports_an_unconfirmed_effect_without_retrying() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("disable-false"); - var disableInfo = GetDisableInfo(patch); - - var outcome = patch.ReleaseWithTargetOutcome(); - patch.Dispose(); - - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.FailureKind); - Assert.True(outcome.RequiresManualRecovery); - Assert.Equal(outcome, patch.LastReleaseOutcome); - Assert.True(patch.IsDisposed); - Assert.True(patch.RequiresManualRecovery); - Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ReleaseWithTargetOutcome_when_disable_raises_reports_the_protected_failure_without_throwing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("disable-raise"); - var disableInfo = GetDisableInfo(patch); - - var outcome = patch.ReleaseWithTargetOutcome(); - - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); - Assert.True(outcome.RequiresManualRecovery); - Assert.True(patch.IsDisposed); - Assert.True(patch.RequiresManualRecovery); - Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ReleaseWithTargetOutcome_when_the_attached_host_cannot_provide_a_state_reports_cleanup_not_invoked() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("success"); - var disableInfo = GetDisableInfo(patch); - - TargetReleaseOutcome outcome; - using (FakeHost.SuppressStateProvider()) - { - outcome = patch.ReleaseWithTargetOutcome(); - } - - Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); - Assert.Null(outcome.FailureKind); - Assert.True(outcome.RequiresManualRecovery); - Assert.Equal(outcome, patch.LastReleaseOutcome); - Assert.True(patch.IsDisposed); - Assert.True(patch.RequiresManualRecovery); - Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Release_when_the_attached_host_cannot_provide_a_state_preserves_the_preinvocation_outcome() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("success"); - var disableInfo = GetDisableInfo(patch); - - using (FakeHost.SuppressStateProvider()) - { - _ = Assert.Throws(patch.Release); - } - - Assert.Equal(TargetReleaseStatus.NotInvoked, patch.LastReleaseOutcome.Status); - Assert.Null(patch.LastReleaseOutcome.FailureKind); - Assert.True(patch.RequiresManualRecovery); - Assert.True(patch.IsDisposed); - Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Release_paths_after_Dispose_throw_without_replaying_disable() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("success"); - - patch.Dispose(); - - _ = Assert.Throws(patch.Release); - _ = Assert.Throws(() => - { - _ = patch.ReleaseWithTargetOutcome(); - }); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Release_when_CE_returns_false_marks_manual_recovery_releases_the_LuaRef_and_never_retries() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("disable-false"); - var disableInfo = GetDisableInfo(patch); - - var exception = Assert.Throws(patch.Release); - - Assert.Equal("AutoAssemblerDisable", exception.Operation); - Assert.True(patch.IsDisposed); - Assert.False(patch.IsEnabled); - Assert.True(patch.RequiresManualRecovery); - Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - - patch.Dispose(); - - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void TryApply_when_patch_publication_and_compensation_fail_reports_the_unconfirmed_cleanup() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var cause = new InvalidOperationException("injected patch publication failure"); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "disable-false", out _, CreateDisableInfo, - (_, _, _) => throw cause)); - - Assert.Same(cause, exception.InnerException); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); - Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void TryApply_when_tracking_and_compensation_fail_reports_the_unconfirmed_cleanup() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "disable-raise", out _, FailDisableInfoTracking, - static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); - - Assert.IsType(exception.InnerException); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.CleanupOutcome.FailureKind); - Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void TryApply_when_tracking_fails_and_compensation_returns_false_reports_the_unconfirmed_cleanup() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - - var exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "disable-false", out _, FailDisableInfoTracking, - static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); - - Assert.IsType(exception.InnerException); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); - Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Dispose_when_the_disable_call_raises_marks_manual_recovery_releases_the_LuaRef_and_never_retries() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("disable-raise"); - var disableInfo = GetDisableInfo(patch); - - patch.Dispose(); - patch.Dispose(); - - Assert.True(patch.IsDisposed); - Assert.True(patch.RequiresManualRecovery); - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, patch.LastReleaseOutcome.Status); - Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Release_after_an_external_target_termination_refuses_without_disabling_the_patch() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallAutoAssembler(scope.State); - var patch = AutoAssemblerPatcher.Apply("success"); - - EngineTest.Run(scope.State, "auto_assembler_target_process_id = 0"u8); - var exception = Assert.Throws(patch.Release); - - Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, exception.Check.Kind); - Assert.Equal(TargetReleaseStatus.RefusedNoTarget, patch.LastReleaseOutcome.Status); - Assert.True(patch.RequiresManualRecovery); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Dispose_after_disable_and_reenable_does_not_route_a_stale_disable_info_into_the_new_lifecycle() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - AutoAssemblerPatch patch; - LuaRef disableInfo; - HostScope firstScope = new(state); - try - { - InstallAutoAssembler(firstScope.State); - patch = AutoAssemblerPatcher.Apply("success"); - disableInfo = GetDisableInfo(patch); - } - finally - { - firstScope.Dispose(); - } - - Assert.False(patch.IsEnabled); - using HostScope secondScope = new(state); - - patch.Dispose(); - - Assert.True(patch.IsDisposed); - Assert.True(patch.RequiresManualRecovery); - Assert.Equal(TargetReleaseStatus.NotInvoked, patch.LastReleaseOutcome.Status); - Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(secondScope.State, "auto_assembler_disable_count")); - Assert.Equal(0, secondScope.State.Top); - } - - private static LuaRef GetDisableInfo(AutoAssemblerPatch patch) - { - var field = typeof(AutoAssemblerPatch).GetField("_disableInfo", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(field); - return Assert.IsType(field.GetValue(patch)); - } - - private static LuaRef CreateDisableInfo(LuaState state) - { - return state.CreateRef(); - } - - private static LuaRef FailDisableInfoTracking(LuaState _) - { - throw new EngineLuaException("AutoAssemblerApply", LuaStatus.MemoryError); - } - - private static void InstallAutoAssembler(CheatEngine.SDK.Lua.State.LuaState state) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("auto_assembler_target_process_id = " + - Environment.ProcessId + - "\nfunction getOpenedProcessID() return auto_assembler_target_process_id end")); - EngineTest.Run(state, """ - auto_assembler_apply_count = 0 - auto_assembler_disable_count = 0 - auto_assembler_disable_received_info = false - - autoAssemble = function(script, disableInfo) - if disableInfo == nil then - if script == "apply-false" then return false, nil end - if script == "apply-raise" then error("apply failure") end - auto_assembler_apply_count = auto_assembler_apply_count + 1 - return true, { sequence = auto_assembler_apply_count } - end - - auto_assembler_disable_count = auto_assembler_disable_count + 1 - auto_assembler_disable_received_info = type(disableInfo) == "table" - if script == "disable-false" then return false end - if script == "disable-raise" then error("disable failure") end - return true - end - """u8); - } - - private static long ReadCounter(CheatEngine.SDK.Lua.State.LuaState state, string name) - { - using var frame = new CheatEngine.SDK.Lua.State.LuaFrame(state); - Assert.True(state.TryGetGlobal(System.Text.Encoding.UTF8.GetBytes(name)).IsOk); - return EngineTest.ReadInteger(state, -1); - } - - private static bool ReadBoolean(CheatEngine.SDK.Lua.State.LuaState state, string name) - { - using var frame = new CheatEngine.SDK.Lua.State.LuaFrame(state); - Assert.True(state.TryGetGlobal(System.Text.Encoding.UTF8.GetBytes(name)).IsOk); - Assert.Equal(CheatEngine.SDK.Lua.State.LuaType.Boolean, state.TypeOf(-1)); - return state.ToBoolean(-1); - } + [Fact] + public void Apply_on_success_retains_the_disable_info_until_Release_completes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + + Assert.True(patch.IsEnabled); + Assert.False(patch.IsDisposed); + Assert.False(patch.RequiresManualRecovery); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + + patch.Release(); + + Assert.True(patch.IsDisposed); + Assert.False(patch.IsEnabled); + Assert.False(patch.RequiresManualRecovery); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void TryApply_when_CE_rejects_the_script_returns_false_without_an_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + bool applied = AutoAssemblerPatcher.TryApply("apply-false", out AutoAssemblerPatch? patch); + + Assert.False(applied); + Assert.Null(patch); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Apply_when_CE_rejects_the_script_throws_the_stable_expected_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + EngineOperationFailedException exception = + Assert.Throws(() => AutoAssemblerPatcher.Apply("apply-false")); + + Assert.Equal("AutoAssemblerApply", exception.Operation); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Apply_when_the_protected_CE_call_fails_preserves_the_Lua_failure_and_restores_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + EngineLuaException exception = + Assert.Throws(() => AutoAssemblerPatcher.Apply("apply-raise")); + + Assert.Equal("AutoAssemblerApply", exception.Operation); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void TryApply_when_patch_publication_fails_compensates_once_with_the_rooted_disable_info() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + InvalidOperationException cause = new("injected patch publication failure"); + + EngineResourceHandoffException exception = Assert.Throws(() => + AutoAssemblerPatcher.TryApplyCore( + "success", out _, CreateDisableInfo, + (_, _, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void TryApply_when_disable_info_tracking_fails_compensates_once_with_the_stack_retained_table() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + EngineResourceHandoffException exception = Assert.Throws(() => + AutoAssemblerPatcher.TryApplyCore( + "success", out _, FailDisableInfoTracking, + static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); + + Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); + Assert.IsType(exception.InnerException); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Dispose_after_a_successful_disable_is_idempotent_and_never_replays_disable() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + + patch.Dispose(); + patch.Dispose(); + + Assert.True(patch.IsDisposed); + Assert.False(patch.RequiresManualRecovery); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ReleaseWithTargetOutcome_after_a_successful_disable_reports_released_and_consumes_the_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + + TargetReleaseOutcome outcome = patch.ReleaseWithTargetOutcome(); + + Assert.Equal(TargetReleaseStatus.Released, outcome.Status); + Assert.False(outcome.RequiresManualRecovery); + Assert.False(patch.RequiresManualRecovery); + Assert.True(patch.IsDisposed); + Assert.Equal(outcome, patch.LastReleaseOutcome); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ReleaseWithTargetOutcome_when_the_current_target_differs_refuses_without_disabling() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + using LuaFrame frame = new(scope.State); + scope.State.CreateTable(); + LuaRef disableInfo = scope.State.CreateRef(); + int originalTargetId = Environment.ProcessId == 1 ? 2 : 1; + AutoAssemblerPatch patch = new("success", disableInfo, + new TargetProcessIncarnation(originalTargetId, 1)); + + TargetReleaseOutcome outcome = patch.ReleaseWithTargetOutcome(); + + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, outcome.Status); + Assert.Equal(TargetIdentityCheckKind.TargetChanged, outcome.TargetCheck.GetValueOrDefault().Kind); + Assert.False(outcome.FailureKind.HasValue); + Assert.True(patch.IsDisposed); + Assert.True(patch.RequiresManualRecovery); + Assert.False(disableInfo.IsResolved); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ReleaseWithTargetOutcome_when_disable_fails_reports_an_unconfirmed_effect_without_retrying() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-false"); + LuaRef disableInfo = GetDisableInfo(patch); + + TargetReleaseOutcome outcome = patch.ReleaseWithTargetOutcome(); + patch.Dispose(); + + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.FailureKind); + Assert.True(outcome.RequiresManualRecovery); + Assert.Equal(outcome, patch.LastReleaseOutcome); + Assert.True(patch.IsDisposed); + Assert.True(patch.RequiresManualRecovery); + Assert.False(disableInfo.IsResolved); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ReleaseWithTargetOutcome_when_disable_raises_reports_the_protected_failure_without_throwing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-raise"); + LuaRef disableInfo = GetDisableInfo(patch); + + TargetReleaseOutcome outcome = patch.ReleaseWithTargetOutcome(); + + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); + Assert.True(outcome.RequiresManualRecovery); + Assert.True(patch.IsDisposed); + Assert.True(patch.RequiresManualRecovery); + Assert.False(disableInfo.IsResolved); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ReleaseWithTargetOutcome_when_the_attached_host_cannot_provide_a_state_reports_cleanup_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + LuaRef disableInfo = GetDisableInfo(patch); + + TargetReleaseOutcome outcome; + using (FakeHost.SuppressStateProvider()) + { + outcome = patch.ReleaseWithTargetOutcome(); + } + + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); + Assert.Null(outcome.FailureKind); + Assert.True(outcome.RequiresManualRecovery); + Assert.Equal(outcome, patch.LastReleaseOutcome); + Assert.True(patch.IsDisposed); + Assert.True(patch.RequiresManualRecovery); + Assert.False(disableInfo.IsResolved); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Release_when_the_attached_host_cannot_provide_a_state_preserves_the_preinvocation_outcome() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + LuaRef disableInfo = GetDisableInfo(patch); + + using (FakeHost.SuppressStateProvider()) + { + _ = Assert.Throws(patch.Release); + } + + Assert.Equal(TargetReleaseStatus.NotInvoked, patch.LastReleaseOutcome.Status); + Assert.Null(patch.LastReleaseOutcome.FailureKind); + Assert.True(patch.RequiresManualRecovery); + Assert.True(patch.IsDisposed); + Assert.False(disableInfo.IsResolved); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Release_paths_after_Dispose_throw_without_replaying_disable() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + + patch.Dispose(); + + _ = Assert.Throws(patch.Release); + _ = Assert.Throws(() => + { + _ = patch.ReleaseWithTargetOutcome(); + }); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Release_when_CE_returns_false_marks_manual_recovery_releases_the_LuaRef_and_never_retries() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-false"); + LuaRef disableInfo = GetDisableInfo(patch); + + EngineOperationFailedException exception = Assert.Throws(patch.Release); + + Assert.Equal("AutoAssemblerDisable", exception.Operation); + Assert.True(patch.IsDisposed); + Assert.False(patch.IsEnabled); + Assert.True(patch.RequiresManualRecovery); + Assert.False(disableInfo.IsResolved); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + + patch.Dispose(); + + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void TryApply_when_patch_publication_and_compensation_fail_reports_the_unconfirmed_cleanup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + InvalidOperationException cause = new("injected patch publication failure"); + + EngineResourceHandoffException exception = Assert.Throws(() => + AutoAssemblerPatcher.TryApplyCore( + "disable-false", out _, CreateDisableInfo, + (_, _, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); + Assert.True(exception.CleanupOutcome.RequiresManualRecovery); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void TryApply_when_tracking_and_compensation_fail_reports_the_unconfirmed_cleanup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + EngineResourceHandoffException exception = Assert.Throws(() => + AutoAssemblerPatcher.TryApplyCore( + "disable-raise", out _, FailDisableInfoTracking, + static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); + + Assert.IsType(exception.InnerException); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.CleanupOutcome.FailureKind); + Assert.True(exception.CleanupOutcome.RequiresManualRecovery); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void TryApply_when_tracking_fails_and_compensation_returns_false_reports_the_unconfirmed_cleanup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + + EngineResourceHandoffException exception = Assert.Throws(() => + AutoAssemblerPatcher.TryApplyCore( + "disable-false", out _, FailDisableInfoTracking, + static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); + + Assert.IsType(exception.InnerException); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); + Assert.True(exception.CleanupOutcome.RequiresManualRecovery); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Dispose_when_the_disable_call_raises_marks_manual_recovery_releases_the_LuaRef_and_never_retries() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-raise"); + LuaRef disableInfo = GetDisableInfo(patch); + + patch.Dispose(); + patch.Dispose(); + + Assert.True(patch.IsDisposed); + Assert.True(patch.RequiresManualRecovery); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, patch.LastReleaseOutcome.Status); + Assert.False(disableInfo.IsResolved); + Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Release_after_an_external_target_termination_refuses_without_disabling_the_patch() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallAutoAssembler(scope.State); + AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); + + EngineTest.Run(scope.State, "auto_assembler_target_process_id = 0"u8); + EngineTargetIdentityException exception = Assert.Throws(patch.Release); + + Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, exception.Check.Kind); + Assert.Equal(TargetReleaseStatus.RefusedNoTarget, patch.LastReleaseOutcome.Status); + Assert.True(patch.RequiresManualRecovery); + Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Dispose_after_disable_and_reenable_does_not_route_a_stale_disable_info_into_the_new_lifecycle() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + AutoAssemblerPatch patch; + LuaRef disableInfo; + HostScope firstScope = new(state); + try + { + InstallAutoAssembler(firstScope.State); + patch = AutoAssemblerPatcher.Apply("success"); + disableInfo = GetDisableInfo(patch); + } + finally + { + firstScope.Dispose(); + } + + Assert.False(patch.IsEnabled); + using HostScope secondScope = new(state); + + patch.Dispose(); + + Assert.True(patch.IsDisposed); + Assert.True(patch.RequiresManualRecovery); + Assert.Equal(TargetReleaseStatus.NotInvoked, patch.LastReleaseOutcome.Status); + Assert.False(disableInfo.IsResolved); + Assert.Equal(0, ReadCounter(secondScope.State, "auto_assembler_disable_count")); + Assert.Equal(0, secondScope.State.Top); + } + + private static LuaRef GetDisableInfo(AutoAssemblerPatch patch) + { + FieldInfo? field = typeof(AutoAssemblerPatch).GetField("_disableInfo", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return Assert.IsType(field.GetValue(patch)); + } + + private static LuaRef CreateDisableInfo(LuaState state) + { + return state.CreateRef(); + } + + private static LuaRef FailDisableInfoTracking(LuaState _) + { + throw new EngineLuaException("AutoAssemblerApply", LuaStatus.MemoryError); + } + + private static void InstallAutoAssembler(LuaState state) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("auto_assembler_target_process_id = " + + Environment.ProcessId + + "\nfunction getOpenedProcessID() return auto_assembler_target_process_id end")); + EngineTest.Run(state, """ + auto_assembler_apply_count = 0 + auto_assembler_disable_count = 0 + auto_assembler_disable_received_info = false + + autoAssemble = function(script, disableInfo) + if disableInfo == nil then + if script == "apply-false" then return false, nil end + if script == "apply-raise" then error("apply failure") end + auto_assembler_apply_count = auto_assembler_apply_count + 1 + return true, { sequence = auto_assembler_apply_count } + end + + auto_assembler_disable_count = auto_assembler_disable_count + 1 + auto_assembler_disable_received_info = type(disableInfo) == "table" + if script == "disable-false" then return false end + if script == "disable-raise" then error("disable failure") end + return true + end + """u8); + } + + private static long ReadCounter(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadInteger(state, -1); + } + + private static bool ReadBoolean(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + Assert.Equal(LuaType.Boolean, state.TypeOf(-1)); + return state.ToBoolean(-1); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Enums/CEEnumNamesTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Enums/CEEnumNamesTests.cs index 021c8c38..0a621888 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Enums/CEEnumNamesTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Enums/CEEnumNamesTests.cs @@ -1,4 +1,5 @@ using System.Text; + using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Tests.Support; @@ -7,199 +8,202 @@ namespace CheatEngine.SDK.Engine.Tests.Enums; /// The CE name of every member, pinned to the identifier in defines.lua, and the reverse lookup. public sealed class CEEnumNamesTests { - [Theory] - [InlineData(VariableType.Byte, "vtByte")] - [InlineData(VariableType.Word, "vtWord")] - [InlineData(VariableType.Dword, "vtDword")] - [InlineData(VariableType.Qword, "vtQword")] - [InlineData(VariableType.Single, "vtSingle")] - [InlineData(VariableType.Double, "vtDouble")] - [InlineData(VariableType.String, "vtString")] - [InlineData(VariableType.WideString, "vtWideString")] - [InlineData(VariableType.ByteArray, "vtByteArray")] - [InlineData(VariableType.Binary, "vtBinary")] - [InlineData(VariableType.All, "vtAll")] - [InlineData(VariableType.AutoAssembler, "vtAutoAssembler")] - [InlineData(VariableType.Pointer, "vtPointer")] - [InlineData(VariableType.Custom, "vtCustom")] - [InlineData(VariableType.Grouped, "vtGrouped")] - public void VariableType_names_round_trip(VariableType member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out VariableType parsed)); - Assert.Equal(member, parsed); - } - - [Fact] - public void VariableType_accepts_the_documented_alias_and_nothing_else() - { - Assert.True(CEEnumNames.TryParseCEName("vtUnicodeString"u8, out VariableType alias)); - Assert.Equal(VariableType.WideString, alias); - Assert.False(CEEnumNames.TryParseCEName("vtdword"u8, out VariableType wrongCase)); - Assert.Equal(default, wrongCase); - Assert.False(CEEnumNames.TryParseCEName("Dword"u8, out VariableType _)); - Assert.False(CEEnumNames.TryParseCEName(""u8, out VariableType _)); - Assert.False(CEEnumNames.TryParseCEName("vtDword "u8, out VariableType _)); - Assert.True(((VariableType)99).ToCEName().IsEmpty); - } - - [Theory] - [InlineData(ScanOption.UnknownValue, "soUnknownValue")] - [InlineData(ScanOption.ExactValue, "soExactValue")] - [InlineData(ScanOption.ValueBetween, "soValueBetween")] - [InlineData(ScanOption.BiggerThan, "soBiggerThan")] - [InlineData(ScanOption.SmallerThan, "soSmallerThan")] - [InlineData(ScanOption.IncreasedValue, "soIncreasedValue")] - [InlineData(ScanOption.IncreasedValueBy, "soIncreasedValueBy")] - [InlineData(ScanOption.DecreasedValue, "soDecreasedValue")] - [InlineData(ScanOption.DecreasedValueBy, "soDecreasedValueBy")] - [InlineData(ScanOption.Changed, "soChanged")] - [InlineData(ScanOption.Unchanged, "soUnchanged")] - public void ScanOption_names_round_trip(ScanOption member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out ScanOption parsed)); - Assert.Equal(member, parsed); - } - - [Theory] - [InlineData(RoundingType.Rounded, "rtRounded")] - [InlineData(RoundingType.ExtremeRounded, "rtExtremerounded")] - [InlineData(RoundingType.Truncated, "rtTruncated")] - public void RoundingType_names_round_trip(RoundingType member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out RoundingType parsed)); - Assert.Equal(member, parsed); - } - - [Theory] - [InlineData(FastScanMethod.NotAligned, "fsmNotAligned")] - [InlineData(FastScanMethod.Aligned, "fsmAligned")] - [InlineData(FastScanMethod.LastDigits, "fsmLastDigits")] - public void FastScanMethod_names_round_trip(FastScanMethod member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out FastScanMethod parsed)); - Assert.Equal(member, parsed); - } - - [Theory] - [InlineData(BreakpointMethod.Int3, "bpmInt3")] - [InlineData(BreakpointMethod.DebugRegister, "bpmDebugRegister")] - [InlineData(BreakpointMethod.Exception, "bpmException")] - public void BreakpointMethod_names_round_trip(BreakpointMethod member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out BreakpointMethod parsed)); - Assert.Equal(member, parsed); - } - - [Theory] - [InlineData(BreakpointTrigger.Execute, "bptExecute")] - [InlineData(BreakpointTrigger.Access, "bptAccess")] - [InlineData(BreakpointTrigger.Write, "bptWrite")] - public void BreakpointTrigger_names_round_trip(BreakpointTrigger member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out BreakpointTrigger parsed)); - Assert.Equal(member, parsed); - } - - [Theory] - [InlineData(ContinueMethod.Run, "co_run")] - [InlineData(ContinueMethod.StepInto, "co_stepinto")] - [InlineData(ContinueMethod.StepOver, "co_stepover")] - public void ContinueMethod_names_round_trip(ContinueMethod member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out ContinueMethod parsed)); - Assert.Equal(member, parsed); - } - - [Theory] - [InlineData(MemoryProtection.ReadOnly, "PAGE_READONLY")] - [InlineData(MemoryProtection.ReadWrite, "PAGE_READWRITE")] - [InlineData(MemoryProtection.WriteCopy, "PAGE_WRITECOPY")] - [InlineData(MemoryProtection.Execute, "PAGE_EXECUTE")] - [InlineData(MemoryProtection.ExecuteRead, "PAGE_EXECUTE_READ")] - [InlineData(MemoryProtection.ExecuteReadWrite, "PAGE_EXECUTE_READWRITE")] - [InlineData(MemoryProtection.ExecuteWriteCopy, "PAGE_EXECUTE_WRITECOPY")] - public void MemoryProtection_names_round_trip(MemoryProtection member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out MemoryProtection parsed)); - Assert.Equal(member, parsed); - } - - [Fact] - public void MemoryProtection_combinations_and_None_have_no_CE_name() - { - Assert.True(MemoryProtection.None.ToCEName().IsEmpty); - Assert.True((MemoryProtection.ReadWrite | MemoryProtection.Execute).ToCEName().IsEmpty); - Assert.False(CEEnumNames.TryParseCEName("PAGE_NOACCESS"u8, out MemoryProtection notListed)); - Assert.Equal(MemoryProtection.None, notListed); - } - - [Theory] - [InlineData(DuplicateHandling.Ignore, "dupIgnore")] - [InlineData(DuplicateHandling.Accept, "dupAccept")] - [InlineData(DuplicateHandling.Error, "dupError")] - public void DuplicateHandling_names_round_trip(DuplicateHandling member, string ceName) - { - Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); - Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out DuplicateHandling parsed)); - Assert.Equal(member, parsed); - } - - [Fact] - public void Every_defined_member_of_every_enum_has_a_name_that_parses_back() - { - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out VariableType v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out ScanOption v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out RoundingType v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out FastScanMethod v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out BreakpointMethod v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out BreakpointTrigger v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out ContinueMethod v) => CEEnumNames.TryParseCEName(n, out v)); - AssertAllNamed(static v => v.ToCEName(), - static (ReadOnlySpan n, out DuplicateHandling v) => CEEnumNames.TryParseCEName(n, out v)); - } - - [Fact] - public void Name_lookup_allocates_nothing() - { - var sink = 0; - - AllocationGate.AssertZero(() => - { - sink += VariableType.Dword.ToCEName().Length; - if (CEEnumNames.TryParseCEName("soExactValue"u8, out ScanOption option)) sink += (int)option; - }); - - Assert.NotEqual(0, sink); - } - - private static void AssertAllNamed(NameOf nameOf, ParseName parse) - where TEnum : struct, Enum - { - foreach (var member in Enum.GetValues()) - { - var name = nameOf(member); - Assert.False(name.IsEmpty, member + " has no CE name."); - Assert.True(parse(name, out var parsed), member + " does not parse back."); - Assert.Equal(member, parsed); - } - } - - private delegate ReadOnlySpan NameOf(TEnum value); - - private delegate bool ParseName(ReadOnlySpan name, out TEnum value); + [Theory] + [InlineData(VariableType.Byte, "vtByte")] + [InlineData(VariableType.Word, "vtWord")] + [InlineData(VariableType.Dword, "vtDword")] + [InlineData(VariableType.Qword, "vtQword")] + [InlineData(VariableType.Single, "vtSingle")] + [InlineData(VariableType.Double, "vtDouble")] + [InlineData(VariableType.String, "vtString")] + [InlineData(VariableType.WideString, "vtWideString")] + [InlineData(VariableType.ByteArray, "vtByteArray")] + [InlineData(VariableType.Binary, "vtBinary")] + [InlineData(VariableType.All, "vtAll")] + [InlineData(VariableType.AutoAssembler, "vtAutoAssembler")] + [InlineData(VariableType.Pointer, "vtPointer")] + [InlineData(VariableType.Custom, "vtCustom")] + [InlineData(VariableType.Grouped, "vtGrouped")] + public void VariableType_names_round_trip(VariableType member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out VariableType parsed)); + Assert.Equal(member, parsed); + } + + [Fact] + public void VariableType_accepts_the_documented_alias_and_nothing_else() + { + Assert.True(CEEnumNames.TryParseCEName("vtUnicodeString"u8, out VariableType alias)); + Assert.Equal(VariableType.WideString, alias); + Assert.False(CEEnumNames.TryParseCEName("vtdword"u8, out VariableType wrongCase)); + Assert.Equal(default, wrongCase); + Assert.False(CEEnumNames.TryParseCEName("Dword"u8, out VariableType _)); + Assert.False(CEEnumNames.TryParseCEName(""u8, out VariableType _)); + Assert.False(CEEnumNames.TryParseCEName("vtDword "u8, out VariableType _)); + Assert.True(((VariableType) 99).ToCEName().IsEmpty); + } + + [Theory] + [InlineData(ScanOption.UnknownValue, "soUnknownValue")] + [InlineData(ScanOption.ExactValue, "soExactValue")] + [InlineData(ScanOption.ValueBetween, "soValueBetween")] + [InlineData(ScanOption.BiggerThan, "soBiggerThan")] + [InlineData(ScanOption.SmallerThan, "soSmallerThan")] + [InlineData(ScanOption.IncreasedValue, "soIncreasedValue")] + [InlineData(ScanOption.IncreasedValueBy, "soIncreasedValueBy")] + [InlineData(ScanOption.DecreasedValue, "soDecreasedValue")] + [InlineData(ScanOption.DecreasedValueBy, "soDecreasedValueBy")] + [InlineData(ScanOption.Changed, "soChanged")] + [InlineData(ScanOption.Unchanged, "soUnchanged")] + public void ScanOption_names_round_trip(ScanOption member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out ScanOption parsed)); + Assert.Equal(member, parsed); + } + + [Theory] + [InlineData(RoundingType.Rounded, "rtRounded")] + [InlineData(RoundingType.ExtremeRounded, "rtExtremerounded")] + [InlineData(RoundingType.Truncated, "rtTruncated")] + public void RoundingType_names_round_trip(RoundingType member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out RoundingType parsed)); + Assert.Equal(member, parsed); + } + + [Theory] + [InlineData(FastScanMethod.NotAligned, "fsmNotAligned")] + [InlineData(FastScanMethod.Aligned, "fsmAligned")] + [InlineData(FastScanMethod.LastDigits, "fsmLastDigits")] + public void FastScanMethod_names_round_trip(FastScanMethod member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out FastScanMethod parsed)); + Assert.Equal(member, parsed); + } + + [Theory] + [InlineData(BreakpointMethod.Int3, "bpmInt3")] + [InlineData(BreakpointMethod.DebugRegister, "bpmDebugRegister")] + [InlineData(BreakpointMethod.Exception, "bpmException")] + public void BreakpointMethod_names_round_trip(BreakpointMethod member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out BreakpointMethod parsed)); + Assert.Equal(member, parsed); + } + + [Theory] + [InlineData(BreakpointTrigger.Execute, "bptExecute")] + [InlineData(BreakpointTrigger.Access, "bptAccess")] + [InlineData(BreakpointTrigger.Write, "bptWrite")] + public void BreakpointTrigger_names_round_trip(BreakpointTrigger member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out BreakpointTrigger parsed)); + Assert.Equal(member, parsed); + } + + [Theory] + [InlineData(ContinueMethod.Run, "co_run")] + [InlineData(ContinueMethod.StepInto, "co_stepinto")] + [InlineData(ContinueMethod.StepOver, "co_stepover")] + public void ContinueMethod_names_round_trip(ContinueMethod member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out ContinueMethod parsed)); + Assert.Equal(member, parsed); + } + + [Theory] + [InlineData(MemoryProtection.ReadOnly, "PAGE_READONLY")] + [InlineData(MemoryProtection.ReadWrite, "PAGE_READWRITE")] + [InlineData(MemoryProtection.WriteCopy, "PAGE_WRITECOPY")] + [InlineData(MemoryProtection.Execute, "PAGE_EXECUTE")] + [InlineData(MemoryProtection.ExecuteRead, "PAGE_EXECUTE_READ")] + [InlineData(MemoryProtection.ExecuteReadWrite, "PAGE_EXECUTE_READWRITE")] + [InlineData(MemoryProtection.ExecuteWriteCopy, "PAGE_EXECUTE_WRITECOPY")] + public void MemoryProtection_names_round_trip(MemoryProtection member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out MemoryProtection parsed)); + Assert.Equal(member, parsed); + } + + [Fact] + public void MemoryProtection_combinations_and_None_have_no_CE_name() + { + Assert.True(MemoryProtection.None.ToCEName().IsEmpty); + Assert.True((MemoryProtection.ReadWrite | MemoryProtection.Execute).ToCEName().IsEmpty); + Assert.False(CEEnumNames.TryParseCEName("PAGE_NOACCESS"u8, out MemoryProtection notListed)); + Assert.Equal(MemoryProtection.None, notListed); + } + + [Theory] + [InlineData(DuplicateHandling.Ignore, "dupIgnore")] + [InlineData(DuplicateHandling.Accept, "dupAccept")] + [InlineData(DuplicateHandling.Error, "dupError")] + public void DuplicateHandling_names_round_trip(DuplicateHandling member, string ceName) + { + Assert.Equal(ceName, Encoding.UTF8.GetString(member.ToCEName())); + Assert.True(CEEnumNames.TryParseCEName(Encoding.UTF8.GetBytes(ceName), out DuplicateHandling parsed)); + Assert.Equal(member, parsed); + } + + [Fact] + public void Every_defined_member_of_every_enum_has_a_name_that_parses_back() + { + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out VariableType v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out ScanOption v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out RoundingType v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out FastScanMethod v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out BreakpointMethod v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out BreakpointTrigger v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out ContinueMethod v) => CEEnumNames.TryParseCEName(n, out v)); + AssertAllNamed(static v => v.ToCEName(), + static (ReadOnlySpan n, out DuplicateHandling v) => CEEnumNames.TryParseCEName(n, out v)); + } + + [Fact] + public void Name_lookup_allocates_nothing() + { + int sink = 0; + + AllocationGate.AssertZero(() => + { + sink += VariableType.Dword.ToCEName().Length; + if (CEEnumNames.TryParseCEName("soExactValue"u8, out ScanOption option)) + { + sink += (int) option; + } + }); + + Assert.NotEqual(0, sink); + } + + private static void AssertAllNamed(NameOf nameOf, ParseName parse) + where TEnum : struct, Enum + { + foreach (TEnum member in Enum.GetValues()) + { + ReadOnlySpan name = nameOf(member); + Assert.False(name.IsEmpty, member + " has no CE name."); + Assert.True(parse(name, out TEnum parsed), member + " does not parse back."); + Assert.Equal(member, parsed); + } + } + + private delegate ReadOnlySpan NameOf(TEnum value); + + private delegate bool ParseName(ReadOnlySpan name, out TEnum value); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumMarshallerTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumMarshallerTests.cs index b1f723f3..28017df3 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumMarshallerTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumMarshallerTests.cs @@ -8,103 +8,107 @@ namespace CheatEngine.SDK.Engine.Tests.Enums; /// Enums as the Lua integers Cheat Engine exchanges them as. public sealed class EnumMarshallerTests { - [Fact] - public void ToInt64_carries_the_value_of_every_underlying_type() - { - Assert.Equal(2L, EnumMarshaller.ToInt64(VariableType.Dword)); - Assert.Equal(128L, EnumMarshaller.ToInt64(MemoryProtection.ExecuteWriteCopy)); - Assert.Equal(250L, EnumMarshaller.ToInt64(Narrow.High)); - Assert.Equal(-5L, EnumMarshaller.ToInt64(Signed.Negative)); - Assert.Equal(-1L, EnumMarshaller.ToInt64(Wide.Top)); - } + [Fact] + public void ToInt64_carries_the_value_of_every_underlying_type() + { + Assert.Equal(2L, EnumMarshaller.ToInt64(VariableType.Dword)); + Assert.Equal(128L, EnumMarshaller.ToInt64(MemoryProtection.ExecuteWriteCopy)); + Assert.Equal(250L, EnumMarshaller.ToInt64(Narrow.High)); + Assert.Equal(-5L, EnumMarshaller.ToInt64(Signed.Negative)); + Assert.Equal(-1L, EnumMarshaller.ToInt64(Wide.Top)); + } - [Fact] - public void TryFromInt64_accepts_what_fits_and_refuses_the_rest() - { - Assert.True(EnumMarshaller.TryFromInt64(7, out var wide)); - Assert.Equal(VariableType.WideString, wide); - Assert.True(EnumMarshaller.TryFromInt64(99, out var undefined)); - Assert.Equal((VariableType)99, undefined); - Assert.False(EnumMarshaller.TryFromInt64(long.MaxValue, out var overflow)); - Assert.Equal(default, overflow); + [Fact] + public void TryFromInt64_accepts_what_fits_and_refuses_the_rest() + { + Assert.True(EnumMarshaller.TryFromInt64(7, out VariableType wide)); + Assert.Equal(VariableType.WideString, wide); + Assert.True(EnumMarshaller.TryFromInt64(99, out VariableType undefined)); + Assert.Equal((VariableType) 99, undefined); + Assert.False(EnumMarshaller.TryFromInt64(long.MaxValue, out VariableType overflow)); + Assert.Equal(default, overflow); - Assert.True(EnumMarshaller.TryFromInt64(20, out var combined)); - Assert.Equal(MemoryProtection.ReadWrite | MemoryProtection.Execute, combined); - Assert.False(EnumMarshaller.TryFromInt64(-1, out _)); + Assert.True(EnumMarshaller.TryFromInt64(20, out MemoryProtection combined)); + Assert.Equal(MemoryProtection.ReadWrite | MemoryProtection.Execute, combined); + Assert.False(EnumMarshaller.TryFromInt64(-1, out _)); - Assert.True(EnumMarshaller.TryFromInt64(250, out var high)); - Assert.Equal(Narrow.High, high); - Assert.False(EnumMarshaller.TryFromInt64(256, out _)); - Assert.True(EnumMarshaller.TryFromInt64(-5, out var negative)); - Assert.Equal(Signed.Negative, negative); - Assert.True(EnumMarshaller.TryFromInt64(-1, out var top)); - Assert.Equal(Wide.Top, top); - } + Assert.True(EnumMarshaller.TryFromInt64(250, out Narrow high)); + Assert.Equal(Narrow.High, high); + Assert.False(EnumMarshaller.TryFromInt64(256, out _)); + Assert.True(EnumMarshaller.TryFromInt64(-5, out Signed negative)); + Assert.Equal(Signed.Negative, negative); + Assert.True(EnumMarshaller.TryFromInt64(-1, out Wide top)); + Assert.Equal(Wide.Top, top); + } - [Fact] - [Trait("Category", "NativeLua")] - public void Push_and_read_round_trip_on_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); + [Fact] + [Trait("Category", "NativeLua")] + public void Push_and_read_round_trip_on_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); - EnumMarshaller.Push(L, ScanOption.Unchanged); - EnumMarshaller.Push(L, MemoryProtection.ExecuteReadWrite); - L.PushNumber(2.0); - L.PushString("2"u8); - L.PushNil(); + EnumMarshaller.Push(L, ScanOption.Unchanged); + EnumMarshaller.Push(L, MemoryProtection.ExecuteReadWrite); + L.PushNumber(2.0); + L.PushString("2"u8); + L.PushNil(); - Assert.True(L.IsInteger(1)); - Assert.Equal(10, EngineTest.ReadInteger(L, 1)); - Assert.True(EnumMarshaller.TryRead(L, 1, out var option)); - Assert.Equal(ScanOption.Unchanged, option); - Assert.True(EnumMarshaller.TryRead(L, 2, out var protection)); - Assert.Equal(MemoryProtection.ExecuteReadWrite, protection); - Assert.True(EnumMarshaller.TryRead(L, 3, out var fromFloat)); - Assert.Equal(RoundingType.Truncated, fromFloat); - Assert.True(EnumMarshaller.TryRead(L, 4, out var fromString)); - Assert.Equal(VariableType.Dword, fromString); - Assert.False(EnumMarshaller.TryRead(L, 5, out var fromNil)); - Assert.Equal(default, fromNil); - Assert.Equal(5, L.Top); - } + Assert.True(L.IsInteger(1)); + Assert.Equal(10, EngineTest.ReadInteger(L, 1)); + Assert.True(EnumMarshaller.TryRead(L, 1, out ScanOption option)); + Assert.Equal(ScanOption.Unchanged, option); + Assert.True(EnumMarshaller.TryRead(L, 2, out MemoryProtection protection)); + Assert.Equal(MemoryProtection.ExecuteReadWrite, protection); + Assert.True(EnumMarshaller.TryRead(L, 3, out RoundingType fromFloat)); + Assert.Equal(RoundingType.Truncated, fromFloat); + Assert.True(EnumMarshaller.TryRead(L, 4, out VariableType fromString)); + Assert.Equal(VariableType.Dword, fromString); + Assert.False(EnumMarshaller.TryRead(L, 5, out VariableType fromNil)); + Assert.Equal(default, fromNil); + Assert.Equal(5, L.Top); + } - [Fact] - [Trait("Category", "NativeLua")] - public void Push_and_read_allocate_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - var sink = 0; + [Fact] + [Trait("Category", "NativeLua")] + public void Push_and_read_allocate_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + int sink = 0; - AllocationGate.AssertZero(() => - { - EnumMarshaller.Push(L, VariableType.Qword); - if (!EnumMarshaller.TryRead(L, -1, out var back)) Assert.Fail("read failed"); - sink += (int)back; - L.Pop(1); - }); + AllocationGate.AssertZero(() => + { + EnumMarshaller.Push(L, VariableType.Qword); + if (!EnumMarshaller.TryRead(L, -1, out VariableType back)) + { + Assert.Fail("read failed"); + } - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } + sink += (int) back; + L.Pop(1); + }); - private enum Narrow : byte - { - Low = 1, - High = 250 - } + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } - private enum Signed : short - { - Negative = -5 - } + private enum Narrow : byte + { + Low = 1, + High = 250 + } - private enum Wide : ulong - { - Top = ulong.MaxValue - } + private enum Signed : short + { + Negative = -5 + } + + private enum Wide : ulong + { + Top = ulong.MaxValue + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumValueTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumValueTests.cs index 070d10e5..e9cb05a5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumValueTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Enums/EnumValueTests.cs @@ -8,134 +8,134 @@ namespace CheatEngine.SDK.Engine.Tests.Enums; /// public sealed class EnumValueTests { - [Theory] - [InlineData(VariableType.Byte, 0)] - [InlineData(VariableType.Word, 1)] - [InlineData(VariableType.Dword, 2)] - [InlineData(VariableType.Qword, 3)] - [InlineData(VariableType.Single, 4)] - [InlineData(VariableType.Double, 5)] - [InlineData(VariableType.String, 6)] - [InlineData(VariableType.WideString, 7)] - [InlineData(VariableType.ByteArray, 8)] - [InlineData(VariableType.Binary, 9)] - [InlineData(VariableType.All, 10)] - [InlineData(VariableType.AutoAssembler, 11)] - [InlineData(VariableType.Pointer, 12)] - [InlineData(VariableType.Custom, 13)] - [InlineData(VariableType.Grouped, 14)] - public void VariableType_values_match_defines_lua(VariableType member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(VariableType.Byte, 0)] + [InlineData(VariableType.Word, 1)] + [InlineData(VariableType.Dword, 2)] + [InlineData(VariableType.Qword, 3)] + [InlineData(VariableType.Single, 4)] + [InlineData(VariableType.Double, 5)] + [InlineData(VariableType.String, 6)] + [InlineData(VariableType.WideString, 7)] + [InlineData(VariableType.ByteArray, 8)] + [InlineData(VariableType.Binary, 9)] + [InlineData(VariableType.All, 10)] + [InlineData(VariableType.AutoAssembler, 11)] + [InlineData(VariableType.Pointer, 12)] + [InlineData(VariableType.Custom, 13)] + [InlineData(VariableType.Grouped, 14)] + public void VariableType_values_match_defines_lua(VariableType member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(ScanOption.UnknownValue, 0)] - [InlineData(ScanOption.ExactValue, 1)] - [InlineData(ScanOption.ValueBetween, 2)] - [InlineData(ScanOption.BiggerThan, 3)] - [InlineData(ScanOption.SmallerThan, 4)] - [InlineData(ScanOption.IncreasedValue, 5)] - [InlineData(ScanOption.IncreasedValueBy, 6)] - [InlineData(ScanOption.DecreasedValue, 7)] - [InlineData(ScanOption.DecreasedValueBy, 8)] - [InlineData(ScanOption.Changed, 9)] - [InlineData(ScanOption.Unchanged, 10)] - public void ScanOption_values_match_defines_lua(ScanOption member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(ScanOption.UnknownValue, 0)] + [InlineData(ScanOption.ExactValue, 1)] + [InlineData(ScanOption.ValueBetween, 2)] + [InlineData(ScanOption.BiggerThan, 3)] + [InlineData(ScanOption.SmallerThan, 4)] + [InlineData(ScanOption.IncreasedValue, 5)] + [InlineData(ScanOption.IncreasedValueBy, 6)] + [InlineData(ScanOption.DecreasedValue, 7)] + [InlineData(ScanOption.DecreasedValueBy, 8)] + [InlineData(ScanOption.Changed, 9)] + [InlineData(ScanOption.Unchanged, 10)] + public void ScanOption_values_match_defines_lua(ScanOption member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(RoundingType.Rounded, 0)] - [InlineData(RoundingType.ExtremeRounded, 1)] - [InlineData(RoundingType.Truncated, 2)] - public void RoundingType_values_match_defines_lua(RoundingType member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(RoundingType.Rounded, 0)] + [InlineData(RoundingType.ExtremeRounded, 1)] + [InlineData(RoundingType.Truncated, 2)] + public void RoundingType_values_match_defines_lua(RoundingType member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(FastScanMethod.NotAligned, 0)] - [InlineData(FastScanMethod.Aligned, 1)] - [InlineData(FastScanMethod.LastDigits, 2)] - public void FastScanMethod_values_match_defines_lua(FastScanMethod member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(FastScanMethod.NotAligned, 0)] + [InlineData(FastScanMethod.Aligned, 1)] + [InlineData(FastScanMethod.LastDigits, 2)] + public void FastScanMethod_values_match_defines_lua(FastScanMethod member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(BreakpointMethod.Int3, 0)] - [InlineData(BreakpointMethod.DebugRegister, 1)] - [InlineData(BreakpointMethod.Exception, 2)] - public void BreakpointMethod_values_match_defines_lua(BreakpointMethod member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(BreakpointMethod.Int3, 0)] + [InlineData(BreakpointMethod.DebugRegister, 1)] + [InlineData(BreakpointMethod.Exception, 2)] + public void BreakpointMethod_values_match_defines_lua(BreakpointMethod member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(BreakpointTrigger.Execute, 0)] - [InlineData(BreakpointTrigger.Access, 1)] - [InlineData(BreakpointTrigger.Write, 2)] - public void BreakpointTrigger_values_match_defines_lua(BreakpointTrigger member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(BreakpointTrigger.Execute, 0)] + [InlineData(BreakpointTrigger.Access, 1)] + [InlineData(BreakpointTrigger.Write, 2)] + public void BreakpointTrigger_values_match_defines_lua(BreakpointTrigger member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(ContinueMethod.Run, 0)] - [InlineData(ContinueMethod.StepInto, 1)] - [InlineData(ContinueMethod.StepOver, 2)] - public void ContinueMethod_values_match_defines_lua(ContinueMethod member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(ContinueMethod.Run, 0)] + [InlineData(ContinueMethod.StepInto, 1)] + [InlineData(ContinueMethod.StepOver, 2)] + public void ContinueMethod_values_match_defines_lua(ContinueMethod member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(MemoryProtection.None, 0u)] - [InlineData(MemoryProtection.ReadOnly, 2u)] - [InlineData(MemoryProtection.ReadWrite, 4u)] - [InlineData(MemoryProtection.WriteCopy, 8u)] - [InlineData(MemoryProtection.Execute, 16u)] - [InlineData(MemoryProtection.ExecuteRead, 32u)] - [InlineData(MemoryProtection.ExecuteReadWrite, 64u)] - [InlineData(MemoryProtection.ExecuteWriteCopy, 128u)] - public void MemoryProtection_values_match_defines_lua(MemoryProtection member, uint expected) - { - Assert.Equal(expected, (uint)member); - } + [Theory] + [InlineData(MemoryProtection.None, 0u)] + [InlineData(MemoryProtection.ReadOnly, 2u)] + [InlineData(MemoryProtection.ReadWrite, 4u)] + [InlineData(MemoryProtection.WriteCopy, 8u)] + [InlineData(MemoryProtection.Execute, 16u)] + [InlineData(MemoryProtection.ExecuteRead, 32u)] + [InlineData(MemoryProtection.ExecuteReadWrite, 64u)] + [InlineData(MemoryProtection.ExecuteWriteCopy, 128u)] + public void MemoryProtection_values_match_defines_lua(MemoryProtection member, uint expected) + { + Assert.Equal(expected, (uint) member); + } - [Theory] - [InlineData(DuplicateHandling.Ignore, 0)] - [InlineData(DuplicateHandling.Accept, 1)] - [InlineData(DuplicateHandling.Error, 2)] - public void DuplicateHandling_values_match_defines_lua(DuplicateHandling member, int expected) - { - Assert.Equal(expected, (int)member); - } + [Theory] + [InlineData(DuplicateHandling.Ignore, 0)] + [InlineData(DuplicateHandling.Accept, 1)] + [InlineData(DuplicateHandling.Error, 2)] + public void DuplicateHandling_values_match_defines_lua(DuplicateHandling member, int expected) + { + Assert.Equal(expected, (int) member); + } - [Theory] - [InlineData(typeof(VariableType), typeof(int), 15)] - [InlineData(typeof(ScanOption), typeof(int), 11)] - [InlineData(typeof(RoundingType), typeof(int), 3)] - [InlineData(typeof(FastScanMethod), typeof(int), 3)] - [InlineData(typeof(BreakpointMethod), typeof(int), 3)] - [InlineData(typeof(BreakpointTrigger), typeof(int), 3)] - [InlineData(typeof(ContinueMethod), typeof(int), 3)] - [InlineData(typeof(MemoryProtection), typeof(uint), 8)] - [InlineData(typeof(DuplicateHandling), typeof(int), 3)] - public void Underlying_types_and_member_counts_are_as_declared(Type enumType, Type underlying, int memberCount) - { - Assert.Equal(underlying, Enum.GetUnderlyingType(enumType)); - Assert.Equal(memberCount, Enum.GetNames(enumType).Length); - } + [Theory] + [InlineData(typeof(VariableType), typeof(int), 15)] + [InlineData(typeof(ScanOption), typeof(int), 11)] + [InlineData(typeof(RoundingType), typeof(int), 3)] + [InlineData(typeof(FastScanMethod), typeof(int), 3)] + [InlineData(typeof(BreakpointMethod), typeof(int), 3)] + [InlineData(typeof(BreakpointTrigger), typeof(int), 3)] + [InlineData(typeof(ContinueMethod), typeof(int), 3)] + [InlineData(typeof(MemoryProtection), typeof(uint), 8)] + [InlineData(typeof(DuplicateHandling), typeof(int), 3)] + public void Underlying_types_and_member_counts_are_as_declared(Type enumType, Type underlying, int memberCount) + { + Assert.Equal(underlying, Enum.GetUnderlyingType(enumType)); + Assert.Equal(memberCount, Enum.GetNames(enumType).Length); + } - [Fact] - public void MemoryProtection_is_the_only_flags_enum() - { - Assert.True(typeof(MemoryProtection).IsDefined(typeof(FlagsAttribute), false)); - Assert.False(typeof(VariableType).IsDefined(typeof(FlagsAttribute), false)); - Assert.False(typeof(ScanOption).IsDefined(typeof(FlagsAttribute), false)); - Assert.Equal(MemoryProtection.ReadWrite | MemoryProtection.Execute, (MemoryProtection)20u); - } + [Fact] + public void MemoryProtection_is_the_only_flags_enum() + { + Assert.True(typeof(MemoryProtection).IsDefined(typeof(FlagsAttribute), false)); + Assert.False(typeof(VariableType).IsDefined(typeof(FlagsAttribute), false)); + Assert.False(typeof(ScanOption).IsDefined(typeof(FlagsAttribute), false)); + Assert.Equal(MemoryProtection.ReadWrite | MemoryProtection.Execute, (MemoryProtection) 20u); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Errors/EngineExceptionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Errors/EngineExceptionTests.cs index 8be1ed0f..42d47622 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Errors/EngineExceptionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Errors/EngineExceptionTests.cs @@ -7,152 +7,151 @@ namespace CheatEngine.SDK.Engine.Tests.Errors; /// Contract tests for the stable Engine error taxonomy. public sealed class EngineExceptionTests { - [Fact] - public void Operation_failure_exposes_its_category_operation_and_inner_cause() - { - var cause = new InvalidOperationException("The target address was unreadable."); - var exception = - new EngineOperationFailedException("TargetMemory.ReadInt32", "The read could not complete.", cause); - - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.Kind); - Assert.Equal("TargetMemory.ReadInt32", exception.Operation); - Assert.Equal("The read could not complete.", exception.Message); - Assert.Same(cause, exception.InnerException); - } - - [Fact] - public void Capability_failure_keeps_the_public_identifier_out_of_its_binding_details() - { - var exception = new EngineCapabilityUnavailableException("MemoryProtection.Query"); - - Assert.Equal(EngineFailureKind.CapabilityUnavailable, exception.Kind); - Assert.Equal("MemoryProtection.Query", exception.Capability); - Assert.Equal("The Cheat Engine capability 'MemoryProtection.Query' is unavailable.", exception.Message); - Assert.DoesNotContain("VirtualQueryEx", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void Global_failure_is_distinct_from_a_capability_that_is_unavailable_before_binding() - { - var exception = new EngineGlobalUnavailableException("TargetMemory.ReadInt32"); - - Assert.Equal(EngineFailureKind.GlobalUnavailable, exception.Kind); - Assert.Equal("TargetMemory.ReadInt32", exception.Operation); - Assert.Equal("The required binding global for Cheat Engine operation 'TargetMemory.ReadInt32' is unavailable.", - exception.Message); - Assert.DoesNotContain("readInteger", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void Protected_lua_failure_exposes_status_but_not_the_inner_lua_text() - { - var cause = new LuaException("internal Lua stack: target process detached"); - var exception = new EngineLuaException("TargetMemory.ReadInt32", LuaStatus.RuntimeError); - - var withCause = new EngineLuaException("TargetMemory.ReadInt32", LuaStatus.RuntimeError, - "The protected call failed.", cause); - - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.Kind); - Assert.Equal("TargetMemory.ReadInt32", exception.Operation); - Assert.Equal(LuaStatus.RuntimeError, exception.Status); - Assert.Equal("The protected Lua operation 'TargetMemory.ReadInt32' failed with status LUA_ERRRUN.", - exception.Message); - Assert.DoesNotContain("internal Lua stack", exception.Message, StringComparison.Ordinal); - Assert.Same(cause, withCause.InnerException); - Assert.DoesNotContain("internal Lua stack", withCause.Message, StringComparison.Ordinal); - } - - [Fact] - public void Protected_lua_failure_rejects_a_success_status() - { - var exception = Assert.Throws(() => - new EngineLuaException("TargetMemory.ReadInt32", LuaStatus.Ok)); - - Assert.Equal("status", exception.ParamName); - } - - [Fact] - public void Binding_failure_distinguishes_a_contract_problem_from_a_missing_capability() - { - var exception = new EngineBindingException("MemoryScalars.ReadInt32"); - - Assert.Equal(EngineFailureKind.BindingFailure, exception.Kind); - Assert.Equal("MemoryScalars.ReadInt32", exception.Binding); - Assert.Equal("The Cheat Engine binding 'MemoryScalars.ReadInt32' cannot uphold its contract.", - exception.Message); - } - - [Fact] - public void Marshalling_failure_distinguishes_an_invalid_result_and_preserves_its_cause() - { - var cause = new InvalidCastException("A Lua table cannot be read as an integer."); - var exception = new EngineMarshallingException("TargetMemory.ReadInt32", EngineMarshallingDirection.Result, - "a 32-bit signed integer", "a table", "The target returned an invalid value.", cause); - - Assert.Equal(EngineFailureKind.MarshallingFailure, exception.Kind); - Assert.Equal("TargetMemory.ReadInt32", exception.Operation); - Assert.Equal(EngineMarshallingDirection.Result, exception.Direction); - Assert.Equal("a 32-bit signed integer", exception.Expected); - Assert.Equal("a table", exception.Actual); - Assert.Equal("The target returned an invalid value.", exception.Message); - Assert.Same(cause, exception.InnerException); - } - - [Fact] - public void Marshalling_failure_rejects_an_undefined_direction() - { - var exception = Assert.Throws(() => - new EngineMarshallingException("TargetMemory.ReadInt32", (EngineMarshallingDirection)42, - "a 32-bit signed integer", "a table")); - - Assert.Equal("direction", exception.ParamName); - } - - [Fact] - public void Target_identity_failure_preserves_the_observed_mismatch_category() - { - TargetProcessIncarnation observedIncarnation = new(43, 2); - var observed = TargetSelectionObservation.Qualified(observedIncarnation); - var check = new TargetIdentityCheck(TargetIdentityCheckKind.TargetChanged, observed); - var exception = new EngineTargetIdentityException("TargetMemoryDeallocate", check); - - Assert.Equal(EngineFailureKind.TargetIdentityMismatch, exception.Kind); - Assert.Equal(TargetIdentityCheckKind.TargetChanged, exception.Check.Kind); - Assert.True(exception.Check.Observed.Incarnation.HasValue); - Assert.Equal(observedIncarnation, exception.Check.Observed.Incarnation.GetValueOrDefault()); - } - - [Fact] - public void Target_identity_failure_kinds_append_without_reassigning_the_existing_failure_values() - { - Assert.Equal(6, (int)EngineFailureKind.TargetIdentityUnavailable); - Assert.Equal(7, (int)EngineFailureKind.TargetIdentityMismatch); - } - - [Fact] - public void Resource_handoff_failure_preserves_the_primary_cause_and_the_single_cleanup_diagnostic() - { - var cause = new InvalidOperationException("injected owner publication failure"); - var cleanup = TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); - var exception = new EngineResourceHandoffException("AutoAssemblerApply", cleanup, cause); - - Assert.Equal(EngineFailureKind.BindingFailure, exception.Kind); - Assert.Equal("AutoAssemblerApply", exception.Operation); - Assert.Equal(cleanup, exception.CleanupOutcome); - Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Same(cause, exception.InnerException); - } - - [Theory] - [InlineData("")] - [InlineData(null)] - public void Public_identifiers_must_not_be_null_or_empty(string? value) - { - Assert.Throws(() => new EngineOperationFailedException(value!)); - Assert.Throws(() => new EngineCapabilityUnavailableException(value!)); - Assert.Throws(() => new EngineGlobalUnavailableException(value!)); - Assert.Throws(() => new EngineBindingException(value!)); - Assert.Throws(() => new EngineMarshallingException("TargetMemory.ReadInt32", - EngineMarshallingDirection.Result, value!, "a nil value")); - } + [Fact] + public void Operation_failure_exposes_its_category_operation_and_inner_cause() + { + InvalidOperationException cause = new("The target address was unreadable."); + EngineOperationFailedException exception = new("TargetMemory.ReadInt32", "The read could not complete.", cause); + + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.Kind); + Assert.Equal("TargetMemory.ReadInt32", exception.Operation); + Assert.Equal("The read could not complete.", exception.Message); + Assert.Same(cause, exception.InnerException); + } + + [Fact] + public void Capability_failure_keeps_the_public_identifier_out_of_its_binding_details() + { + EngineCapabilityUnavailableException exception = new("MemoryProtection.Query"); + + Assert.Equal(EngineFailureKind.CapabilityUnavailable, exception.Kind); + Assert.Equal("MemoryProtection.Query", exception.Capability); + Assert.Equal("The Cheat Engine capability 'MemoryProtection.Query' is unavailable.", exception.Message); + Assert.DoesNotContain("VirtualQueryEx", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Global_failure_is_distinct_from_a_capability_that_is_unavailable_before_binding() + { + EngineGlobalUnavailableException exception = new("TargetMemory.ReadInt32"); + + Assert.Equal(EngineFailureKind.GlobalUnavailable, exception.Kind); + Assert.Equal("TargetMemory.ReadInt32", exception.Operation); + Assert.Equal("The required binding global for Cheat Engine operation 'TargetMemory.ReadInt32' is unavailable.", + exception.Message); + Assert.DoesNotContain("readInteger", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Protected_lua_failure_exposes_status_but_not_the_inner_lua_text() + { + LuaException cause = new("internal Lua stack: target process detached"); + EngineLuaException exception = new("TargetMemory.ReadInt32", LuaStatus.RuntimeError); + + EngineLuaException withCause = new("TargetMemory.ReadInt32", LuaStatus.RuntimeError, + "The protected call failed.", cause); + + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.Kind); + Assert.Equal("TargetMemory.ReadInt32", exception.Operation); + Assert.Equal(LuaStatus.RuntimeError, exception.Status); + Assert.Equal("The protected Lua operation 'TargetMemory.ReadInt32' failed with status LUA_ERRRUN.", + exception.Message); + Assert.DoesNotContain("internal Lua stack", exception.Message, StringComparison.Ordinal); + Assert.Same(cause, withCause.InnerException); + Assert.DoesNotContain("internal Lua stack", withCause.Message, StringComparison.Ordinal); + } + + [Fact] + public void Protected_lua_failure_rejects_a_success_status() + { + ArgumentException exception = Assert.Throws(() => + new EngineLuaException("TargetMemory.ReadInt32", LuaStatus.Ok)); + + Assert.Equal("status", exception.ParamName); + } + + [Fact] + public void Binding_failure_distinguishes_a_contract_problem_from_a_missing_capability() + { + EngineBindingException exception = new("MemoryScalars.ReadInt32"); + + Assert.Equal(EngineFailureKind.BindingFailure, exception.Kind); + Assert.Equal("MemoryScalars.ReadInt32", exception.Binding); + Assert.Equal("The Cheat Engine binding 'MemoryScalars.ReadInt32' cannot uphold its contract.", + exception.Message); + } + + [Fact] + public void Marshalling_failure_distinguishes_an_invalid_result_and_preserves_its_cause() + { + InvalidCastException cause = new("A Lua table cannot be read as an integer."); + EngineMarshallingException exception = new("TargetMemory.ReadInt32", EngineMarshallingDirection.Result, + "a 32-bit signed integer", "a table", "The target returned an invalid value.", cause); + + Assert.Equal(EngineFailureKind.MarshallingFailure, exception.Kind); + Assert.Equal("TargetMemory.ReadInt32", exception.Operation); + Assert.Equal(EngineMarshallingDirection.Result, exception.Direction); + Assert.Equal("a 32-bit signed integer", exception.Expected); + Assert.Equal("a table", exception.Actual); + Assert.Equal("The target returned an invalid value.", exception.Message); + Assert.Same(cause, exception.InnerException); + } + + [Fact] + public void Marshalling_failure_rejects_an_undefined_direction() + { + ArgumentOutOfRangeException exception = Assert.Throws(() => + new EngineMarshallingException("TargetMemory.ReadInt32", (EngineMarshallingDirection) 42, + "a 32-bit signed integer", "a table")); + + Assert.Equal("direction", exception.ParamName); + } + + [Fact] + public void Target_identity_failure_preserves_the_observed_mismatch_category() + { + TargetProcessIncarnation observedIncarnation = new(43, 2); + TargetSelectionObservation observed = TargetSelectionObservation.Qualified(observedIncarnation); + TargetIdentityCheck check = new(TargetIdentityCheckKind.TargetChanged, observed); + EngineTargetIdentityException exception = new("TargetMemoryDeallocate", check); + + Assert.Equal(EngineFailureKind.TargetIdentityMismatch, exception.Kind); + Assert.Equal(TargetIdentityCheckKind.TargetChanged, exception.Check.Kind); + Assert.True(exception.Check.Observed.Incarnation.HasValue); + Assert.Equal(observedIncarnation, exception.Check.Observed.Incarnation.GetValueOrDefault()); + } + + [Fact] + public void Target_identity_failure_kinds_append_without_reassigning_the_existing_failure_values() + { + Assert.Equal(6, (int) EngineFailureKind.TargetIdentityUnavailable); + Assert.Equal(7, (int) EngineFailureKind.TargetIdentityMismatch); + } + + [Fact] + public void Resource_handoff_failure_preserves_the_primary_cause_and_the_single_cleanup_diagnostic() + { + InvalidOperationException cause = new("injected owner publication failure"); + TargetReleaseOutcome cleanup = TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); + EngineResourceHandoffException exception = new("AutoAssemblerApply", cleanup, cause); + + Assert.Equal(EngineFailureKind.BindingFailure, exception.Kind); + Assert.Equal("AutoAssemblerApply", exception.Operation); + Assert.Equal(cleanup, exception.CleanupOutcome); + Assert.True(exception.CleanupOutcome.RequiresManualRecovery); + Assert.Same(cause, exception.InnerException); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Public_identifiers_must_not_be_null_or_empty(string? value) + { + Assert.Throws(() => new EngineOperationFailedException(value!)); + Assert.Throws(() => new EngineCapabilityUnavailableException(value!)); + Assert.Throws(() => new EngineGlobalUnavailableException(value!)); + Assert.Throws(() => new EngineBindingException(value!)); + Assert.Throws(() => new EngineMarshallingException("TargetMemory.ReadInt32", + EngineMarshallingDirection.Result, value!, "a nil value")); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs index 8a543ab4..9e188b9b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs @@ -1,6 +1,8 @@ using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Inspection; @@ -12,376 +14,380 @@ namespace CheatEngine.SDK.Engine.Tests.Inspection; [Trait("Category", "NativeLua")] public sealed class EngineInspectionTests { - [Fact] - public void EnumerateModules_current_and_explicit_process_copy_ce77_fields_and_preserve_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - enumModules = function(pid) - if pid == nil then - return { - { Name = 'game.exe', Address = 0x140000000, Size = 0x320000, Is64Bit = true, PathToFile = 'C:/games/game.exe' }, - { Name = 'xinput1_4.dll', Address = 0x180000000, Size = 0x12000, Is64Bit = true, PathToFile = 'C:/Windows/System32/xinput1_4.dll' } - } - end - if pid == 4242 then - return { { Name = 'other.exe', Address = 0x400000, Size = 0x1000, Is64Bit = false, PathToFile = 'C:/other.exe' } } - end - error('unexpected process id') - end - """u8); - - var top = L.Top; - var current = new ModuleInfo[2]; - var status = EngineInspection.EnumerateModules(current, out var currentCount); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(2, currentCount); - Assert.Equal("game.exe", current[0].Name); - Assert.Equal(0x140000000UL, current[0].BaseAddress.Value); - Assert.True(current[0].ImageSize.HasValue); - Assert.Equal(0x320000UL, current[0].ImageSize.GetValueOrDefault().Value); - Assert.True(current[0].Is64Bit); - Assert.Equal("C:/games/game.exe", current[0].PathToFile); - Assert.Equal(top, L.Top); - - var explicitProcess = new ModuleInfo[1]; - status = EngineInspection.EnumerateModules(new TargetProcessId(4242), explicitProcess, out var explicitCount); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(1, explicitCount); - Assert.Equal("other.exe", explicitProcess[0].Name); - Assert.False(explicitProcess[0].Is64Bit); - Assert.Equal(top, L.Top); - } - - [Fact] - public void EnumerateModules_without_size_returns_a_null_image_size() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - enumModules = function() - return { - { Name = 'game.exe', Address = 0x140000000, Is64Bit = true, PathToFile = 'C:/games/game.exe' } - } - end - """u8); - - var modules = new ModuleInfo[1]; - var top = L.Top; - var status = EngineInspection.EnumerateModules(modules, out var written); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(1, written); - Assert.Equal("game.exe", modules[0].Name); - Assert.Null(modules[0].ImageSize); - Assert.Equal(top, L.Top); - } - - [Fact] - public void EnumerateModules_destination_too_small_writes_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - enumModules = function() - return { - { Name = 'one', Address = 1, Size = 1, Is64Bit = false, PathToFile = 'one' }, - { Name = 'two', Address = 2, Size = 2, Is64Bit = false, PathToFile = 'two' } - } - end - """u8); - - ModuleInfo sentinel = new("sentinel", 0x10, new MemorySize(4), Is64Bit: false, "sentinel"); - ModuleInfo[] destination = [sentinel]; - var top = L.Top; - var status = EngineInspection.EnumerateModules(destination, out var written); - - Assert.Equal(InspectionStatus.DestinationTooSmall, status); - Assert.Equal(0, written); - Assert.Equal(sentinel, destination[0]); - Assert.Equal(top, L.Top); - } - - [Fact] - public void EnumerateModules_malformed_later_entry_writes_no_partial_snapshot() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - enumModules = function() - return { - { Name = 'valid', Address = 1, Size = 1, Is64Bit = false, PathToFile = 'valid' }, - { Name = 'bad', Address = 2, Size = 2, Is64Bit = 'not-a-boolean', PathToFile = 'bad' } - } - end - """u8); - - ModuleInfo first = new("first", 0x10, new MemorySize(1), Is64Bit: false, "first"); - ModuleInfo second = new("second", 0x20, new MemorySize(2), Is64Bit: true, "second"); - ModuleInfo[] destination = [first, second]; - var top = L.Top; - var status = EngineInspection.EnumerateModules(destination, out var written); - - Assert.Equal(InspectionStatus.InvalidResult, status); - Assert.Equal(0, written); - Assert.Equal(first, destination[0]); - Assert.Equal(second, destination[1]); - Assert.Equal(top, L.Top); - } - - [Fact] - public void EnumerateSections_address_and_name_selectors_copy_file_offsets_without_confusing_them_with_addresses() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - enumSectionsOfModule = function(selector) - if selector == 0x140000000 then - return { { Name = '.text', Size = 0x5000, Address = 0x140001000, FileAddress = 0x1000 } } - end - if selector == 'game.exe' then - return { { Name = '.rdata', Size = 0x2000, Address = 0x140006000, FileAddress = 0x6000 } } - end - error('unexpected selector') - end - """u8); - - var sections = new ModuleSectionInfo[1]; - var top = L.Top; - var status = EngineInspection.EnumerateSections(0x140000000UL, sections, out var written); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(1, written); - Assert.Equal(".text", sections[0].Name); - Assert.Equal(0x140001000UL, sections[0].Address.Value); - Assert.Equal(0x1000UL, sections[0].FileOffset.Value); - Assert.Equal(top, L.Top); - - status = EngineInspection.EnumerateSections(new ModuleName("game.exe"), sections, out written); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(1, written); - Assert.Equal(".rdata", sections[0].Name); - Assert.Equal(0x6000UL, sections[0].FileOffset.Value); - Assert.Equal(top, L.Top); - } - - [Fact] - public void ResolveAddress_distinguishes_a_nil_miss_from_a_zero_address_and_lua_failure() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - getAddressSafe = function(expression, localFlag, shallow) - if expression == 'zero' and not localFlag and not shallow then return 0 end - if expression == 'hostSymbol' and localFlag and shallow then return 0x7FF600001000 end - if expression == 'broken' then error('symbol handler unavailable') end - return nil - end - """u8); - - var top = L.Top; - var status = EngineInspection.ResolveAddress(new SymbolExpression("zero"), default, out var zero); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(Address.Zero, zero); - Assert.Equal(top, L.Top); - - status = EngineInspection.ResolveHostAddress(new SymbolExpression("hostSymbol"), - new AddressResolutionOptions(Shallow: true), out var found); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(unchecked((nuint)0x7FF600001000UL), found.Value); - Assert.Equal(top, L.Top); - - status = EngineInspection.ResolveAddress(new SymbolExpression("missing"), default, out var missing); - - Assert.Equal(InspectionStatus.NotFound, status); - Assert.Equal(Address.Zero, missing); - Assert.Equal(top, L.Top); - - status = EngineInspection.ResolveAddress(new SymbolExpression("broken"), default, out var failed); - - Assert.Equal(InspectionStatus.LuaFailure, status); - Assert.Equal(Address.Zero, failed); - Assert.Equal(top, L.Top); - } - - [Fact] - public void ResolveAddress_reports_an_unavailable_global_without_entering_lua() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var top = L.Top; - - var status = EngineInspection.ResolveAddress(new SymbolExpression("missingGlobal"), default, out var address); - - Assert.Equal(InspectionStatus.GlobalUnavailable, status); - Assert.Equal(Address.Zero, address); - Assert.Equal(top, L.Top); - } - - [Fact] - public void Legacy_positional_host_option_is_preserved_but_rejected_by_target_resolution() - { - var legacy = new AddressResolutionOptions(true); - legacy.Deconstruct(UseHostSymbolTable: out var useHostSymbolTable, Shallow: out var shallow); - - Assert.True(useHostSymbolTable); - Assert.False(shallow); - Assert.Throws(() => - EngineInspection.ResolveAddress(new SymbolExpression("hostSymbol"), legacy, out _)); - } - - [Fact] - public void AddressResolutionOptions_preserves_init_and_with_compatibility() - { - var options = new AddressResolutionOptions { Shallow = true }; - var updated = options with { Shallow = false }; - - Assert.True(options.Shallow); - Assert.False(updated.Shallow); + [Fact] + public void EnumerateModules_current_and_explicit_process_copy_ce77_fields_and_preserve_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + enumModules = function(pid) + if pid == nil then + return { + { Name = 'game.exe', Address = 0x140000000, Size = 0x320000, Is64Bit = true, PathToFile = 'C:/games/game.exe' }, + { Name = 'xinput1_4.dll', Address = 0x180000000, Size = 0x12000, Is64Bit = true, PathToFile = 'C:/Windows/System32/xinput1_4.dll' } + } + end + if pid == 4242 then + return { { Name = 'other.exe', Address = 0x400000, Size = 0x1000, Is64Bit = false, PathToFile = 'C:/other.exe' } } + end + error('unexpected process id') + end + """u8); + + int top = L.Top; + ModuleInfo[] current = new ModuleInfo[2]; + InspectionStatus status = EngineInspection.EnumerateModules(current, out int currentCount); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(2, currentCount); + Assert.Equal("game.exe", current[0].Name); + Assert.Equal(0x140000000UL, current[0].BaseAddress.Value); + Assert.True(current[0].ImageSize.HasValue); + Assert.Equal(0x320000UL, current[0].ImageSize.GetValueOrDefault().Value); + Assert.True(current[0].Is64Bit); + Assert.Equal("C:/games/game.exe", current[0].PathToFile); + Assert.Equal(top, L.Top); + + ModuleInfo[] explicitProcess = new ModuleInfo[1]; + status = EngineInspection.EnumerateModules(new TargetProcessId(4242), explicitProcess, out int explicitCount); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(1, explicitCount); + Assert.Equal("other.exe", explicitProcess[0].Name); + Assert.False(explicitProcess[0].Is64Bit); + Assert.Equal(top, L.Top); + } + + [Fact] + public void EnumerateModules_without_size_returns_a_null_image_size() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + enumModules = function() + return { + { Name = 'game.exe', Address = 0x140000000, Is64Bit = true, PathToFile = 'C:/games/game.exe' } + } + end + """u8); + + ModuleInfo[] modules = new ModuleInfo[1]; + int top = L.Top; + InspectionStatus status = EngineInspection.EnumerateModules(modules, out int written); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(1, written); + Assert.Equal("game.exe", modules[0].Name); + Assert.Null(modules[0].ImageSize); + Assert.Equal(top, L.Top); + } + + [Fact] + public void EnumerateModules_destination_too_small_writes_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + enumModules = function() + return { + { Name = 'one', Address = 1, Size = 1, Is64Bit = false, PathToFile = 'one' }, + { Name = 'two', Address = 2, Size = 2, Is64Bit = false, PathToFile = 'two' } + } + end + """u8); + + ModuleInfo sentinel = new("sentinel", 0x10, new MemorySize(4), false, "sentinel"); + ModuleInfo[] destination = [sentinel]; + int top = L.Top; + InspectionStatus status = EngineInspection.EnumerateModules(destination, out int written); + + Assert.Equal(InspectionStatus.DestinationTooSmall, status); + Assert.Equal(0, written); + Assert.Equal(sentinel, destination[0]); + Assert.Equal(top, L.Top); + } + + [Fact] + public void EnumerateModules_malformed_later_entry_writes_no_partial_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + enumModules = function() + return { + { Name = 'valid', Address = 1, Size = 1, Is64Bit = false, PathToFile = 'valid' }, + { Name = 'bad', Address = 2, Size = 2, Is64Bit = 'not-a-boolean', PathToFile = 'bad' } + } + end + """u8); + + ModuleInfo first = new("first", 0x10, new MemorySize(1), false, "first"); + ModuleInfo second = new("second", 0x20, new MemorySize(2), true, "second"); + ModuleInfo[] destination = [first, second]; + int top = L.Top; + InspectionStatus status = EngineInspection.EnumerateModules(destination, out int written); + + Assert.Equal(InspectionStatus.InvalidResult, status); + Assert.Equal(0, written); + Assert.Equal(first, destination[0]); + Assert.Equal(second, destination[1]); + Assert.Equal(top, L.Top); + } + + [Fact] + public void EnumerateSections_address_and_name_selectors_copy_file_offsets_without_confusing_them_with_addresses() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + enumSectionsOfModule = function(selector) + if selector == 0x140000000 then + return { { Name = '.text', Size = 0x5000, Address = 0x140001000, FileAddress = 0x1000 } } + end + if selector == 'game.exe' then + return { { Name = '.rdata', Size = 0x2000, Address = 0x140006000, FileAddress = 0x6000 } } + end + error('unexpected selector') + end + """u8); + + ModuleSectionInfo[] sections = new ModuleSectionInfo[1]; + int top = L.Top; + InspectionStatus status = EngineInspection.EnumerateSections(0x140000000UL, sections, out int written); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(1, written); + Assert.Equal(".text", sections[0].Name); + Assert.Equal(0x140001000UL, sections[0].Address.Value); + Assert.Equal(0x1000UL, sections[0].FileOffset.Value); + Assert.Equal(top, L.Top); + + status = EngineInspection.EnumerateSections(new ModuleName("game.exe"), sections, out written); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(1, written); + Assert.Equal(".rdata", sections[0].Name); + Assert.Equal(0x6000UL, sections[0].FileOffset.Value); + Assert.Equal(top, L.Top); + } + + [Fact] + public void ResolveAddress_distinguishes_a_nil_miss_from_a_zero_address_and_lua_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + getAddressSafe = function(expression, localFlag, shallow) + if expression == 'zero' and not localFlag and not shallow then return 0 end + if expression == 'hostSymbol' and localFlag and shallow then return 0x7FF600001000 end + if expression == 'broken' then error('symbol handler unavailable') end + return nil + end + """u8); + + int top = L.Top; + InspectionStatus status = + EngineInspection.ResolveAddress(new SymbolExpression("zero"), default, out Address zero); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(Address.Zero, zero); + Assert.Equal(top, L.Top); + + status = EngineInspection.ResolveHostAddress(new SymbolExpression("hostSymbol"), + new AddressResolutionOptions(Shallow: true), out HostAddress found); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(unchecked((nuint) 0x7FF600001000UL), found.Value); + Assert.Equal(top, L.Top); + + status = EngineInspection.ResolveAddress(new SymbolExpression("missing"), default, out Address missing); + + Assert.Equal(InspectionStatus.NotFound, status); + Assert.Equal(Address.Zero, missing); + Assert.Equal(top, L.Top); + + status = EngineInspection.ResolveAddress(new SymbolExpression("broken"), default, out Address failed); + + Assert.Equal(InspectionStatus.LuaFailure, status); + Assert.Equal(Address.Zero, failed); + Assert.Equal(top, L.Top); + } + + [Fact] + public void ResolveAddress_reports_an_unavailable_global_without_entering_lua() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + int top = L.Top; + + InspectionStatus status = + EngineInspection.ResolveAddress(new SymbolExpression("missingGlobal"), default, out Address address); + + Assert.Equal(InspectionStatus.GlobalUnavailable, status); + Assert.Equal(Address.Zero, address); + Assert.Equal(top, L.Top); + } + + [Fact] + public void Legacy_positional_host_option_is_preserved_but_rejected_by_target_resolution() + { + AddressResolutionOptions legacy = new(true); + legacy.Deconstruct(out bool useHostSymbolTable, out bool shallow); + + Assert.True(useHostSymbolTable); + Assert.False(shallow); + Assert.Throws(() => + EngineInspection.ResolveAddress(new SymbolExpression("hostSymbol"), legacy, out _)); + } + + [Fact] + public void AddressResolutionOptions_preserves_init_and_with_compatibility() + { + AddressResolutionOptions options = new() { Shallow = true }; + AddressResolutionOptions updated = options with { Shallow = false }; + + Assert.True(options.Shallow); + Assert.False(updated.Shallow); #pragma warning disable CS0618 - var legacy = new AddressResolutionOptions { UseHostSymbolTable = true }; + AddressResolutionOptions legacy = new() { UseHostSymbolTable = true }; #pragma warning restore CS0618 #pragma warning disable CS0618 - Assert.True(legacy.UseHostSymbolTable); + Assert.True(legacy.UseHostSymbolTable); #pragma warning restore CS0618 - } - - [Fact] - public void Inspection_preserves_lua_failures_from_global_and_table_field_resolution() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - setmetatable(_G, { __index = function(_, key) - if key == 'getAddressSafe' then error('global lookup failed') end - end }) - getSymbolInfo = function() - return setmetatable({}, { __index = function() error('field lookup failed') end }) - end - """u8); - - var top = L.Top; - var status = EngineInspection.ResolveAddress(new SymbolExpression("any"), default, out var address); - Assert.Equal(InspectionStatus.LuaFailure, status); - Assert.Equal(Address.Zero, address); - Assert.Equal(top, L.Top); - - status = EngineInspection.GetSymbolInfo(new SymbolExpression("any"), out var symbol); - Assert.Equal(InspectionStatus.LuaFailure, status); - Assert.Equal(default, symbol); - Assert.Equal(top, L.Top); - } - - [Fact] - public void GetSymbolInfo_uses_the_symbol_list_canonical_symbolsize_field_and_preserves_nil_absence() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - getSymbolInfo = function(expression) - if expression == 'Game.Update' then - return { modulename = 'game.exe', searchkey = 'Game.Update', address = 0x140012340, symbolsize = 42 } - end - if expression == 'malformed' then return { modulename = 'game.exe', searchkey = 'bad', address = 1, size = 42 } end - return nil - end - """u8); - - var top = L.Top; - var status = EngineInspection.GetSymbolInfo(new SymbolExpression("Game.Update"), out var symbol); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal("game.exe", symbol.ModuleName); - Assert.Equal("Game.Update", symbol.SearchKey); - Assert.Equal(0x140012340UL, symbol.Address.Value); - Assert.Equal(42UL, symbol.Size.Value); - Assert.Equal(top, L.Top); - - status = EngineInspection.GetSymbolInfo(new SymbolExpression("missing"), out symbol); - Assert.Equal(InspectionStatus.NotFound, status); - Assert.Equal(default, symbol); - Assert.Equal(top, L.Top); - - status = EngineInspection.GetSymbolInfo(new SymbolExpression("malformed"), out symbol); - Assert.Equal(InspectionStatus.InvalidResult, status); - Assert.Equal(default, symbol); - Assert.Equal(top, L.Top); - } - - [Fact] - public void Memory_regions_copy_windows_fields_optional_extra_and_reject_malformed_or_nil_single_records() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - local function region(base, extra) - return { - BaseAddress = base, AllocationBase = base, AllocationProtect = 0x04, - RegionSize = 0x2000, State = 0x1000, Protect = 0x20, Type = 0x1000000, Extra = extra - } - end - enumMemoryRegions = function() return { region(0x140000000, 'C:/game.exe'), region(0x150000000, nil) } end - getMemoryRegionInfo = function(address) - if address == 0x140000000 then return region(address, 'C:/game.exe') end - if address == 0x1234 then return { BaseAddress = 1 } end - return nil - end - """u8); - - var regions = new MemoryRegionInfo[2]; - var top = L.Top; - var status = EngineInspection.EnumerateMemoryRegions(regions, out var written); - - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(2, written); - Assert.Equal(0x140000000UL, regions[0].BaseAddress.Value); - Assert.Equal(0x2000UL, regions[0].Size.Value); - Assert.Equal(MemoryRegionState.Committed, regions[0].State); - Assert.Equal(MemoryRegionType.Image, regions[0].Type); - Assert.Equal("C:/game.exe", regions[0].Extra); - Assert.Null(regions[1].Extra); - Assert.Equal(top, L.Top); - - status = EngineInspection.GetMemoryRegionInfo(0x140000000UL, out var one); - Assert.Equal(InspectionStatus.Success, status); - Assert.Equal(regions[0], one); - Assert.Equal(top, L.Top); - - status = EngineInspection.GetMemoryRegionInfo(0x1234UL, out one); - Assert.Equal(InspectionStatus.InvalidResult, status); - Assert.Equal(default, one); - Assert.Equal(top, L.Top); - - status = EngineInspection.GetMemoryRegionInfo(0x9999UL, out one); - Assert.Equal(InspectionStatus.InvalidResult, status); - Assert.Equal(default, one); - Assert.Equal(top, L.Top); - } + } + + [Fact] + public void Inspection_preserves_lua_failures_from_global_and_table_field_resolution() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + setmetatable(_G, { __index = function(_, key) + if key == 'getAddressSafe' then error('global lookup failed') end + end }) + getSymbolInfo = function() + return setmetatable({}, { __index = function() error('field lookup failed') end }) + end + """u8); + + int top = L.Top; + InspectionStatus status = + EngineInspection.ResolveAddress(new SymbolExpression("any"), default, out Address address); + Assert.Equal(InspectionStatus.LuaFailure, status); + Assert.Equal(Address.Zero, address); + Assert.Equal(top, L.Top); + + status = EngineInspection.GetSymbolInfo(new SymbolExpression("any"), out SymbolInfo symbol); + Assert.Equal(InspectionStatus.LuaFailure, status); + Assert.Equal(default, symbol); + Assert.Equal(top, L.Top); + } + + [Fact] + public void GetSymbolInfo_uses_the_symbol_list_canonical_symbolsize_field_and_preserves_nil_absence() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + getSymbolInfo = function(expression) + if expression == 'Game.Update' then + return { modulename = 'game.exe', searchkey = 'Game.Update', address = 0x140012340, symbolsize = 42 } + end + if expression == 'malformed' then return { modulename = 'game.exe', searchkey = 'bad', address = 1, size = 42 } end + return nil + end + """u8); + + int top = L.Top; + InspectionStatus status = + EngineInspection.GetSymbolInfo(new SymbolExpression("Game.Update"), out SymbolInfo symbol); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal("game.exe", symbol.ModuleName); + Assert.Equal("Game.Update", symbol.SearchKey); + Assert.Equal(0x140012340UL, symbol.Address.Value); + Assert.Equal(42UL, symbol.Size.Value); + Assert.Equal(top, L.Top); + + status = EngineInspection.GetSymbolInfo(new SymbolExpression("missing"), out symbol); + Assert.Equal(InspectionStatus.NotFound, status); + Assert.Equal(default, symbol); + Assert.Equal(top, L.Top); + + status = EngineInspection.GetSymbolInfo(new SymbolExpression("malformed"), out symbol); + Assert.Equal(InspectionStatus.InvalidResult, status); + Assert.Equal(default, symbol); + Assert.Equal(top, L.Top); + } + + [Fact] + public void Memory_regions_copy_windows_fields_optional_extra_and_reject_malformed_or_nil_single_records() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + local function region(base, extra) + return { + BaseAddress = base, AllocationBase = base, AllocationProtect = 0x04, + RegionSize = 0x2000, State = 0x1000, Protect = 0x20, Type = 0x1000000, Extra = extra + } + end + enumMemoryRegions = function() return { region(0x140000000, 'C:/game.exe'), region(0x150000000, nil) } end + getMemoryRegionInfo = function(address) + if address == 0x140000000 then return region(address, 'C:/game.exe') end + if address == 0x1234 then return { BaseAddress = 1 } end + return nil + end + """u8); + + MemoryRegionInfo[] regions = new MemoryRegionInfo[2]; + int top = L.Top; + InspectionStatus status = EngineInspection.EnumerateMemoryRegions(regions, out int written); + + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(2, written); + Assert.Equal(0x140000000UL, regions[0].BaseAddress.Value); + Assert.Equal(0x2000UL, regions[0].Size.Value); + Assert.Equal(MemoryRegionState.Committed, regions[0].State); + Assert.Equal(MemoryRegionType.Image, regions[0].Type); + Assert.Equal("C:/game.exe", regions[0].Extra); + Assert.Null(regions[1].Extra); + Assert.Equal(top, L.Top); + + status = EngineInspection.GetMemoryRegionInfo(0x140000000UL, out MemoryRegionInfo one); + Assert.Equal(InspectionStatus.Success, status); + Assert.Equal(regions[0], one); + Assert.Equal(top, L.Top); + + status = EngineInspection.GetMemoryRegionInfo(0x1234UL, out one); + Assert.Equal(InspectionStatus.InvalidResult, status); + Assert.Equal(default, one); + Assert.Equal(top, L.Top); + + status = EngineInspection.GetMemoryRegionInfo(0x9999UL, out one); + Assert.Equal(InspectionStatus.InvalidResult, status); + Assert.Equal(default, one); + Assert.Equal(top, L.Top); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/InspectionValueTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/InspectionValueTests.cs index 624a4bdd..e03998d2 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/InspectionValueTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/InspectionValueTests.cs @@ -5,40 +5,40 @@ namespace CheatEngine.SDK.Engine.Tests.Inspection; /// The managed-only value and selector invariants of the inspection surface. public sealed class InspectionValueTests { - [Fact] - public void TargetProcessId_rejects_nonpositive_values() - { - Assert.Throws(() => new TargetProcessId(0)); - Assert.Throws(() => new TargetProcessId(-1)); - Assert.Equal(42, new TargetProcessId(42).Value); - } + [Fact] + public void TargetProcessId_rejects_nonpositive_values() + { + Assert.Throws(() => new TargetProcessId(0)); + Assert.Throws(() => new TargetProcessId(-1)); + Assert.Equal(42, new TargetProcessId(42).Value); + } - [Fact] - public void Explicit_process_enumeration_rejects_the_default_process_identifier_before_lua_is_acquired() - { - var destination = new ModuleInfo[1]; + [Fact] + public void Explicit_process_enumeration_rejects_the_default_process_identifier_before_lua_is_acquired() + { + ModuleInfo[] destination = new ModuleInfo[1]; - Assert.Throws(() => - EngineInspection.EnumerateModules(default, destination, out _)); - } + Assert.Throws(() => + EngineInspection.EnumerateModules(default, destination, out _)); + } - [Fact] - public void Module_and_symbol_selectors_reject_empty_values_and_compare_ordinally() - { - Assert.Throws(() => new ModuleName("")); - Assert.Throws(() => new ModuleName(" \t")); - Assert.Throws(() => new SymbolExpression("")); + [Fact] + public void Module_and_symbol_selectors_reject_empty_values_and_compare_ordinally() + { + Assert.Throws(() => new ModuleName("")); + Assert.Throws(() => new ModuleName(" \t")); + Assert.Throws(() => new SymbolExpression("")); - Assert.Equal(new ModuleName("GAME.EXE"), new ModuleName("GAME.EXE")); - Assert.NotEqual(new ModuleName("GAME.EXE"), new ModuleName("game.exe")); - Assert.NotEqual(new SymbolExpression("Game.Update"), new SymbolExpression("game.update")); - } + Assert.Equal(new ModuleName("GAME.EXE"), new ModuleName("GAME.EXE")); + Assert.NotEqual(new ModuleName("GAME.EXE"), new ModuleName("game.exe")); + Assert.NotEqual(new SymbolExpression("Game.Update"), new SymbolExpression("game.update")); + } - [Fact] - public void MemorySize_and_file_offset_are_distinct_value_categories() - { - Assert.Equal("8192", new MemorySize(8192).ToString()); - Assert.Equal("2000", new ModuleFileOffset(0x2000).ToString()); - Assert.True(new MemorySize(8) < new MemorySize(16)); - } + [Fact] + public void MemorySize_and_file_offset_are_distinct_value_categories() + { + Assert.Equal("8192", new MemorySize(8192).ToString()); + Assert.Equal("2000", new ModuleFileOffset(0x2000).ToString()); + Assert.True(new MemorySize(8) < new MemorySize(16)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs index b3696224..f238433f 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Inspection; @@ -9,275 +10,279 @@ namespace CheatEngine.SDK.Engine.Tests.Inspection; [Trait("Category", "NativeLua")] public sealed class SymbolRegistryTests { - [Fact] - public void TryGetName_forwards_only_the_source_mapped_address() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - getNameFromAddress = function(address, second) - if address == 0x140001000 and second == nil then - return 'one-argument-default' - end - return 42 - end - """u8); - - var top = L.Top; - var status = SymbolRegistry.TryGetName(0x140001000UL, out var defaultName); - - Assert.Equal(LuaOperationStatusKind.Success, status.Kind); - Assert.Equal("one-argument-default", defaultName); - Assert.Equal(top, L.Top); - - } - - [Fact] - public void Register_and_unregister_forward_typed_values_and_preserve_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - registered = nil - removed = nil - registerSymbol = function(name, address, doNotSave) - registered = { name = name, address = address, doNotSave = doNotSave } - end - unregisterSymbol = function(name) removed = name end - """u8); - - var top = L.Top; - SymbolName name = new("Player.Health"); - var registration = SymbolRegistry.Register(name, 0x140001234UL, - new SymbolRegistrationOptions(DoNotSave: true)); - - Assert.Equal(LuaOperationStatusKind.Success, registration.Kind); - Assert.Equal(top, L.Top); - EngineTest.Run(L, - "assert(registered.name == 'Player.Health' and registered.address == 0x140001234 and registered.doNotSave)"u8); - Assert.Equal(top, L.Top); - - var release = SymbolRegistry.Unregister(name); - - Assert.Equal(LuaOperationStatusKind.Success, release.Kind); - Assert.Equal(top, L.Top); - EngineTest.Run(L, "assert(removed == 'Player.Health')"u8); - Assert.Equal(top, L.Top); - } - - [Fact] - public void Registry_distinguishes_missing_globals_lua_failures_and_invalid_name_results() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var top = L.Top; - - var status = SymbolRegistry.TryGetName(0x140001000UL, out var unavailableName); - - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, status.Kind); - Assert.Null(unavailableName); - Assert.Equal(top, L.Top); - - EngineTest.Run(L, """ - getNameFromAddress = function() return nil end - registerSymbol = function() error('registration rejected') end - unregisterSymbol = function() error('removal rejected') end - """u8); - - status = SymbolRegistry.TryGetName(0x140001000UL, out var malformedName); - - Assert.Equal(LuaOperationStatusKind.NilResult, status.Kind); - Assert.Null(malformedName); - Assert.Equal(top, L.Top); - - SymbolName name = new("Player.Health"); - Assert.Equal(LuaOperationStatusKind.LuaFailure, SymbolRegistry.Register(name, 0x140001000UL).Kind); - Assert.Equal(top, L.Top); - Assert.Equal(LuaOperationStatusKind.LuaFailure, SymbolRegistry.Unregister(name).Kind); - Assert.Equal(top, L.Top); - } - - [Fact] - public void Symbol_name_rejects_missing_text_and_keeps_ordinal_identity() - { - Assert.Throws(() => new SymbolName("")); - Assert.Throws(() => new SymbolName(" \t")); - - SymbolName upper = new("Player.Health"); - SymbolName same = new("Player.Health"); - SymbolName differentCase = new("player.health"); - - Assert.Equal(upper, same); - Assert.NotEqual(upper, differentCase); - Assert.Equal("Player.Health", upper.ToString()); - } - - [Fact] - public void Owned_registration_lease_never_unregisters_a_newer_coordinated_registration() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - registrations = 0 - removals = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end - unregisterSymbol = function(name) removals = removals + 1 end - """u8); - SymbolName name = new("Player.Health"); - - var first = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); - var second = SymbolRegistry.TryRegisterOwned(name, 0x140002000UL); - - Assert.True(first.HasLease); - Assert.True(second.HasLease); - Assert.Equal(SymbolRegistrationReleaseKind.Superseded, first.Lease!.Release().Kind); - Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, first.Lease.Release().Kind); - Assert.Equal(SymbolRegistrationReleaseKind.Released, second.Lease!.Release().Kind); - Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, second.Lease.Release().Kind); - EngineTest.Run(L, "assert(registrations == 2 and removals == 1)"u8); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Symbol_mutations_that_raise_after_starting_supersede_tracked_leases() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - registerFailures = 0 - unregisterFailures = 0 - registerSymbol = function(name, address, doNotSave) - if address == 0x140002000 then - registerFailures = registerFailures + 1 - error('raised after registering') - end - end - unregisterSymbol = function(name) - unregisterFailures = unregisterFailures + 1 - error('raised after unregistering') - end - """u8); - SymbolName name = new("Player.Health"); - - var first = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); - var failedOwned = SymbolRegistry.TryRegisterOwned(name, 0x140002000UL); - - Assert.True(first.HasLease); - Assert.False(failedOwned.HasLease); - Assert.Equal(LuaOperationStatusKind.LuaFailure, failedOwned.Status.Kind); - Assert.Equal(SymbolRegistrationReleaseKind.Superseded, first.Lease!.Release().Kind); - - var second = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); - var failedDirectRegistration = SymbolRegistry.Register(name, 0x140002000UL); - - Assert.True(second.HasLease); - Assert.Equal(LuaOperationStatusKind.LuaFailure, failedDirectRegistration.Kind); - Assert.Equal(SymbolRegistrationReleaseKind.Superseded, second.Lease!.Release().Kind); - - var third = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); - var failedDirectUnregistration = SymbolRegistry.Unregister(name); - - Assert.True(third.HasLease); - Assert.Equal(LuaOperationStatusKind.LuaFailure, failedDirectUnregistration.Kind); - Assert.Equal(SymbolRegistrationReleaseKind.Superseded, third.Lease!.Release().Kind); - EngineTest.Run(L, "assert(registerFailures == 2 and unregisterFailures == 1)"u8); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Owned_registration_becomes_stale_without_unregistering_after_runtime_detaches() - { - EngineTest.RequireNativeLua(); - SymbolRegistrationLease lease; - using (NativeLuaState state = new()) - using (HostScope scope = new(state)) - { - var L = scope.State; - EngineTest.Run(L, """ - registerSymbol = function(name, address, doNotSave) end - unregisterSymbol = function(name) error('must not run after detach') end - """u8); - var acquired = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); - Assert.True(acquired.HasLease); - lease = acquired.Lease!; - } - - var release = lease.Release(); - - Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, release.Kind); - Assert.True(lease.IsTerminal); - } - - [Fact] - public void Owned_registration_reports_failed_acquisition_without_creating_a_lease() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - - var outcome = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); - - Assert.False(outcome.HasLease); - Assert.Null(outcome.Lease); - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, outcome.Status.Kind); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Owned_registration_cleanup_can_retry_unavailability_and_dispose_only_once() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "registerSymbol = function(name, address, doNotSave) end"u8); - - var retryable = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL, - new SymbolRegistrationOptions(DoNotSave: true)); - - Assert.True(retryable.HasLease); - Assert.True(retryable.Lease!.Options.DoNotSave); - var unavailable = retryable.Lease.Release(); - Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, unavailable.Kind); - Assert.False(unavailable.IsTerminal); - Assert.False(retryable.Lease.IsTerminal); - - EngineTest.Run(L, "unregisterSymbol = function(name) end"u8); - var released = retryable.Lease.Release(); - Assert.Equal(SymbolRegistrationReleaseKind.Released, released.Kind); - Assert.True(released.IsTerminal); - retryable.Lease.Dispose(); - Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, retryable.Lease.Release().Kind); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Owned_registration_cleanup_after_a_lua_failure_is_indeterminate_and_terminal() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - - EngineTest.Run(L, "registerSymbol = function(name, address, doNotSave) end; unregisterSymbol = function(name) error('cleanup started then failed') end"u8); - var failed = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Mana"), 0x140002000UL); - var indeterminate = failed.Lease!.Release(); - - Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, indeterminate.Kind); - Assert.True(indeterminate.IsTerminal); - Assert.False(indeterminate.Status.IsSuccess); - Assert.True(failed.Lease.IsTerminal); - Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, failed.Lease.Release().Kind); - Assert.Equal(0, L.Top); - } + [Fact] + public void TryGetName_forwards_only_the_source_mapped_address() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + getNameFromAddress = function(address, second) + if address == 0x140001000 and second == nil then + return 'one-argument-default' + end + return 42 + end + """u8); + + int top = L.Top; + LuaOperationStatus status = SymbolRegistry.TryGetName(0x140001000UL, out string? defaultName); + + Assert.Equal(LuaOperationStatusKind.Success, status.Kind); + Assert.Equal("one-argument-default", defaultName); + Assert.Equal(top, L.Top); + } + + [Fact] + public void Register_and_unregister_forward_typed_values_and_preserve_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registered = nil + removed = nil + registerSymbol = function(name, address, doNotSave) + registered = { name = name, address = address, doNotSave = doNotSave } + end + unregisterSymbol = function(name) removed = name end + """u8); + + int top = L.Top; + SymbolName name = new("Player.Health"); + LuaOperationStatus registration = SymbolRegistry.Register(name, 0x140001234UL, + new SymbolRegistrationOptions(true)); + + Assert.Equal(LuaOperationStatusKind.Success, registration.Kind); + Assert.Equal(top, L.Top); + EngineTest.Run(L, + "assert(registered.name == 'Player.Health' and registered.address == 0x140001234 and registered.doNotSave)"u8); + Assert.Equal(top, L.Top); + + LuaOperationStatus release = SymbolRegistry.Unregister(name); + + Assert.Equal(LuaOperationStatusKind.Success, release.Kind); + Assert.Equal(top, L.Top); + EngineTest.Run(L, "assert(removed == 'Player.Health')"u8); + Assert.Equal(top, L.Top); + } + + [Fact] + public void Registry_distinguishes_missing_globals_lua_failures_and_invalid_name_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + int top = L.Top; + + LuaOperationStatus status = SymbolRegistry.TryGetName(0x140001000UL, out string? unavailableName); + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, status.Kind); + Assert.Null(unavailableName); + Assert.Equal(top, L.Top); + + EngineTest.Run(L, """ + getNameFromAddress = function() return nil end + registerSymbol = function() error('registration rejected') end + unregisterSymbol = function() error('removal rejected') end + """u8); + + status = SymbolRegistry.TryGetName(0x140001000UL, out string? malformedName); + + Assert.Equal(LuaOperationStatusKind.NilResult, status.Kind); + Assert.Null(malformedName); + Assert.Equal(top, L.Top); + + SymbolName name = new("Player.Health"); + Assert.Equal(LuaOperationStatusKind.LuaFailure, SymbolRegistry.Register(name, 0x140001000UL).Kind); + Assert.Equal(top, L.Top); + Assert.Equal(LuaOperationStatusKind.LuaFailure, SymbolRegistry.Unregister(name).Kind); + Assert.Equal(top, L.Top); + } + + [Fact] + public void Symbol_name_rejects_missing_text_and_keeps_ordinal_identity() + { + Assert.Throws(() => new SymbolName("")); + Assert.Throws(() => new SymbolName(" \t")); + + SymbolName upper = new("Player.Health"); + SymbolName same = new("Player.Health"); + SymbolName differentCase = new("player.health"); + + Assert.Equal(upper, same); + Assert.NotEqual(upper, differentCase); + Assert.Equal("Player.Health", upper.ToString()); + } + + [Fact] + public void Owned_registration_lease_never_unregisters_a_newer_coordinated_registration() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registrations = 0 + removals = 0 + registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end + unregisterSymbol = function(name) removals = removals + 1 end + """u8); + SymbolName name = new("Player.Health"); + + SymbolRegistrationAcquireOutcome first = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); + SymbolRegistrationAcquireOutcome second = SymbolRegistry.TryRegisterOwned(name, 0x140002000UL); + + Assert.True(first.HasLease); + Assert.True(second.HasLease); + Assert.Equal(SymbolRegistrationReleaseKind.Superseded, first.Lease!.Release().Kind); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, first.Lease.Release().Kind); + Assert.Equal(SymbolRegistrationReleaseKind.Released, second.Lease!.Release().Kind); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, second.Lease.Release().Kind); + EngineTest.Run(L, "assert(registrations == 2 and removals == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Symbol_mutations_that_raise_after_starting_supersede_tracked_leases() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registerFailures = 0 + unregisterFailures = 0 + registerSymbol = function(name, address, doNotSave) + if address == 0x140002000 then + registerFailures = registerFailures + 1 + error('raised after registering') + end + end + unregisterSymbol = function(name) + unregisterFailures = unregisterFailures + 1 + error('raised after unregistering') + end + """u8); + SymbolName name = new("Player.Health"); + + SymbolRegistrationAcquireOutcome first = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); + SymbolRegistrationAcquireOutcome failedOwned = SymbolRegistry.TryRegisterOwned(name, 0x140002000UL); + + Assert.True(first.HasLease); + Assert.False(failedOwned.HasLease); + Assert.Equal(LuaOperationStatusKind.LuaFailure, failedOwned.Status.Kind); + Assert.Equal(SymbolRegistrationReleaseKind.Superseded, first.Lease!.Release().Kind); + + SymbolRegistrationAcquireOutcome second = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); + LuaOperationStatus failedDirectRegistration = SymbolRegistry.Register(name, 0x140002000UL); + + Assert.True(second.HasLease); + Assert.Equal(LuaOperationStatusKind.LuaFailure, failedDirectRegistration.Kind); + Assert.Equal(SymbolRegistrationReleaseKind.Superseded, second.Lease!.Release().Kind); + + SymbolRegistrationAcquireOutcome third = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); + LuaOperationStatus failedDirectUnregistration = SymbolRegistry.Unregister(name); + + Assert.True(third.HasLease); + Assert.Equal(LuaOperationStatusKind.LuaFailure, failedDirectUnregistration.Kind); + Assert.Equal(SymbolRegistrationReleaseKind.Superseded, third.Lease!.Release().Kind); + EngineTest.Run(L, "assert(registerFailures == 2 and unregisterFailures == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_becomes_stale_without_unregistering_after_runtime_detaches() + { + EngineTest.RequireNativeLua(); + SymbolRegistrationLease lease; + using (NativeLuaState state = new()) + using (HostScope scope = new(state)) + { + LuaState L = scope.State; + EngineTest.Run(L, """ + registerSymbol = function(name, address, doNotSave) end + unregisterSymbol = function(name) error('must not run after detach') end + """u8); + SymbolRegistrationAcquireOutcome acquired = + SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); + Assert.True(acquired.HasLease); + lease = acquired.Lease!; + } + + SymbolRegistrationReleaseOutcome release = lease.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, release.Kind); + Assert.True(lease.IsTerminal); + } + + [Fact] + public void Owned_registration_reports_failed_acquisition_without_creating_a_lease() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + + SymbolRegistrationAcquireOutcome outcome = + SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); + + Assert.False(outcome.HasLease); + Assert.Null(outcome.Lease); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, outcome.Status.Kind); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Owned_registration_cleanup_can_retry_unavailability_and_dispose_only_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "registerSymbol = function(name, address, doNotSave) end"u8); + + SymbolRegistrationAcquireOutcome retryable = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), + 0x140001000UL, + new SymbolRegistrationOptions(true)); + + Assert.True(retryable.HasLease); + Assert.True(retryable.Lease!.Options.DoNotSave); + SymbolRegistrationReleaseOutcome unavailable = retryable.Lease.Release(); + Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, unavailable.Kind); + Assert.False(unavailable.IsTerminal); + Assert.False(retryable.Lease.IsTerminal); + + EngineTest.Run(L, "unregisterSymbol = function(name) end"u8); + SymbolRegistrationReleaseOutcome released = retryable.Lease.Release(); + Assert.Equal(SymbolRegistrationReleaseKind.Released, released.Kind); + Assert.True(released.IsTerminal); + retryable.Lease.Dispose(); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, retryable.Lease.Release().Kind); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_cleanup_after_a_lua_failure_is_indeterminate_and_terminal() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + + EngineTest.Run(L, + "registerSymbol = function(name, address, doNotSave) end; unregisterSymbol = function(name) error('cleanup started then failed') end"u8); + SymbolRegistrationAcquireOutcome failed = + SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Mana"), 0x140002000UL); + SymbolRegistrationReleaseOutcome indeterminate = failed.Lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, indeterminate.Kind); + Assert.True(indeterminate.IsTerminal); + Assert.False(indeterminate.Status.IsSuccess); + Assert.True(failed.Lease.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, failed.Lease.Release().Kind); + Assert.Equal(0, L.Top); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs index 7c1390d0..05e75c2a 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; + using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Memory; @@ -11,504 +13,509 @@ namespace CheatEngine.SDK.Engine.Tests.Memory; [Trait("Category", "NativeLua")] public sealed class MemoryApiTests { - [SuppressMessage("Meziantou.Analyzer", "MA0051", - Justification = "The CE-shaped fixture is kept in one raw Lua source for its full primitive contract.")] - private static ReadOnlySpan TargetStandIn => """ - local target = { - byte = {[16] = 255}, - word = {[17] = 65534}, - dword = {[18] = 4294967294, [27] = "12"}, - qword = {[19] = -2}, - pointer = {[20] = -16, [23] = 0xFEDCBA98, [24] = 0x123456789ABCDEF, [25] = 4294967296}, - single = {[21] = 1.5}, - double = {[22] = 3.25, [28] = "3.25"}, - bytes = {[32] = {3, 1, 4, 1}, [37] = {3, 1}, [42] = {3, "1"}}, - text = {[48] = "target-text", [51] = "\195\169"}, - } - local function signed(v, width) - local top = 2 ^ (width - 1) - local range = 2 ^ width - return v >= top and v - range or v - end - function readByte(a) return target.byte[a] end - function readSmallInteger(a, s) - local v = target.word[a] - if v == nil then return nil end - return s and signed(v, 16) or v - end - function readInteger(a, s) - local v = target.dword[a] - if v == nil then return nil end - return s and signed(v, 32) or v - end - function readQword(a) return target.qword[a] end - function readPointer(a) return target.pointer[a] end - function readFloat(a) return target.single[a] end - function readDouble(a) return target.double[a] end - function readString(a, _, _) return target.text[a] end - function readBytes(a, count, asTable) - local source = target.bytes[a] - if source == nil then return nil end - local result = {} - local limit = math.min(count, #source) - for i = 1, limit do - result[i] = source[i] - end - return asTable and result or table.unpack(result) - end - function writeByte(a, v) target.byte[a] = v; return true end - function writeSmallInteger(a, v) target.word[a] = v % 65536; return true end - function writeInteger(a, v) target.dword[a] = v % 4294967296; return a ~= 57005 end - function writeQword(a, v) target.qword[a] = v; return true end - function writePointer(a, v) - if a == 26 then error("qualified overflow must not invoke CE") end - target.pointer[a] = v - return true - end - function writeFloat(a, v) target.single[a] = v; return true end - function writeDouble(a, v) target.double[a] = v; return true end - function writeString(a, v, _) target.text[a] = v; return true end - function writeBytes(a, values) - if a == 38 then return -1 end - if a == 39 then return #values + 1 end - if a == 40 then return "4" end - local completed = #values - if a == 34 then completed = #values - 1 end - if a == 35 then completed = 0 end - local copy = {} - for i = 1, completed do copy[i] = values[i] end - target.bytes[a] = copy - return completed - end - """u8; - - private static ReadOnlySpan HostStandIn => """ - local host = { - word = {[65] = 65534}, - dword = {[66] = 4294967294}, - qword = {[67] = -2}, - pointer = {[68] = -32}, - single = {[69] = 2.5}, - double = {[70] = 6.5}, - bytes = {[64] = {255}, [80] = {9, 8, 7}, [84] = {9}}, - text = {[96] = "host-text"}, - } - local function signed(v, width) - local top = 2 ^ (width - 1) - local range = 2 ^ width - return v >= top and v - range or v - end - function readBytesLocal(a, count, asTable) - local source = host.bytes[a] - if source == nil then return nil end - local result = {} - local limit = math.min(count, #source) - for i = 1, limit do - result[i] = source[i] - end - return asTable and result or table.unpack(result) - end - function readSmallIntegerLocal(a, s) - local v = host.word[a] - if v == nil then return nil end - return s and signed(v, 16) or v - end - function readIntegerLocal(a, s) - local v = host.dword[a] - if v == nil then return nil end - return s and signed(v, 32) or v - end - function readQwordLocal(a) return host.qword[a] end - function readPointerLocal(a) return host.pointer[a] end - function readFloatLocal(a) return host.single[a] end - function readDoubleLocal(a) return host.double[a] end - function readStringLocal(a, _, _) return host.text[a] end - function writeBytesLocal(a, values) - local copy = {} - for i = 1, #values do copy[i] = values[i] end - host.bytes[a] = copy - if a == 82 then return #values - 1 end - if a == 83 then return 0 end - return #values - end - function writeSmallIntegerLocal(a, v) host.word[a] = v % 65536; return true end - function writeIntegerLocal(a, v) host.dword[a] = v % 4294967296; return true end - function writeQwordLocal(a, v) host.qword[a] = v; return true end - function writePointerLocal(a, v) host.pointer[a] = v; return true end - function writeFloatLocal(a, v) host.single[a] = v; return true end - function writeDoubleLocal(a, v) host.double[a] = v; return true end - function writeStringLocal(a, v, _) host.text[a] = v; return true end - """u8; - - [Fact] - public void Target_scalars_preserve_signedness_widths_pointer_bits_and_floating_point_values() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - - Assert.True(TargetMemory.TryReadUInt8(16UL, out var u8, out var failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(byte.MaxValue, u8); - Assert.True(TargetMemory.TryReadInt8(16UL, out var i8, out failure)); - Assert.Equal(-1, i8); - Assert.True(TargetMemory.TryReadUInt16(17UL, out var u16, out failure)); - Assert.Equal(ushort.MaxValue - 1, u16); - Assert.True(TargetMemory.TryReadInt16(17UL, out var i16, out failure)); - Assert.Equal(-2, i16); - Assert.True(TargetMemory.TryReadUInt32(18UL, out var u32, out failure)); - Assert.Equal(uint.MaxValue - 1, u32); - Assert.True(TargetMemory.TryReadInt32(18UL, out var i32, out failure)); - Assert.Equal(-2, i32); - Assert.True(TargetMemory.TryReadUInt64(19UL, out var u64, out failure)); - Assert.Equal(ulong.MaxValue - 1, u64); - Assert.True(TargetMemory.TryReadInt64(19UL, out var i64, out failure)); - Assert.Equal(-2, i64); - Assert.True(TargetMemory.TryReadPointer(20UL, out var pointer, out failure)); - Assert.Equal(ulong.MaxValue - 15, pointer.Value); - Assert.True(TargetMemory.TryReadSingle(21UL, out var single, out failure)); - Assert.Equal(1.5F, single); - Assert.True(TargetMemory.TryReadDouble(22UL, out var @double, out failure)); - Assert.Equal(3.25, @double); - - Assert.True(TargetMemory.TryWriteInt8(16UL, -7, out failure)); - Assert.True(TargetMemory.TryWriteUInt16(17UL, 123, out failure)); - Assert.True(TargetMemory.TryWriteInt16(17UL, -2, out failure)); - Assert.True(TargetMemory.TryWriteUInt32(18UL, uint.MaxValue, out failure)); - Assert.True(TargetMemory.TryWriteInt32(18UL, -2, out failure)); - Assert.True(TargetMemory.TryWriteUInt64(19UL, ulong.MaxValue, out failure)); - Assert.True(TargetMemory.TryWriteInt64(19UL, -2, out failure)); - Assert.True(TargetMemory.TryWritePointer(20UL, 0x1234UL, out failure)); - Assert.True(TargetMemory.TryWriteSingle(21UL, 2.5F, out failure)); - Assert.True(TargetMemory.TryWriteDouble(22UL, 7.5, out failure)); - Assert.True(TargetMemory.TryReadPointer(20UL, out pointer, out failure)); - Assert.Equal(0x1234UL, pointer.Value); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Target_buffers_and_strings_keep_their_order_and_never_return_a_dangling_lua_span() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - - Span bytes = stackalloc byte[4]; - Assert.True(TargetMemory.TryReadBytes(32UL, bytes, out var failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.True(bytes.SequenceEqual(new byte[] { 3, 1, 4, 1 })); - Assert.True(TargetMemory.TryWriteBytes(33UL, [2, 7, 1, 8], out failure)); - bytes.Clear(); - Assert.True(TargetMemory.TryReadBytes(33UL, bytes, out failure)); - Assert.True(bytes.SequenceEqual(new byte[] { 2, 7, 1, 8 })); - - Span utf8 = stackalloc byte[16]; - Assert.True(TargetMemory.TryReadUtf8(48UL, 100, utf8, wideCharacter: false, out var written, out failure)); - Assert.True(utf8[..written].SequenceEqual("target-text"u8)); - Assert.True(TargetMemory.TryReadString(48UL, 100, wideCharacter: false, out var text, out failure)); - Assert.Equal("target-text", text); - Assert.True(TargetMemory.TryWriteUtf8(49UL, "updated"u8, wideCharacter: false, out failure)); - Assert.True(TargetMemory.TryReadString(49UL, 100, wideCharacter: false, out text, out failure)); - Assert.Equal("updated", text); - Assert.True(TargetMemory.TryWriteString(50UL, "text path".AsSpan(), wideCharacter: false, out failure)); - Assert.True(TargetMemory.TryReadString(50UL, 100, wideCharacter: false, out text, out failure)); - Assert.Equal("text path", text); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Target_pointer_width_qualification_uses_the_observed_target_not_the_host_and_refuses_x86_overflow() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - - Assert.True(TargetMemory.TryReadPointer(23UL, PointerSize.Bit32, out var x86Pointer, out var failure)); - Assert.Equal(0xFEDCBA98UL, x86Pointer.Value); - Assert.True(TargetMemory.TryReadPointer(24UL, PointerSize.Bit64, out var x64Pointer, out failure)); - Assert.Equal(0x123456789ABCDEFUL, x64Pointer.Value); - Assert.False(TargetMemory.TryReadPointer(25UL, PointerSize.Bit32, out var overflowPointer, out failure)); - Assert.Equal(MemoryAccessFailure.PointerValueExceedsTargetWidth, failure); - Assert.Equal(0UL, overflowPointer.Value); - Assert.False(TargetMemory.TryReadPointer(23UL, PointerSize.Unknown, out var unknownWidthPointer, out failure)); - Assert.Equal(MemoryAccessFailure.PointerWidthUnknown, failure); - Assert.Equal(0UL, unknownWidthPointer.Value); - - Assert.True(TargetMemory.TryWritePointer(23UL, 0x1234UL, PointerSize.Bit32, out failure)); - Assert.True(TargetMemory.TryReadPointer(23UL, PointerSize.Bit32, out x86Pointer, out failure)); - Assert.Equal(0x1234UL, x86Pointer.Value); - Assert.True(TargetMemory.TryWritePointer(24UL, 0x123456789ABCDEFUL, PointerSize.Bit64, out failure)); - Assert.True(TargetMemory.TryReadPointer(24UL, PointerSize.Bit64, out x64Pointer, out failure)); - Assert.Equal(0x123456789ABCDEFUL, x64Pointer.Value); - Assert.False(TargetMemory.TryWritePointer(26UL, 0x1_0000_0000UL, PointerSize.Bit32, out failure)); - Assert.Equal(MemoryAccessFailure.PointerValueExceedsTargetWidth, failure); - Assert.False(TargetMemory.TryWritePointer(23UL, 0x1234UL, PointerSize.Unknown, out failure)); - Assert.Equal(MemoryAccessFailure.PointerWidthUnknown, failure); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Detailed_target_buffer_reads_preserve_a_partial_prefix_and_required_utf8_capacity_without_lua_owned_storage() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - - Span detailedDestination = stackalloc byte[4]; - detailedDestination.Fill(0xA5); - Assert.False(TargetMemory.TryReadBytes(37UL, detailedDestination, out var copied, out var failure)); - Assert.Equal(MemoryAccessFailure.PartialRead, failure); - Assert.Equal(2, copied); - Assert.True(detailedDestination.SequenceEqual(new byte[] { 3, 1, 0xA5, 0xA5 })); - - Span malformedDestination = stackalloc byte[2]; - malformedDestination.Fill(0xA5); - Assert.False(TargetMemory.TryReadBytes(42UL, malformedDestination, out copied, out failure)); - Assert.Equal(MemoryAccessFailure.InvalidResult, failure); - Assert.Equal(1, copied); - Assert.True(malformedDestination.SequenceEqual(new byte[] { 3, 0xA5 })); - - Span legacyDestination = stackalloc byte[4]; - legacyDestination.Fill(0xA5); - Assert.False(TargetMemory.TryReadBytes(37UL, legacyDestination, out failure)); - Assert.Equal(MemoryAccessFailure.ReadFailed, failure); - Assert.True(legacyDestination.SequenceEqual(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5 })); - - Span tooSmall = stackalloc byte[1]; - tooSmall[0] = 0xA5; - Assert.False(TargetMemory.TryReadUtf8(51UL, 100, tooSmall, wideCharacter: false, out var written, - out var requiredLength, out failure)); - Assert.Equal(MemoryAccessFailure.DestinationTooSmall, failure); - Assert.Equal(0, written); - Assert.Equal(2, requiredLength); - Assert.Equal(0xA5, tooSmall[0]); - - Span exact = stackalloc byte[2]; - Assert.True(TargetMemory.TryReadUtf8(51UL, 100, exact, wideCharacter: false, out written, - out requiredLength, out failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(2, written); - Assert.Equal(2, requiredLength); - Assert.True(exact.SequenceEqual("é"u8)); - - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Detailed_host_buffer_read_preserves_a_partial_prefix() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, HostStandIn); - - Span destination = stackalloc byte[3]; - destination.Fill(0xA5); - Assert.False(HostMemory.TryReadBytes(new HostAddress(84), destination, out var copied, out var failure)); - Assert.Equal(MemoryAccessFailure.PartialRead, failure); - Assert.Equal(1, copied); - Assert.True(destination.SequenceEqual(new byte[] { 9, 0xA5, 0xA5 })); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Byte_writes_require_the_full_CE_count_for_target_and_host_memory() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - EngineTest.Run(scope.State, HostStandIn); - - ReadOnlySpan payload = [0, 1, 255, 42]; - Assert.True(TargetMemory.TryWriteBytes(33UL, payload, out var failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.False(TargetMemory.TryWriteBytes(34UL, payload, out var targetPartialCount, out failure)); - Assert.Equal(MemoryAccessFailure.WriteFailed, failure); - Assert.Equal(payload.Length - 1, targetPartialCount); - Assert.False(TargetMemory.TryWriteBytes(35UL, payload, out var targetZeroCount, out failure)); - Assert.Equal(MemoryAccessFailure.WriteFailed, failure); - Assert.Equal(0, targetZeroCount); - Assert.False(TargetMemory.TryWriteBytes(38UL, payload, out var targetInvalidCount, out failure)); - Assert.Equal(MemoryAccessFailure.InvalidResult, failure); - Assert.Equal(0, targetInvalidCount); - Assert.False(TargetMemory.TryWriteBytes(39UL, payload, out targetInvalidCount, out failure)); - Assert.Equal(MemoryAccessFailure.InvalidResult, failure); - Assert.Equal(0, targetInvalidCount); - Assert.False(TargetMemory.TryWriteBytes(40UL, payload, out targetInvalidCount, out failure)); - Assert.Equal(MemoryAccessFailure.InvalidResult, failure); - Assert.Equal(0, targetInvalidCount); - - Assert.True(HostMemory.TryWriteBytes(new HostAddress(81), payload, out failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.False(HostMemory.TryWriteBytes(new HostAddress(82), payload, out var hostPartialCount, out failure)); - Assert.Equal(MemoryAccessFailure.WriteFailed, failure); - Assert.Equal(payload.Length - 1, hostPartialCount); - Assert.False(HostMemory.TryWriteBytes(new HostAddress(83), payload, out var hostZeroCount, out failure)); - Assert.Equal(MemoryAccessFailure.WriteFailed, failure); - Assert.Equal(0, hostZeroCount); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Empty_byte_reads_and_writes_do_not_resolve_or_invoke_CE_globals() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, - "function readBytes(_) error('must not run') end function readBytesLocal(_) error('must not run') end function writeBytes(_) error('must not run') end function writeBytesLocal(_) error('must not run') end"u8); - - Assert.Equal(0, FakeHost.ProviderCalls); - Assert.True(TargetMemory.TryReadBytes(1UL, [], out var failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(1, FakeHost.ProviderCalls); - Assert.True(HostMemory.TryReadBytes(new HostAddress(1), [], out failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(2, FakeHost.ProviderCalls); - Assert.True(TargetMemory.TryWriteBytes(1UL, [], out failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(3, FakeHost.ProviderCalls); - Assert.True(HostMemory.TryWriteBytes(new HostAddress(1), [], out failure)); - Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(4, FakeHost.ProviderCalls); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Empty_byte_reads_and_writes_preserve_detached_runtime_admission() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using (HostScope scope = new(state)) - { - } - - Assert.Throws(() => TargetMemory.TryReadBytes(1UL, [], out _)); - Assert.Throws(() => HostMemory.TryReadBytes(new HostAddress(1), [], out _)); - Assert.Throws(() => TargetMemory.TryWriteBytes(1UL, [], out _)); - Assert.Throws(() => HostMemory.TryWriteBytes(new HostAddress(1), [], out _)); - } - - [Fact] - [SuppressMessage("Meziantou.Analyzer", "MA0051", - Justification = - "The host scalar contract is intentionally exercised end to end in one table-shaped fixture test.")] - public void Host_scalars_use_host_addresses_and_the_documented_local_byte_table_for_8_bit_access() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, HostStandIn); - - HostAddress byteAddress = new(64); - Assert.True(HostMemory.TryReadUInt8(byteAddress, out var u8, out var failure)); - Assert.Equal(byte.MaxValue, u8); - Assert.True(HostMemory.TryReadInt8(byteAddress, out var i8, out failure)); - Assert.Equal(-1, i8); - Assert.True(HostMemory.TryReadUInt16(new HostAddress(65), out var u16, out failure)); - Assert.Equal(ushort.MaxValue - 1, u16); - Assert.True(HostMemory.TryReadInt16(new HostAddress(65), out var i16, out failure)); - Assert.Equal(-2, i16); - Assert.True(HostMemory.TryReadUInt32(new HostAddress(66), out var u32, out failure)); - Assert.Equal(uint.MaxValue - 1, u32); - Assert.True(HostMemory.TryReadInt32(new HostAddress(66), out var i32, out failure)); - Assert.Equal(-2, i32); - Assert.True(HostMemory.TryReadUInt64(new HostAddress(67), out var u64, out failure)); - Assert.Equal(ulong.MaxValue - 1, u64); - Assert.True(HostMemory.TryReadInt64(new HostAddress(67), out var i64, out failure)); - Assert.Equal(-2, i64); - Assert.True(HostMemory.TryReadPointer(new HostAddress(68), out var pointer, out failure)); - Assert.Equal(unchecked((nuint)(-32)), pointer.Value); - Assert.True(HostMemory.TryReadSingle(new HostAddress(69), out var single, out failure)); - Assert.Equal(2.5F, single); - Assert.True(HostMemory.TryReadDouble(new HostAddress(70), out var @double, out failure)); - Assert.Equal(6.5, @double); - - Assert.True(HostMemory.TryWriteUInt8(byteAddress, 7, out failure)); - Assert.True(HostMemory.TryReadUInt8(byteAddress, out u8, out failure)); - Assert.Equal(7, u8); - Assert.True(HostMemory.TryWritePointer(new HostAddress(68), new HostAddress(0x5678), out failure)); - Assert.True(HostMemory.TryReadPointer(new HostAddress(68), out pointer, out failure)); - Assert.Equal((nuint)0x5678, pointer.Value); - Assert.True(HostMemory.TryWriteDouble(new HostAddress(70), 8.5, out failure)); - Assert.True(HostMemory.TryReadDouble(new HostAddress(70), out @double, out failure)); - Assert.Equal(8.5, @double); - - Span bytes = stackalloc byte[3]; - Assert.True(HostMemory.TryReadBytes(new HostAddress(80), bytes, out failure)); - Assert.True(bytes.SequenceEqual(new byte[] { 9, 8, 7 })); - Assert.True(HostMemory.TryWriteBytes(new HostAddress(81), [6, 2, 6], out failure)); - Assert.True(HostMemory.TryReadBytes(new HostAddress(81), bytes, out failure)); - Assert.True(bytes.SequenceEqual(new byte[] { 6, 2, 6 })); - Assert.True(HostMemory.TryReadString(new HostAddress(96), 100, wideCharacter: false, out var text, out failure)); - Assert.Equal("host-text", text); - Assert.True(HostMemory.TryWriteUtf8(new HostAddress(97), "host-update"u8, wideCharacter: false, out failure)); - Assert.True(HostMemory.TryReadString(new HostAddress(97), 100, wideCharacter: false, out text, out failure)); - Assert.Equal("host-update", text); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Failures_are_classified_without_leaking_stack_values() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - - Assert.False(TargetMemory.TryReadUInt32(999UL, out _, out var failure)); - Assert.Equal(MemoryAccessFailure.ReadFailed, failure); - Assert.False(TargetMemory.TryReadUInt32(27UL, out _, out failure)); - Assert.Equal(MemoryAccessFailure.InvalidResult, failure); - Assert.False(TargetMemory.TryWriteUInt32(57005UL, 1, out failure)); - Assert.Equal(MemoryAccessFailure.WriteFailed, failure); - EngineTest.Run(scope.State, "function readDouble(_) error('fixture failure') end"u8); - Assert.False(TargetMemory.TryReadDouble(22UL, out _, out failure)); - Assert.Equal(MemoryAccessFailure.LuaError, failure); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Scalar_number_reads_reject_coercible_Lua_strings() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, TargetStandIn); - - Assert.False(TargetMemory.TryReadDouble(28UL, out _, out var failure)); - Assert.Equal(MemoryAccessFailure.InvalidResult, failure); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void A_missing_global_is_distinct_from_a_detached_runtime() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using (HostScope scope = new(state)) - { - Assert.False(TargetMemory.TryReadPointer(1UL, out _, out var failure)); - Assert.Equal(MemoryAccessFailure.GlobalUnavailable, failure); - Assert.Equal(0, scope.State.Top); - } - - Assert.Throws(() => TargetMemory.TryReadPointer(1UL, out _, out _)); - } - - [Fact] - public void Host_address_does_not_implicitly_cross_the_target_address_space() - { - HostAddress address = new(0x1234); - Assert.Equal((nuint)0x1234, address.Value); - Assert.Equal(new HostAddress(0x1234), address); - Assert.NotEqual(new HostAddress(0x1235), address); - Assert.Equal(IntPtr.Size == 8 ? "0000000000001234" : "00001234", - address.ToString(IntPtr.Size == 8 ? "X16" : "X8", CultureInfo.InvariantCulture)); - } + [SuppressMessage("Meziantou.Analyzer", "MA0051", + Justification = "The CE-shaped fixture is kept in one raw Lua source for its full primitive contract.")] + private static ReadOnlySpan TargetStandIn => """ + local target = { + byte = {[16] = 255}, + word = {[17] = 65534}, + dword = {[18] = 4294967294, [27] = "12"}, + qword = {[19] = -2}, + pointer = {[20] = -16, [23] = 0xFEDCBA98, [24] = 0x123456789ABCDEF, [25] = 4294967296}, + single = {[21] = 1.5}, + double = {[22] = 3.25, [28] = "3.25"}, + bytes = {[32] = {3, 1, 4, 1}, [37] = {3, 1}, [42] = {3, "1"}}, + text = {[48] = "target-text", [51] = "\195\169"}, + } + local function signed(v, width) + local top = 2 ^ (width - 1) + local range = 2 ^ width + return v >= top and v - range or v + end + function readByte(a) return target.byte[a] end + function readSmallInteger(a, s) + local v = target.word[a] + if v == nil then return nil end + return s and signed(v, 16) or v + end + function readInteger(a, s) + local v = target.dword[a] + if v == nil then return nil end + return s and signed(v, 32) or v + end + function readQword(a) return target.qword[a] end + function readPointer(a) return target.pointer[a] end + function readFloat(a) return target.single[a] end + function readDouble(a) return target.double[a] end + function readString(a, _, _) return target.text[a] end + function readBytes(a, count, asTable) + local source = target.bytes[a] + if source == nil then return nil end + local result = {} + local limit = math.min(count, #source) + for i = 1, limit do + result[i] = source[i] + end + return asTable and result or table.unpack(result) + end + function writeByte(a, v) target.byte[a] = v; return true end + function writeSmallInteger(a, v) target.word[a] = v % 65536; return true end + function writeInteger(a, v) target.dword[a] = v % 4294967296; return a ~= 57005 end + function writeQword(a, v) target.qword[a] = v; return true end + function writePointer(a, v) + if a == 26 then error("qualified overflow must not invoke CE") end + target.pointer[a] = v + return true + end + function writeFloat(a, v) target.single[a] = v; return true end + function writeDouble(a, v) target.double[a] = v; return true end + function writeString(a, v, _) target.text[a] = v; return true end + function writeBytes(a, values) + if a == 38 then return -1 end + if a == 39 then return #values + 1 end + if a == 40 then return "4" end + local completed = #values + if a == 34 then completed = #values - 1 end + if a == 35 then completed = 0 end + local copy = {} + for i = 1, completed do copy[i] = values[i] end + target.bytes[a] = copy + return completed + end + """u8; + + private static ReadOnlySpan HostStandIn => """ + local host = { + word = {[65] = 65534}, + dword = {[66] = 4294967294}, + qword = {[67] = -2}, + pointer = {[68] = -32}, + single = {[69] = 2.5}, + double = {[70] = 6.5}, + bytes = {[64] = {255}, [80] = {9, 8, 7}, [84] = {9}}, + text = {[96] = "host-text"}, + } + local function signed(v, width) + local top = 2 ^ (width - 1) + local range = 2 ^ width + return v >= top and v - range or v + end + function readBytesLocal(a, count, asTable) + local source = host.bytes[a] + if source == nil then return nil end + local result = {} + local limit = math.min(count, #source) + for i = 1, limit do + result[i] = source[i] + end + return asTable and result or table.unpack(result) + end + function readSmallIntegerLocal(a, s) + local v = host.word[a] + if v == nil then return nil end + return s and signed(v, 16) or v + end + function readIntegerLocal(a, s) + local v = host.dword[a] + if v == nil then return nil end + return s and signed(v, 32) or v + end + function readQwordLocal(a) return host.qword[a] end + function readPointerLocal(a) return host.pointer[a] end + function readFloatLocal(a) return host.single[a] end + function readDoubleLocal(a) return host.double[a] end + function readStringLocal(a, _, _) return host.text[a] end + function writeBytesLocal(a, values) + local copy = {} + for i = 1, #values do copy[i] = values[i] end + host.bytes[a] = copy + if a == 82 then return #values - 1 end + if a == 83 then return 0 end + return #values + end + function writeSmallIntegerLocal(a, v) host.word[a] = v % 65536; return true end + function writeIntegerLocal(a, v) host.dword[a] = v % 4294967296; return true end + function writeQwordLocal(a, v) host.qword[a] = v; return true end + function writePointerLocal(a, v) host.pointer[a] = v; return true end + function writeFloatLocal(a, v) host.single[a] = v; return true end + function writeDoubleLocal(a, v) host.double[a] = v; return true end + function writeStringLocal(a, v, _) host.text[a] = v; return true end + """u8; + + [Fact] + public void Target_scalars_preserve_signedness_widths_pointer_bits_and_floating_point_values() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + + Assert.True(TargetMemory.TryReadUInt8(16UL, out byte u8, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(byte.MaxValue, u8); + Assert.True(TargetMemory.TryReadInt8(16UL, out sbyte i8, out failure)); + Assert.Equal(-1, i8); + Assert.True(TargetMemory.TryReadUInt16(17UL, out ushort u16, out failure)); + Assert.Equal(ushort.MaxValue - 1, u16); + Assert.True(TargetMemory.TryReadInt16(17UL, out short i16, out failure)); + Assert.Equal(-2, i16); + Assert.True(TargetMemory.TryReadUInt32(18UL, out uint u32, out failure)); + Assert.Equal(uint.MaxValue - 1, u32); + Assert.True(TargetMemory.TryReadInt32(18UL, out int i32, out failure)); + Assert.Equal(-2, i32); + Assert.True(TargetMemory.TryReadUInt64(19UL, out ulong u64, out failure)); + Assert.Equal(ulong.MaxValue - 1, u64); + Assert.True(TargetMemory.TryReadInt64(19UL, out long i64, out failure)); + Assert.Equal(-2, i64); + Assert.True(TargetMemory.TryReadPointer(20UL, out Address pointer, out failure)); + Assert.Equal(ulong.MaxValue - 15, pointer.Value); + Assert.True(TargetMemory.TryReadSingle(21UL, out float single, out failure)); + Assert.Equal(1.5F, single); + Assert.True(TargetMemory.TryReadDouble(22UL, out double @double, out failure)); + Assert.Equal(3.25, @double); + + Assert.True(TargetMemory.TryWriteInt8(16UL, -7, out failure)); + Assert.True(TargetMemory.TryWriteUInt16(17UL, 123, out failure)); + Assert.True(TargetMemory.TryWriteInt16(17UL, -2, out failure)); + Assert.True(TargetMemory.TryWriteUInt32(18UL, uint.MaxValue, out failure)); + Assert.True(TargetMemory.TryWriteInt32(18UL, -2, out failure)); + Assert.True(TargetMemory.TryWriteUInt64(19UL, ulong.MaxValue, out failure)); + Assert.True(TargetMemory.TryWriteInt64(19UL, -2, out failure)); + Assert.True(TargetMemory.TryWritePointer(20UL, 0x1234UL, out failure)); + Assert.True(TargetMemory.TryWriteSingle(21UL, 2.5F, out failure)); + Assert.True(TargetMemory.TryWriteDouble(22UL, 7.5, out failure)); + Assert.True(TargetMemory.TryReadPointer(20UL, out pointer, out failure)); + Assert.Equal(0x1234UL, pointer.Value); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Target_buffers_and_strings_keep_their_order_and_never_return_a_dangling_lua_span() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + + Span bytes = stackalloc byte[4]; + Assert.True(TargetMemory.TryReadBytes(32UL, bytes, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.True(bytes.SequenceEqual(new byte[] { 3, 1, 4, 1 })); + Assert.True(TargetMemory.TryWriteBytes(33UL, [2, 7, 1, 8], out failure)); + bytes.Clear(); + Assert.True(TargetMemory.TryReadBytes(33UL, bytes, out failure)); + Assert.True(bytes.SequenceEqual(new byte[] { 2, 7, 1, 8 })); + + Span utf8 = stackalloc byte[16]; + Assert.True(TargetMemory.TryReadUtf8(48UL, 100, utf8, false, out int written, out failure)); + Assert.True(utf8[..written].SequenceEqual("target-text"u8)); + Assert.True(TargetMemory.TryReadString(48UL, 100, false, out string? text, out failure)); + Assert.Equal("target-text", text); + Assert.True(TargetMemory.TryWriteUtf8(49UL, "updated"u8, false, out failure)); + Assert.True(TargetMemory.TryReadString(49UL, 100, false, out text, out failure)); + Assert.Equal("updated", text); + Assert.True(TargetMemory.TryWriteString(50UL, "text path".AsSpan(), false, out failure)); + Assert.True(TargetMemory.TryReadString(50UL, 100, false, out text, out failure)); + Assert.Equal("text path", text); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Target_pointer_width_qualification_uses_the_observed_target_not_the_host_and_refuses_x86_overflow() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + + Assert.True(TargetMemory.TryReadPointer(23UL, PointerSize.Bit32, out Address x86Pointer, + out MemoryAccessFailure failure)); + Assert.Equal(0xFEDCBA98UL, x86Pointer.Value); + Assert.True(TargetMemory.TryReadPointer(24UL, PointerSize.Bit64, out Address x64Pointer, out failure)); + Assert.Equal(0x123456789ABCDEFUL, x64Pointer.Value); + Assert.False(TargetMemory.TryReadPointer(25UL, PointerSize.Bit32, out Address overflowPointer, out failure)); + Assert.Equal(MemoryAccessFailure.PointerValueExceedsTargetWidth, failure); + Assert.Equal(0UL, overflowPointer.Value); + Assert.False(TargetMemory.TryReadPointer(23UL, PointerSize.Unknown, out Address unknownWidthPointer, + out failure)); + Assert.Equal(MemoryAccessFailure.PointerWidthUnknown, failure); + Assert.Equal(0UL, unknownWidthPointer.Value); + + Assert.True(TargetMemory.TryWritePointer(23UL, 0x1234UL, PointerSize.Bit32, out failure)); + Assert.True(TargetMemory.TryReadPointer(23UL, PointerSize.Bit32, out x86Pointer, out failure)); + Assert.Equal(0x1234UL, x86Pointer.Value); + Assert.True(TargetMemory.TryWritePointer(24UL, 0x123456789ABCDEFUL, PointerSize.Bit64, out failure)); + Assert.True(TargetMemory.TryReadPointer(24UL, PointerSize.Bit64, out x64Pointer, out failure)); + Assert.Equal(0x123456789ABCDEFUL, x64Pointer.Value); + Assert.False(TargetMemory.TryWritePointer(26UL, 0x1_0000_0000UL, PointerSize.Bit32, out failure)); + Assert.Equal(MemoryAccessFailure.PointerValueExceedsTargetWidth, failure); + Assert.False(TargetMemory.TryWritePointer(23UL, 0x1234UL, PointerSize.Unknown, out failure)); + Assert.Equal(MemoryAccessFailure.PointerWidthUnknown, failure); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void + Detailed_target_buffer_reads_preserve_a_partial_prefix_and_required_utf8_capacity_without_lua_owned_storage() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + + Span detailedDestination = stackalloc byte[4]; + detailedDestination.Fill(0xA5); + Assert.False(TargetMemory.TryReadBytes(37UL, detailedDestination, out int copied, + out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.PartialRead, failure); + Assert.Equal(2, copied); + Assert.True(detailedDestination.SequenceEqual(new byte[] { 3, 1, 0xA5, 0xA5 })); + + Span malformedDestination = stackalloc byte[2]; + malformedDestination.Fill(0xA5); + Assert.False(TargetMemory.TryReadBytes(42UL, malformedDestination, out copied, out failure)); + Assert.Equal(MemoryAccessFailure.InvalidResult, failure); + Assert.Equal(1, copied); + Assert.True(malformedDestination.SequenceEqual(new byte[] { 3, 0xA5 })); + + Span legacyDestination = stackalloc byte[4]; + legacyDestination.Fill(0xA5); + Assert.False(TargetMemory.TryReadBytes(37UL, legacyDestination, out failure)); + Assert.Equal(MemoryAccessFailure.ReadFailed, failure); + Assert.True(legacyDestination.SequenceEqual(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5 })); + + Span tooSmall = stackalloc byte[1]; + tooSmall[0] = 0xA5; + Assert.False(TargetMemory.TryReadUtf8(51UL, 100, tooSmall, false, out int written, + out int requiredLength, out failure)); + Assert.Equal(MemoryAccessFailure.DestinationTooSmall, failure); + Assert.Equal(0, written); + Assert.Equal(2, requiredLength); + Assert.Equal(0xA5, tooSmall[0]); + + Span exact = stackalloc byte[2]; + Assert.True(TargetMemory.TryReadUtf8(51UL, 100, exact, false, out written, + out requiredLength, out failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(2, written); + Assert.Equal(2, requiredLength); + Assert.True(exact.SequenceEqual("é"u8)); + + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Detailed_host_buffer_read_preserves_a_partial_prefix() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, HostStandIn); + + Span destination = stackalloc byte[3]; + destination.Fill(0xA5); + Assert.False(HostMemory.TryReadBytes(new HostAddress(84), destination, out int copied, + out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.PartialRead, failure); + Assert.Equal(1, copied); + Assert.True(destination.SequenceEqual(new byte[] { 9, 0xA5, 0xA5 })); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Byte_writes_require_the_full_CE_count_for_target_and_host_memory() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + EngineTest.Run(scope.State, HostStandIn); + + ReadOnlySpan payload = [0, 1, 255, 42]; + Assert.True(TargetMemory.TryWriteBytes(33UL, payload, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.False(TargetMemory.TryWriteBytes(34UL, payload, out int targetPartialCount, out failure)); + Assert.Equal(MemoryAccessFailure.WriteFailed, failure); + Assert.Equal(payload.Length - 1, targetPartialCount); + Assert.False(TargetMemory.TryWriteBytes(35UL, payload, out int targetZeroCount, out failure)); + Assert.Equal(MemoryAccessFailure.WriteFailed, failure); + Assert.Equal(0, targetZeroCount); + Assert.False(TargetMemory.TryWriteBytes(38UL, payload, out int targetInvalidCount, out failure)); + Assert.Equal(MemoryAccessFailure.InvalidResult, failure); + Assert.Equal(0, targetInvalidCount); + Assert.False(TargetMemory.TryWriteBytes(39UL, payload, out targetInvalidCount, out failure)); + Assert.Equal(MemoryAccessFailure.InvalidResult, failure); + Assert.Equal(0, targetInvalidCount); + Assert.False(TargetMemory.TryWriteBytes(40UL, payload, out targetInvalidCount, out failure)); + Assert.Equal(MemoryAccessFailure.InvalidResult, failure); + Assert.Equal(0, targetInvalidCount); + + Assert.True(HostMemory.TryWriteBytes(new HostAddress(81), payload, out failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.False(HostMemory.TryWriteBytes(new HostAddress(82), payload, out int hostPartialCount, out failure)); + Assert.Equal(MemoryAccessFailure.WriteFailed, failure); + Assert.Equal(payload.Length - 1, hostPartialCount); + Assert.False(HostMemory.TryWriteBytes(new HostAddress(83), payload, out int hostZeroCount, out failure)); + Assert.Equal(MemoryAccessFailure.WriteFailed, failure); + Assert.Equal(0, hostZeroCount); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Empty_byte_reads_and_writes_do_not_resolve_or_invoke_CE_globals() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, + "function readBytes(_) error('must not run') end function readBytesLocal(_) error('must not run') end function writeBytes(_) error('must not run') end function writeBytesLocal(_) error('must not run') end"u8); + + Assert.Equal(0, FakeHost.ProviderCalls); + Assert.True(TargetMemory.TryReadBytes(1UL, [], out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(1, FakeHost.ProviderCalls); + Assert.True(HostMemory.TryReadBytes(new HostAddress(1), [], out failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(2, FakeHost.ProviderCalls); + Assert.True(TargetMemory.TryWriteBytes(1UL, [], out failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(3, FakeHost.ProviderCalls); + Assert.True(HostMemory.TryWriteBytes(new HostAddress(1), [], out failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(4, FakeHost.ProviderCalls); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Empty_byte_reads_and_writes_preserve_detached_runtime_admission() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using (HostScope scope = new(state)) + { + } + + Assert.Throws(() => TargetMemory.TryReadBytes(1UL, [], out _)); + Assert.Throws(() => HostMemory.TryReadBytes(new HostAddress(1), [], out _)); + Assert.Throws(() => TargetMemory.TryWriteBytes(1UL, [], out _)); + Assert.Throws(() => HostMemory.TryWriteBytes(new HostAddress(1), [], out _)); + } + + [Fact] + [SuppressMessage("Meziantou.Analyzer", "MA0051", + Justification = + "The host scalar contract is intentionally exercised end to end in one table-shaped fixture test.")] + public void Host_scalars_use_host_addresses_and_the_documented_local_byte_table_for_8_bit_access() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, HostStandIn); + + HostAddress byteAddress = new(64); + Assert.True(HostMemory.TryReadUInt8(byteAddress, out byte u8, out MemoryAccessFailure failure)); + Assert.Equal(byte.MaxValue, u8); + Assert.True(HostMemory.TryReadInt8(byteAddress, out sbyte i8, out failure)); + Assert.Equal(-1, i8); + Assert.True(HostMemory.TryReadUInt16(new HostAddress(65), out ushort u16, out failure)); + Assert.Equal(ushort.MaxValue - 1, u16); + Assert.True(HostMemory.TryReadInt16(new HostAddress(65), out short i16, out failure)); + Assert.Equal(-2, i16); + Assert.True(HostMemory.TryReadUInt32(new HostAddress(66), out uint u32, out failure)); + Assert.Equal(uint.MaxValue - 1, u32); + Assert.True(HostMemory.TryReadInt32(new HostAddress(66), out int i32, out failure)); + Assert.Equal(-2, i32); + Assert.True(HostMemory.TryReadUInt64(new HostAddress(67), out ulong u64, out failure)); + Assert.Equal(ulong.MaxValue - 1, u64); + Assert.True(HostMemory.TryReadInt64(new HostAddress(67), out long i64, out failure)); + Assert.Equal(-2, i64); + Assert.True(HostMemory.TryReadPointer(new HostAddress(68), out HostAddress pointer, out failure)); + Assert.Equal(unchecked((nuint) (-32)), pointer.Value); + Assert.True(HostMemory.TryReadSingle(new HostAddress(69), out float single, out failure)); + Assert.Equal(2.5F, single); + Assert.True(HostMemory.TryReadDouble(new HostAddress(70), out double @double, out failure)); + Assert.Equal(6.5, @double); + + Assert.True(HostMemory.TryWriteUInt8(byteAddress, 7, out failure)); + Assert.True(HostMemory.TryReadUInt8(byteAddress, out u8, out failure)); + Assert.Equal(7, u8); + Assert.True(HostMemory.TryWritePointer(new HostAddress(68), new HostAddress(0x5678), out failure)); + Assert.True(HostMemory.TryReadPointer(new HostAddress(68), out pointer, out failure)); + Assert.Equal((nuint) 0x5678, pointer.Value); + Assert.True(HostMemory.TryWriteDouble(new HostAddress(70), 8.5, out failure)); + Assert.True(HostMemory.TryReadDouble(new HostAddress(70), out @double, out failure)); + Assert.Equal(8.5, @double); + + Span bytes = stackalloc byte[3]; + Assert.True(HostMemory.TryReadBytes(new HostAddress(80), bytes, out failure)); + Assert.True(bytes.SequenceEqual(new byte[] { 9, 8, 7 })); + Assert.True(HostMemory.TryWriteBytes(new HostAddress(81), [6, 2, 6], out failure)); + Assert.True(HostMemory.TryReadBytes(new HostAddress(81), bytes, out failure)); + Assert.True(bytes.SequenceEqual(new byte[] { 6, 2, 6 })); + Assert.True(HostMemory.TryReadString(new HostAddress(96), 100, false, out string? text, out failure)); + Assert.Equal("host-text", text); + Assert.True(HostMemory.TryWriteUtf8(new HostAddress(97), "host-update"u8, false, out failure)); + Assert.True(HostMemory.TryReadString(new HostAddress(97), 100, false, out text, out failure)); + Assert.Equal("host-update", text); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Failures_are_classified_without_leaking_stack_values() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + + Assert.False(TargetMemory.TryReadUInt32(999UL, out _, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.ReadFailed, failure); + Assert.False(TargetMemory.TryReadUInt32(27UL, out _, out failure)); + Assert.Equal(MemoryAccessFailure.InvalidResult, failure); + Assert.False(TargetMemory.TryWriteUInt32(57005UL, 1, out failure)); + Assert.Equal(MemoryAccessFailure.WriteFailed, failure); + EngineTest.Run(scope.State, "function readDouble(_) error('fixture failure') end"u8); + Assert.False(TargetMemory.TryReadDouble(22UL, out _, out failure)); + Assert.Equal(MemoryAccessFailure.LuaError, failure); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Scalar_number_reads_reject_coercible_Lua_strings() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, TargetStandIn); + + Assert.False(TargetMemory.TryReadDouble(28UL, out _, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.InvalidResult, failure); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void A_missing_global_is_distinct_from_a_detached_runtime() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using (HostScope scope = new(state)) + { + Assert.False(TargetMemory.TryReadPointer(1UL, out _, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.GlobalUnavailable, failure); + Assert.Equal(0, scope.State.Top); + } + + Assert.Throws(() => TargetMemory.TryReadPointer(1UL, out _, out _)); + } + + [Fact] + public void Host_address_does_not_implicitly_cross_the_target_address_space() + { + HostAddress address = new(0x1234); + Assert.Equal((nuint) 0x1234, address.Value); + Assert.Equal(new HostAddress(0x1234), address); + Assert.NotEqual(new HostAddress(0x1235), address); + Assert.Equal(IntPtr.Size == 8 ? "0000000000001234" : "00001234", + address.ToString(IntPtr.Size == 8 ? "X16" : "X8", CultureInfo.InvariantCulture)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectTests.cs index e24303d1..edb69ce6 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectTests.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Tests.Support; @@ -17,481 +18,482 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; [Trait("Category", "NativeLua")] public sealed unsafe class CEObjectTests { - [Fact] - public void TryRead_decodes_a_full_userdata_whose_first_field_is_the_object_pointer() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - - var probe = FakeHost.CreateObject(L, "Probe"); - probe.Push(L); - Assert.True(L.IsUserdata(-1)); - Assert.True(CEObject.TryRead(L, -1, out var fromPusher)); - Assert.Equal(probe, fromPusher); - - // A userdata made by hand with the same layout, larger than a pointer. - var block = L.NewUserdata(32); - *(nint*)block = 0x7777_0000; - Assert.True(CEObject.TryRead(L, -1, out var fromBlock)); - Assert.Equal(new CEObject(0x7777_0000), fromBlock); - Assert.Equal(frame.Top + 2, L.Top); - } - - [Fact] - public void TryRead_refuses_values_that_are_not_host_objects() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - - L.PushLightUserdata(0x1234); // 1: a bare pointer, no block to read - _ = L.NewUserdata(1); // 2: a block smaller than a pointer - var zeroed = L.NewUserdata((nuint)sizeof(nint)); - *(nint*)zeroed = 0; // 3: a null first field - L.CreateTable(); // 4 - L.PushInteger(0x1234); // 5 - L.PushString("0x1234"u8); // 6 - L.PushNil(); // 7 - - for (var index = 1; index <= 8; index++) - { - Assert.False(CEObject.TryRead(L, index, out var value), - "index " + index.ToString(CultureInfo.InvariantCulture) + " was read as an object"); - Assert.True(value.IsNull); - } - - Assert.Equal(7, L.Top); - } - - [Fact] - public void Push_goes_through_the_host_pusher_and_a_null_handle_pushes_nil() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - - var probe = FakeHost.CreateObject(L, "Probe"); - var pushes = FakeHost.PusherCalls; - probe.Push(L); - CEObject.Push(L, probe); - CEObject.Null.Push(L); - - Assert.Equal(pushes + 2, FakeHost.PusherCalls); - Assert.Equal(LuaType.Userdata, L.TypeOf(-3)); - Assert.Equal(LuaType.Userdata, L.TypeOf(-2)); - Assert.True(L.IsNil(-1)); - Assert.True(CEObject.TryRead(L, -2, out var back)); - Assert.Equal(probe, back); - } - - [Fact] - public void Push_without_a_pusher_in_the_binding_throws() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state, false); - var L = scope.State; - - var probe = FakeHost.CreateObject(L, "Probe"); - Assert.Throws(() => probe.Push(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryGetProperty_leaves_only_the_value_on_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3; o.props.Name = 'probe'"); - - Assert.True(probe.TryGetProperty(L, "Count"u8).IsOk); - Assert.Equal(3, EngineTest.ReadInteger(L, -1)); - Assert.Equal(frame.Top + 1, L.Top); - - Assert.True(probe.TryGetProperty(L, "Name"u8).IsOk); - Assert.Equal("probe", EngineTest.ReadString(L, -1)); - - Assert.True(probe.TryGetProperty(L, "Missing"u8).IsOk); - Assert.True(L.IsNil(-1)); - Assert.Equal(frame.Top + 3, L.Top); - } - - [Fact] - public void TrySetProperty_consumes_the_value_and_the_object_sees_it() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3"); - - L.PushInteger(7); - Assert.True(probe.TrySetProperty(L, "Count"u8).IsOk); - Assert.Equal(frame.Top, L.Top); - - Assert.True(probe.TryGetProperty(L, "Count"u8).IsOk); - Assert.Equal(7, EngineTest.ReadInteger(L, -1)); - } - - [Fact] - public void A_raising_getter_or_setter_is_a_status_with_one_error_value() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject( - L, - "Probe", - "o.getters.Bad = function() error('the getter raised') end; o.setters.Locked = function() error('the setter raised') end"); - - var status = probe.TryGetProperty(L, "Bad"u8); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("the getter raised", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - - L.PushInteger(1); - status = probe.TrySetProperty(L, "Locked"u8); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("the setter raised", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); - Assert.Equal(frame.Top + 1, L.Top); - } - - [Fact] - public void Indexed_access_passes_the_zero_based_index_through_unchanged() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.items = { 'first', 'second' }"); - - Assert.True(probe.TryGetIndex(L, 0).IsOk); - Assert.Equal("first", EngineTest.ReadString(L, -1)); - Assert.True(probe.TryGetIndex(L, 1).IsOk); - Assert.Equal("second", EngineTest.ReadString(L, -1)); - Assert.True(probe.TryGetIndex(L, 2).IsOk); - Assert.True(L.IsNil(-1)); - L.SetTop(frame.Top); - - L.PushString("third"u8); - Assert.True(probe.TrySetIndex(L, 2).IsOk); - Assert.Equal(frame.Top, L.Top); - Assert.True(probe.TryGetIndex(L, 2).IsOk); - Assert.Equal("third", EngineTest.ReadString(L, -1)); - } - - [Fact] - public void TryPushMethod_pushes_an_instance_bound_function_that_is_called_without_self() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 5"); - - Assert.True(probe.TryPushMethod(L, "add"u8).IsOk); - Assert.True(L.IsFunction(-1)); - L.PushInteger(2); - L.PushInteger(3); - Assert.True(L.TryCall(2, 1).IsOk); - Assert.Equal(5, EngineTest.ReadInteger(L, -1)); - Assert.Equal(frame.Top + 1, L.Top); - - Assert.True(probe.TryPushMethod(L, "getClassName"u8).IsOk); - Assert.True(L.TryCall(0, 1).IsOk); - Assert.Equal("Probe", EngineTest.ReadString(L, -1)); - } - - [Fact] - public void TryPushMethod_reports_a_member_that_is_not_a_function_by_name_and_type() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe"); - - var status = probe.TryPushMethod(L, "notAMethod"u8); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal("'notAMethod' is a number, not a method", EngineTest.ErrorMessage(L, status)); - Assert.Equal(frame.Top + 1, L.Top); - - status = probe.TryPushMethod(L, "missing"u8); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal("'missing' is a nil, not a method", EngineTest.ErrorMessage(L, status)); - Assert.Equal(frame.Top + 2, L.Top); - } - - [Fact] - public void TryCallMethod_replaces_the_arguments_by_the_results() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 9"); - - L.PushInteger(40); - L.PushInteger(2); - Assert.True(probe.TryCallMethod(L, "add"u8, 2, 1).IsOk); - Assert.Equal(42, EngineTest.ReadInteger(L, -1)); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - - L.PushInteger(1); - L.PushString("two"u8); - L.PushBoolean(true); - Assert.True(probe.TryCallMethod(L, "echo"u8, 3, LuaState.MultipleResults).IsOk); - Assert.Equal(frame.Top + 3, L.Top); - Assert.Equal(1, EngineTest.ReadInteger(L, -3)); - Assert.Equal("two", EngineTest.ReadString(L, -2)); - Assert.True(L.ToBoolean(-1)); - L.SetTop(frame.Top); - - Assert.True(probe.TryCallMethod(L, "getCount"u8, 0, 1).IsOk); - Assert.Equal(9, EngineTest.ReadInteger(L, -1)); - Assert.True(probe.TryCallMethod(L, "getCount"u8, 0, 0).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - } - - [Fact] - public void TryCallMethod_replaces_the_arguments_by_one_error_value_on_failure() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe"); - - L.PushInteger(1); - L.PushInteger(2); - var status = probe.TryCallMethod(L, "raise"u8, 2, 1); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("raised by the host object", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - - L.PushInteger(1); - status = probe.TryCallMethod(L, "missing"u8, 1, 1); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal("'missing' is a nil, not a method", EngineTest.ErrorMessage(L, status)); - Assert.Equal(frame.Top + 1, L.Top); - } - - [Fact] - public void TryCallMethod_refuses_a_negative_argument_count_before_touching_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var probe = FakeHost.CreateObject(L, "Probe"); - - Assert.Throws(() => probe.TryCallMethod(L, "add"u8, -1, 0)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void A_null_handle_fails_every_stack_member_with_a_status() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - - Assert.False(CEObject.Null.TryGetProperty(L, "Count"u8).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - L.PushInteger(1); - Assert.False(CEObject.Null.TrySetProperty(L, "Count"u8).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - Assert.False(CEObject.Null.TryGetIndex(L, 0).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - Assert.False(CEObject.Null.TryPushMethod(L, "destroy"u8).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - L.Pop(1); - L.PushInteger(1); - Assert.False(CEObject.Null.TryCallMethod(L, "add"u8, 1, 1).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - } - - [Fact] - public void Typed_members_acquire_the_state_read_through_the_marshaller_and_restore_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var probe = FakeHost.CreateObject( - L, - "Probe", - "o.props.Count = 3; o.props.Name = 'probe'; o.props.Result = 0x400000; o.props.Text = '00401000'; o.getters.Bad = function() error('x') end"); - - Assert.True(probe.TryGetProperty("Count"u8, out var count)); - Assert.Equal(3, count); - Assert.True(probe.TryGetProperty("Name"u8, out var name)); - Assert.Equal("probe", name); - Assert.True(probe.TryGetProperty("Result"u8, out var number)); - Assert.Equal(0x400000UL, number.Value); - Assert.True(probe.TryGetProperty("Text"u8, out var text)); - Assert.Equal(0x401000UL, text.Value); - Assert.True(probe.TryGetProperty, VariableType>("Count"u8, out var asEnum)); - Assert.Equal(VariableType.Qword, asEnum); - - Assert.False(probe.TryGetProperty("Name"u8, out var notAnInteger)); - Assert.Equal(0, notAnInteger); - Assert.False(probe.TryGetProperty("Missing"u8, out _)); - Assert.False(probe.TryGetProperty("Bad"u8, out _)); - Assert.False(probe.TryGetProperty("Missing"u8, out var missing)); - Assert.Null(missing); - - Assert.True(probe.TrySetProperty("Count"u8, 11)); - Assert.True(probe.TrySetProperty>("Name"u8, "renamed"u8)); - Assert.True(probe.TrySetProperty("Result"u8, 0x500000)); - Assert.True(probe.TryGetProperty("Count"u8, out count)); - Assert.Equal(11, count); - Assert.True(probe.TryGetProperty("Name"u8, out name)); - Assert.Equal("renamed", name); - Assert.True(probe.TryGetProperty("Result"u8, out number)); - Assert.Equal(0x500000UL, number.Value); - - Assert.True(probe.TryCallMethod("getCount"u8, out var viaMethod)); - Assert.Equal(11, viaMethod); - Assert.True(probe.TryCallMethod("getCount"u8)); - Assert.False(probe.TryCallMethod("raise"u8)); - Assert.False(probe.TryCallMethod("notAMethod"u8)); - Assert.False(probe.TryCallMethod("getClassName"u8, out _)); - Assert.False(probe.TryCallMethod("raise"u8, out _)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Typed_members_restore_the_exact_stack_when_a_consumer_marshaller_throws() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3"); - L.PushInteger(0x1234); - var top = L.Top; - - Assert.Throws(() => probe.TrySetProperty("Count"u8, 4)); - Assert.Equal(top, L.Top); - Assert.Equal(0x1234, EngineTest.ReadInteger(L, -1)); - - Assert.Throws(() => - probe.TryGetProperty("Count"u8, out _)); - Assert.Equal(top, L.Top); - Assert.Equal(0x1234, EngineTest.ReadInteger(L, -1)); - - Assert.Throws(() => - probe.TryCallMethod("getCount"u8, out _)); - Assert.Equal(top, L.Top); - Assert.Equal(0x1234, EngineTest.ReadInteger(L, -1)); - } - - [Fact] - public void An_object_travels_as_an_argument_and_comes_back_as_the_same_handle() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var probe = FakeHost.CreateObject(L, "Probe"); - var other = FakeHost.CreateObject(L, "Probe"); - - using (LuaFrame frame = new(L)) - { - other.Push(L); - Assert.True(probe.TryCallMethod(L, "setOther"u8, 1, 0).IsOk); - Assert.Equal(frame.Top, L.Top); - } - - Assert.True(probe.TryGetProperty("Other"u8, out var back)); - Assert.Equal(other, back); - Assert.NotEqual(probe, back); - Assert.False(probe.TryGetProperty("Missing"u8, out var none)); - Assert.True(none.IsNull); - Assert.Equal(0, L.Top); - } - - [Fact] - public void A_method_returning_hexadecimal_text_is_read_by_the_address_reader_with_a_zero_based_index() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var list = FakeHost.CreateObject(L, "Probe", "o.addresses = { 0x400000, 0x7FF6A1B2C3D4 }"); - - L.PushInteger(0); - Assert.True(list.TryCallMethod(L, "getAddress"u8, 1, 1).IsOk); - Assert.Equal("00400000", EngineTest.ReadString(L, -1)); - Assert.True(Address.TryRead(L, -1, out var first)); - Assert.Equal(0x400000UL, first.Value); - - L.PushInteger(1); - Assert.True(list.TryCallMethod(L, "getAddressNumber"u8, 1, 1).IsOk); - Assert.True(L.IsInteger(-1)); - Assert.True(Address.TryRead(L, -1, out var second)); - Assert.Equal(0x7FF6A1B2C3D4UL, second.Value); - - L.PushInteger(2); - Assert.False(list.TryCallMethod(L, "getAddress"u8, 1, 1).IsOk); - Assert.Equal(frame.Top + 3, L.Top); - } - - private readonly struct ThrowingPushMarshaller : ILuaMarshaller - { - public static void Push(LuaState state, int value) - { - state.PushInteger(value); - throw new InvalidOperationException("The test marshaller failed after pushing a partial value."); - } - - public static bool TryRead(LuaState state, int index, out int value) - { - value = default; - return false; - } - } - - private readonly struct ThrowingReadMarshaller : ILuaMarshaller - { - public static void Push(LuaState state, int value) - { - state.PushInteger(value); - } - - public static bool TryRead(LuaState state, int index, out int value) - { - state.PushInteger(0x5678); - value = default; - throw new InvalidOperationException("The test marshaller failed after creating a partial stack value."); - } - } + [Fact] + public void TryRead_decodes_a_full_userdata_whose_first_field_is_the_object_pointer() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + + CEObject probe = FakeHost.CreateObject(L, "Probe"); + probe.Push(L); + Assert.True(L.IsUserdata(-1)); + Assert.True(CEObject.TryRead(L, -1, out CEObject fromPusher)); + Assert.Equal(probe, fromPusher); + + // A userdata made by hand with the same layout, larger than a pointer. + IntPtr block = L.NewUserdata(32); + *(nint*) block = 0x7777_0000; + Assert.True(CEObject.TryRead(L, -1, out CEObject fromBlock)); + Assert.Equal(new CEObject(0x7777_0000), fromBlock); + Assert.Equal(frame.Top + 2, L.Top); + } + + [Fact] + public void TryRead_refuses_values_that_are_not_host_objects() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + + L.PushLightUserdata(0x1234); // 1: a bare pointer, no block to read + _ = L.NewUserdata(1); // 2: a block smaller than a pointer + IntPtr zeroed = L.NewUserdata((nuint) sizeof(nint)); + *(nint*) zeroed = 0; // 3: a null first field + L.CreateTable(); // 4 + L.PushInteger(0x1234); // 5 + L.PushString("0x1234"u8); // 6 + L.PushNil(); // 7 + + for (int index = 1; index <= 8; index++) + { + Assert.False(CEObject.TryRead(L, index, out CEObject value), + "index " + index.ToString(CultureInfo.InvariantCulture) + " was read as an object"); + Assert.True(value.IsNull); + } + + Assert.Equal(7, L.Top); + } + + [Fact] + public void Push_goes_through_the_host_pusher_and_a_null_handle_pushes_nil() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + + CEObject probe = FakeHost.CreateObject(L, "Probe"); + int pushes = FakeHost.PusherCalls; + probe.Push(L); + CEObject.Push(L, probe); + CEObject.Null.Push(L); + + Assert.Equal(pushes + 2, FakeHost.PusherCalls); + Assert.Equal(LuaType.Userdata, L.TypeOf(-3)); + Assert.Equal(LuaType.Userdata, L.TypeOf(-2)); + Assert.True(L.IsNil(-1)); + Assert.True(CEObject.TryRead(L, -2, out CEObject back)); + Assert.Equal(probe, back); + } + + [Fact] + public void Push_without_a_pusher_in_the_binding_throws() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state, false); + LuaState L = scope.State; + + CEObject probe = FakeHost.CreateObject(L, "Probe"); + Assert.Throws(() => probe.Push(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryGetProperty_leaves_only_the_value_on_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3; o.props.Name = 'probe'"); + + Assert.True(probe.TryGetProperty(L, "Count"u8).IsOk); + Assert.Equal(3, EngineTest.ReadInteger(L, -1)); + Assert.Equal(frame.Top + 1, L.Top); + + Assert.True(probe.TryGetProperty(L, "Name"u8).IsOk); + Assert.Equal("probe", EngineTest.ReadString(L, -1)); + + Assert.True(probe.TryGetProperty(L, "Missing"u8).IsOk); + Assert.True(L.IsNil(-1)); + Assert.Equal(frame.Top + 3, L.Top); + } + + [Fact] + public void TrySetProperty_consumes_the_value_and_the_object_sees_it() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3"); + + L.PushInteger(7); + Assert.True(probe.TrySetProperty(L, "Count"u8).IsOk); + Assert.Equal(frame.Top, L.Top); + + Assert.True(probe.TryGetProperty(L, "Count"u8).IsOk); + Assert.Equal(7, EngineTest.ReadInteger(L, -1)); + } + + [Fact] + public void A_raising_getter_or_setter_is_a_status_with_one_error_value() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject( + L, + "Probe", + "o.getters.Bad = function() error('the getter raised') end; o.setters.Locked = function() error('the setter raised') end"); + + LuaStatus status = probe.TryGetProperty(L, "Bad"u8); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("the getter raised", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + + L.PushInteger(1); + status = probe.TrySetProperty(L, "Locked"u8); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("the setter raised", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); + Assert.Equal(frame.Top + 1, L.Top); + } + + [Fact] + public void Indexed_access_passes_the_zero_based_index_through_unchanged() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.items = { 'first', 'second' }"); + + Assert.True(probe.TryGetIndex(L, 0).IsOk); + Assert.Equal("first", EngineTest.ReadString(L, -1)); + Assert.True(probe.TryGetIndex(L, 1).IsOk); + Assert.Equal("second", EngineTest.ReadString(L, -1)); + Assert.True(probe.TryGetIndex(L, 2).IsOk); + Assert.True(L.IsNil(-1)); + L.SetTop(frame.Top); + + L.PushString("third"u8); + Assert.True(probe.TrySetIndex(L, 2).IsOk); + Assert.Equal(frame.Top, L.Top); + Assert.True(probe.TryGetIndex(L, 2).IsOk); + Assert.Equal("third", EngineTest.ReadString(L, -1)); + } + + [Fact] + public void TryPushMethod_pushes_an_instance_bound_function_that_is_called_without_self() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 5"); + + Assert.True(probe.TryPushMethod(L, "add"u8).IsOk); + Assert.True(L.IsFunction(-1)); + L.PushInteger(2); + L.PushInteger(3); + Assert.True(L.TryCall(2, 1).IsOk); + Assert.Equal(5, EngineTest.ReadInteger(L, -1)); + Assert.Equal(frame.Top + 1, L.Top); + + Assert.True(probe.TryPushMethod(L, "getClassName"u8).IsOk); + Assert.True(L.TryCall(0, 1).IsOk); + Assert.Equal("Probe", EngineTest.ReadString(L, -1)); + } + + [Fact] + public void TryPushMethod_reports_a_member_that_is_not_a_function_by_name_and_type() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe"); + + LuaStatus status = probe.TryPushMethod(L, "notAMethod"u8); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal("'notAMethod' is a number, not a method", EngineTest.ErrorMessage(L, status)); + Assert.Equal(frame.Top + 1, L.Top); + + status = probe.TryPushMethod(L, "missing"u8); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal("'missing' is a nil, not a method", EngineTest.ErrorMessage(L, status)); + Assert.Equal(frame.Top + 2, L.Top); + } + + [Fact] + public void TryCallMethod_replaces_the_arguments_by_the_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 9"); + + L.PushInteger(40); + L.PushInteger(2); + Assert.True(probe.TryCallMethod(L, "add"u8, 2, 1).IsOk); + Assert.Equal(42, EngineTest.ReadInteger(L, -1)); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + + L.PushInteger(1); + L.PushString("two"u8); + L.PushBoolean(true); + Assert.True(probe.TryCallMethod(L, "echo"u8, 3, LuaState.MultipleResults).IsOk); + Assert.Equal(frame.Top + 3, L.Top); + Assert.Equal(1, EngineTest.ReadInteger(L, -3)); + Assert.Equal("two", EngineTest.ReadString(L, -2)); + Assert.True(L.ToBoolean(-1)); + L.SetTop(frame.Top); + + Assert.True(probe.TryCallMethod(L, "getCount"u8, 0, 1).IsOk); + Assert.Equal(9, EngineTest.ReadInteger(L, -1)); + Assert.True(probe.TryCallMethod(L, "getCount"u8, 0, 0).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + } + + [Fact] + public void TryCallMethod_replaces_the_arguments_by_one_error_value_on_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe"); + + L.PushInteger(1); + L.PushInteger(2); + LuaStatus status = probe.TryCallMethod(L, "raise"u8, 2, 1); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("raised by the host object", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + + L.PushInteger(1); + status = probe.TryCallMethod(L, "missing"u8, 1, 1); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal("'missing' is a nil, not a method", EngineTest.ErrorMessage(L, status)); + Assert.Equal(frame.Top + 1, L.Top); + } + + [Fact] + public void TryCallMethod_refuses_a_negative_argument_count_before_touching_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject probe = FakeHost.CreateObject(L, "Probe"); + + Assert.Throws(() => probe.TryCallMethod(L, "add"u8, -1, 0)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void A_null_handle_fails_every_stack_member_with_a_status() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + + Assert.False(CEObject.Null.TryGetProperty(L, "Count"u8).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + L.PushInteger(1); + Assert.False(CEObject.Null.TrySetProperty(L, "Count"u8).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + Assert.False(CEObject.Null.TryGetIndex(L, 0).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + Assert.False(CEObject.Null.TryPushMethod(L, "destroy"u8).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + L.Pop(1); + L.PushInteger(1); + Assert.False(CEObject.Null.TryCallMethod(L, "add"u8, 1, 1).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + } + + [Fact] + public void Typed_members_acquire_the_state_read_through_the_marshaller_and_restore_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject probe = FakeHost.CreateObject( + L, + "Probe", + "o.props.Count = 3; o.props.Name = 'probe'; o.props.Result = 0x400000; o.props.Text = '00401000'; o.getters.Bad = function() error('x') end"); + + Assert.True(probe.TryGetProperty("Count"u8, out int count)); + Assert.Equal(3, count); + Assert.True(probe.TryGetProperty("Name"u8, out string? name)); + Assert.Equal("probe", name); + Assert.True(probe.TryGetProperty("Result"u8, out Address number)); + Assert.Equal(0x400000UL, number.Value); + Assert.True(probe.TryGetProperty("Text"u8, out Address text)); + Assert.Equal(0x401000UL, text.Value); + Assert.True( + probe.TryGetProperty, VariableType>("Count"u8, out VariableType asEnum)); + Assert.Equal(VariableType.Qword, asEnum); + + Assert.False(probe.TryGetProperty("Name"u8, out int notAnInteger)); + Assert.Equal(0, notAnInteger); + Assert.False(probe.TryGetProperty("Missing"u8, out _)); + Assert.False(probe.TryGetProperty("Bad"u8, out _)); + Assert.False(probe.TryGetProperty("Missing"u8, out string? missing)); + Assert.Null(missing); + + Assert.True(probe.TrySetProperty("Count"u8, 11)); + Assert.True(probe.TrySetProperty>("Name"u8, "renamed"u8)); + Assert.True(probe.TrySetProperty("Result"u8, 0x500000)); + Assert.True(probe.TryGetProperty("Count"u8, out count)); + Assert.Equal(11, count); + Assert.True(probe.TryGetProperty("Name"u8, out name)); + Assert.Equal("renamed", name); + Assert.True(probe.TryGetProperty("Result"u8, out number)); + Assert.Equal(0x500000UL, number.Value); + + Assert.True(probe.TryCallMethod("getCount"u8, out int viaMethod)); + Assert.Equal(11, viaMethod); + Assert.True(probe.TryCallMethod("getCount"u8)); + Assert.False(probe.TryCallMethod("raise"u8)); + Assert.False(probe.TryCallMethod("notAMethod"u8)); + Assert.False(probe.TryCallMethod("getClassName"u8, out _)); + Assert.False(probe.TryCallMethod("raise"u8, out _)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Typed_members_restore_the_exact_stack_when_a_consumer_marshaller_throws() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3"); + L.PushInteger(0x1234); + int top = L.Top; + + Assert.Throws(() => probe.TrySetProperty("Count"u8, 4)); + Assert.Equal(top, L.Top); + Assert.Equal(0x1234, EngineTest.ReadInteger(L, -1)); + + Assert.Throws(() => + probe.TryGetProperty("Count"u8, out _)); + Assert.Equal(top, L.Top); + Assert.Equal(0x1234, EngineTest.ReadInteger(L, -1)); + + Assert.Throws(() => + probe.TryCallMethod("getCount"u8, out _)); + Assert.Equal(top, L.Top); + Assert.Equal(0x1234, EngineTest.ReadInteger(L, -1)); + } + + [Fact] + public void An_object_travels_as_an_argument_and_comes_back_as_the_same_handle() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject probe = FakeHost.CreateObject(L, "Probe"); + CEObject other = FakeHost.CreateObject(L, "Probe"); + + using (LuaFrame frame = new(L)) + { + other.Push(L); + Assert.True(probe.TryCallMethod(L, "setOther"u8, 1, 0).IsOk); + Assert.Equal(frame.Top, L.Top); + } + + Assert.True(probe.TryGetProperty("Other"u8, out CEObject back)); + Assert.Equal(other, back); + Assert.NotEqual(probe, back); + Assert.False(probe.TryGetProperty("Missing"u8, out CEObject none)); + Assert.True(none.IsNull); + Assert.Equal(0, L.Top); + } + + [Fact] + public void A_method_returning_hexadecimal_text_is_read_by_the_address_reader_with_a_zero_based_index() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject list = FakeHost.CreateObject(L, "Probe", "o.addresses = { 0x400000, 0x7FF6A1B2C3D4 }"); + + L.PushInteger(0); + Assert.True(list.TryCallMethod(L, "getAddress"u8, 1, 1).IsOk); + Assert.Equal("00400000", EngineTest.ReadString(L, -1)); + Assert.True(Address.TryRead(L, -1, out Address first)); + Assert.Equal(0x400000UL, first.Value); + + L.PushInteger(1); + Assert.True(list.TryCallMethod(L, "getAddressNumber"u8, 1, 1).IsOk); + Assert.True(L.IsInteger(-1)); + Assert.True(Address.TryRead(L, -1, out Address second)); + Assert.Equal(0x7FF6A1B2C3D4UL, second.Value); + + L.PushInteger(2); + Assert.False(list.TryCallMethod(L, "getAddress"u8, 1, 1).IsOk); + Assert.Equal(frame.Top + 3, L.Top); + } + + private readonly struct ThrowingPushMarshaller : ILuaMarshaller + { + public static void Push(LuaState state, int value) + { + state.PushInteger(value); + throw new InvalidOperationException("The test marshaller failed after pushing a partial value."); + } + + public static bool TryRead(LuaState state, int index, out int value) + { + value = default; + return false; + } + } + + private readonly struct ThrowingReadMarshaller : ILuaMarshaller + { + public static void Push(LuaState state, int value) + { + state.PushInteger(value); + } + + public static bool TryRead(LuaState state, int index, out int value) + { + state.PushInteger(0x5678); + value = default; + throw new InvalidOperationException("The test marshaller failed after creating a partial stack value."); + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectValueTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectValueTests.cs index c0dd128b..82afafad 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectValueTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/CEObjectValueTests.cs @@ -7,62 +7,62 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; /// The handle as a value, without any Lua. public sealed class CEObjectValueTests { - [Fact] - public void A_handle_is_its_pointer() - { - CEObject a = new(0x1234); - CEObject b = new(0x1234); - CEObject c = new(0x5678); + [Fact] + public void A_handle_is_its_pointer() + { + CEObject a = new(0x1234); + CEObject b = new(0x1234); + CEObject c = new(0x5678); - Assert.Equal(0x1234, a.Value); - Assert.False(a.IsNull); - Assert.True(a == b); - Assert.False(a != b); - Assert.True(a != c); - Assert.True(a.Equals(b)); - Assert.True(a.Equals((object)b)); - Assert.False(a.Equals(null)); - Assert.False(a.Equals(c)); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - Assert.Equal("CEObject@0x1234", a.ToString()); - } + Assert.Equal(0x1234, a.Value); + Assert.False(a.IsNull); + Assert.True(a == b); + Assert.False(a != b); + Assert.True(a != c); + Assert.True(a.Equals(b)); + Assert.True(a.Equals((object) b)); + Assert.False(a.Equals(null)); + Assert.False(a.Equals(c)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.Equal("CEObject@0x1234", a.ToString()); + } - [Fact] - public void The_default_handle_is_null() - { - Assert.True(default(CEObject).IsNull); - Assert.True(CEObject.Null.IsNull); - Assert.Equal(0, CEObject.Null.Value); - Assert.Equal(default, CEObject.Null); - Assert.Equal("CEObject(null)", CEObject.Null.ToString()); - Assert.Equal(CEObject.Null, new CEObject(0)); - } + [Fact] + public void The_default_handle_is_null() + { + Assert.True(default(CEObject).IsNull); + Assert.True(CEObject.Null.IsNull); + Assert.Equal(0, CEObject.Null.Value); + Assert.Equal(default, CEObject.Null); + Assert.Equal("CEObject(null)", CEObject.Null.ToString()); + Assert.Equal(CEObject.Null, new CEObject(0)); + } - [Fact] - public void The_handle_is_its_own_typed_handle() - { - CEObject handle = new(0x42); - Assert.Equal(handle, handle.Handle); - Assert.Equal(handle, CEObject.FromHandle(handle)); - Assert.Equal(handle, Wrap(handle)); - Assert.True(Wrap(CEObject.Null).IsNull); - } + [Fact] + public void The_handle_is_its_own_typed_handle() + { + CEObject handle = new(0x42); + Assert.Equal(handle, handle.Handle); + Assert.Equal(handle, CEObject.FromHandle(handle)); + Assert.Equal(handle, Wrap(handle)); + Assert.True(Wrap(CEObject.Null).IsNull); + } - [Fact] - public void Members_that_reach_the_host_fail_cleanly_while_detached() - { - LuaRuntime.Detach(); - CEObject handle = new(0x42); + [Fact] + public void Members_that_reach_the_host_fail_cleanly_while_detached() + { + LuaRuntime.Detach(); + CEObject handle = new(0x42); - Assert.Throws(() => handle.TryGetProperty("Count"u8, out _)); - Assert.Throws(() => handle.TrySetProperty("Count"u8, 1)); - Assert.Throws(() => handle.TryCallMethod("destroy"u8)); - Assert.Throws(() => handle.TryCallMethod("getCount"u8, out _)); - } + Assert.Throws(() => handle.TryGetProperty("Count"u8, out _)); + Assert.Throws(() => handle.TrySetProperty("Count"u8, 1)); + Assert.Throws(() => handle.TryCallMethod("destroy"u8)); + Assert.Throws(() => handle.TryCallMethod("getCount"u8, out _)); + } - private static T Wrap(CEObject handle) - where T : struct, ICEObject - { - return T.FromHandle(handle); - } + private static T Wrap(CEObject handle) + where T : struct, ICEObject + { + return T.FromHandle(handle); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/HostCallCountTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/HostCallCountTests.cs index 90ec95d2..22724d92 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/HostCallCountTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/HostCallCountTests.cs @@ -1,4 +1,6 @@ using System.Globalization; + +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; @@ -14,95 +16,95 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; [Trait("Category", "NativeLua")] public sealed class HostCallCountTests { - [Fact] - public void Typed_members_acquire_the_state_once_and_push_the_object_once() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var probe = FakeHost.CreateObject( - scope.State, - "Probe", - "o.props.Count = 3; o.getters.Bad = function() error('x') end; o.setters.Locked = function() error('y') end"); + [Fact] + public void Typed_members_acquire_the_state_once_and_push_the_object_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + CEObject probe = FakeHost.CreateObject( + scope.State, + "Probe", + "o.props.Count = 3; o.getters.Bad = function() error('x') end; o.setters.Locked = function() error('y') end"); - AssertOneAcquisitionAndOnePush(() => probe.TryGetProperty("Count"u8, out _), "typed get"); - AssertOneAcquisitionAndOnePush(() => probe.TrySetProperty("Count"u8, 4), "typed set"); - AssertOneAcquisitionAndOnePush( - () => probe.TrySetProperty>("Name"u8, "renamed"u8), - "typed set of a string"); - AssertOneAcquisitionAndOnePush(() => probe.TryCallMethod("getClassName"u8), "typed call"); - AssertOneAcquisitionAndOnePush( - () => probe.TryCallMethod("getCount"u8, out var count) && count == 4, - "typed call with result"); + AssertOneAcquisitionAndOnePush(() => probe.TryGetProperty("Count"u8, out _), "typed get"); + AssertOneAcquisitionAndOnePush(() => probe.TrySetProperty("Count"u8, 4), "typed set"); + AssertOneAcquisitionAndOnePush( + () => probe.TrySetProperty>("Name"u8, "renamed"u8), + "typed set of a string"); + AssertOneAcquisitionAndOnePush(() => probe.TryCallMethod("getClassName"u8), "typed call"); + AssertOneAcquisitionAndOnePush( + () => probe.TryCallMethod("getCount"u8, out int count) && count == 4, + "typed call with result"); - AssertOneAcquisitionAndOnePush(() => !probe.TryGetProperty("Bad"u8, out _), - "raising get"); - AssertOneAcquisitionAndOnePush(() => !probe.TryGetProperty("Missing"u8, out _), - "get of a missing member"); - AssertOneAcquisitionAndOnePush(() => !probe.TrySetProperty("Locked"u8, 1), "raising set"); - AssertOneAcquisitionAndOnePush(() => !probe.TryCallMethod("raise"u8), "raising call"); - AssertOneAcquisitionAndOnePush(() => !probe.TryCallMethod("notAMethod"u8), - "call of a member that is not a function"); - AssertOneAcquisitionAndOnePush(() => !probe.TryCallMethod("getClassName"u8, out _), - "call whose result has the wrong kind"); - Assert.Equal(0, scope.State.Top); - } + AssertOneAcquisitionAndOnePush(() => !probe.TryGetProperty("Bad"u8, out _), + "raising get"); + AssertOneAcquisitionAndOnePush(() => !probe.TryGetProperty("Missing"u8, out _), + "get of a missing member"); + AssertOneAcquisitionAndOnePush(() => !probe.TrySetProperty("Locked"u8, 1), "raising set"); + AssertOneAcquisitionAndOnePush(() => !probe.TryCallMethod("raise"u8), "raising call"); + AssertOneAcquisitionAndOnePush(() => !probe.TryCallMethod("notAMethod"u8), + "call of a member that is not a function"); + AssertOneAcquisitionAndOnePush(() => !probe.TryCallMethod("getClassName"u8, out _), + "call whose result has the wrong kind"); + Assert.Equal(0, scope.State.Top); + } - [Fact] - public void Stack_level_members_push_the_object_once_and_never_acquire_a_state() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3; o.items = { 'a' }"); + [Fact] + public void Stack_level_members_push_the_object_once_and_never_acquire_a_state() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3; o.items = { 'a' }"); - AssertNoAcquisitionAndOnePush(() => probe.TryGetProperty(L, "Count"u8).IsOk, "get"); - L.PushInteger(5); - AssertNoAcquisitionAndOnePush(() => probe.TrySetProperty(L, "Count"u8).IsOk, "set"); - AssertNoAcquisitionAndOnePush(() => probe.TryGetIndex(L, 0).IsOk, "index get"); - L.PushString("b"u8); - AssertNoAcquisitionAndOnePush(() => probe.TrySetIndex(L, 1).IsOk, "index set"); - AssertNoAcquisitionAndOnePush(() => probe.TryPushMethod(L, "getCount"u8).IsOk, "method push"); - L.PushInteger(1); - L.PushInteger(2); - AssertNoAcquisitionAndOnePush(() => probe.TryCallMethod(L, "add"u8, 2, 1).IsOk, "call with arguments"); - AssertNoAcquisitionAndOnePush(() => !probe.TryCallMethod(L, "missing"u8, 0, 0).IsOk, - "call of a missing method"); - AssertNoAcquisitionAndOnePush(() => probe.TryGetPropertyLeavingObject(L, "Count"u8).IsOk, - "get leaving the object"); - AssertNoAcquisitionAndOnePush(() => probe.TryPushMethodLeavingObject(L, "getCount"u8).IsOk, - "method push leaving the object"); - } + AssertNoAcquisitionAndOnePush(() => probe.TryGetProperty(L, "Count"u8).IsOk, "get"); + L.PushInteger(5); + AssertNoAcquisitionAndOnePush(() => probe.TrySetProperty(L, "Count"u8).IsOk, "set"); + AssertNoAcquisitionAndOnePush(() => probe.TryGetIndex(L, 0).IsOk, "index get"); + L.PushString("b"u8); + AssertNoAcquisitionAndOnePush(() => probe.TrySetIndex(L, 1).IsOk, "index set"); + AssertNoAcquisitionAndOnePush(() => probe.TryPushMethod(L, "getCount"u8).IsOk, "method push"); + L.PushInteger(1); + L.PushInteger(2); + AssertNoAcquisitionAndOnePush(() => probe.TryCallMethod(L, "add"u8, 2, 1).IsOk, "call with arguments"); + AssertNoAcquisitionAndOnePush(() => !probe.TryCallMethod(L, "missing"u8, 0, 0).IsOk, + "call of a missing method"); + AssertNoAcquisitionAndOnePush(() => probe.TryGetPropertyLeavingObject(L, "Count"u8).IsOk, + "get leaving the object"); + AssertNoAcquisitionAndOnePush(() => probe.TryPushMethodLeavingObject(L, "getCount"u8).IsOk, + "method push leaving the object"); + } - private static void AssertOneAcquisitionAndOnePush(Func operation, string what) - { - var providerCalls = FakeHost.ProviderCalls; - var pusherCalls = FakeHost.PusherCalls; + private static void AssertOneAcquisitionAndOnePush(Func operation, string what) + { + int providerCalls = FakeHost.ProviderCalls; + int pusherCalls = FakeHost.PusherCalls; - Assert.True(operation(), what + " did not produce the expected outcome"); + Assert.True(operation(), what + " did not produce the expected outcome"); - Assert.True(FakeHost.ProviderCalls - providerCalls == 1, - what + " acquired the state " + - (FakeHost.ProviderCalls - providerCalls).ToString(CultureInfo.InvariantCulture) + - " times, not once"); - Assert.True(FakeHost.PusherCalls - pusherCalls == 1, - what + " pushed the object " + (FakeHost.PusherCalls - pusherCalls).ToString(CultureInfo.InvariantCulture) + - " times, not once"); - } + Assert.True(FakeHost.ProviderCalls - providerCalls == 1, + what + " acquired the state " + + (FakeHost.ProviderCalls - providerCalls).ToString(CultureInfo.InvariantCulture) + + " times, not once"); + Assert.True(FakeHost.PusherCalls - pusherCalls == 1, + what + " pushed the object " + (FakeHost.PusherCalls - pusherCalls).ToString(CultureInfo.InvariantCulture) + + " times, not once"); + } - private static void AssertNoAcquisitionAndOnePush(Func operation, string what) - { - var providerCalls = FakeHost.ProviderCalls; - var pusherCalls = FakeHost.PusherCalls; + private static void AssertNoAcquisitionAndOnePush(Func operation, string what) + { + int providerCalls = FakeHost.ProviderCalls; + int pusherCalls = FakeHost.PusherCalls; - Assert.True(operation(), what + " did not produce the expected outcome"); + Assert.True(operation(), what + " did not produce the expected outcome"); - Assert.True(FakeHost.ProviderCalls == providerCalls, - what + " acquired a state; a stack-level member works on the state it was given"); - Assert.True(FakeHost.PusherCalls - pusherCalls == 1, - what + " pushed the object " + (FakeHost.PusherCalls - pusherCalls).ToString(CultureInfo.InvariantCulture) + - " times, not once"); - } + Assert.True(FakeHost.ProviderCalls == providerCalls, + what + " acquired a state; a stack-level member works on the state it was given"); + Assert.True(FakeHost.PusherCalls - pusherCalls == 1, + what + " pushed the object " + (FakeHost.PusherCalls - pusherCalls).ToString(CultureInfo.InvariantCulture) + + " times, not once"); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs index 8d1699ef..7f898899 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs @@ -11,307 +11,307 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; /// Ownership: explicit destroy through the protected call, exactly once, and the transfer API. public sealed class OwnedTests { - [Fact] - public void A_null_handle_cannot_be_owned() - { - var exception = Assert.Throws(() => new Owned(CEObject.Null)); - Assert.Equal("value", exception.ParamName); - } - - [Fact] - public void Construction_is_internal_so_a_consumer_cannot_wrap_an_arbitrary_borrowed_handle() - { - Assert.Empty(typeof(Owned).GetConstructors()); - } - - [Fact] - public void Transfer_moves_the_owner_and_abandon_returns_only_a_borrowed_handle() - { - CEObject handle = new(0x1234); - Owned source = new(handle); - - Assert.False(source.IsDisposed); - Assert.Equal(handle, source.Value); - Assert.Equal(handle, source.Handle); - Assert.Equal(handle, source.ToBorrowed()); - Assert.Equal("Owned(CEObject@0x1234)", source.ToString()); - - var destination = source.Transfer(); - Assert.True(source.IsDisposed); - Assert.Equal("Owned(disposed)", source.ToString()); - Assert.Throws(() => source.Value); - Assert.Throws(() => source.Handle); - Assert.Throws(() => source.ToBorrowed()); - Assert.Throws(() => source.Transfer()); - - Assert.False(destination.IsDisposed); - Assert.Equal(handle, destination.Abandon()); - Assert.True(destination.IsDisposed); - Assert.Throws(() => destination.Abandon()); - } - - [Fact] - public void Dispose_while_detached_throws_and_retains_the_owner() - { - LuaRuntime.Detach(); - Owned owned = new(new CEObject(0x1234)); - - Assert.Throws(owned.Dispose); - - Assert.False(owned.IsDisposed); - Assert.Equal(new CEObject(0x1234), owned.Value); - Assert.Equal(new CEObject(0x1234), owned.Abandon()); - } - - [Fact] - public void TryDestroy_while_detached_throws_before_touching_the_state_and_retains_the_owner() - { - LuaRuntime.Detach(); - Owned owned = new(new CEObject(0x1234)); - - // The null state view is never dereferenced: the runtime admission reports detached state first. - Assert.Throws(() => owned.TryDestroy(default)); - - Assert.False(owned.IsDisposed); - Assert.Equal(new CEObject(0x1234), owned.Abandon()); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_detached_owner_can_be_retried_after_its_original_host_binding_is_reattached() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe"); - Owned owned = new(handle); - - LuaRuntime.Detach(); - Assert.Throws(owned.Dispose); - Assert.False(owned.IsDisposed); - Assert.False(FakeHost.IsDestroyed(L, handle)); - - LuaRuntime.Attach(scope.Binding); - owned.Dispose(); - - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(1, FakeHost.DestroyedCount(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Dispose_with_a_binding_that_has_no_pusher_throws_and_retains_the_owner() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state, false); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe"); - Owned owned = new(handle); - - Assert.Throws(owned.Dispose); - - Assert.False(owned.IsDisposed); - Assert.False(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(0, FakeHost.DestroyedCount(L)); - Assert.Equal(0, L.Top); - Assert.Equal(handle, owned.Abandon()); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void TryDestroy_with_a_binding_that_has_no_pusher_throws_and_retains_the_owner() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state, false); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe"); - Owned owned = new(handle); - - Assert.Throws(() => owned.TryDestroy(L)); - - Assert.False(owned.IsDisposed); - Assert.False(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(0, L.Top); - Assert.Throws(() => owned.TryDestroy(L)); - Assert.Equal(0, L.Top); - Assert.Equal(handle, owned.Abandon()); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Dispose_destroys_the_object_exactly_once() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe"); - Owned owned = new(handle); - - owned.Dispose(); - - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(1, FakeHost.DestroyedCount(L)); - Assert.Equal(0, L.Top); - - owned.Dispose(); - Assert.Equal(1, FakeHost.DestroyedCount(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void The_using_pattern_destroys_at_the_end_of_the_block() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe", "o.props.Count = 4"); - - using (Owned owned = new(handle)) - { - Assert.True(owned.Value.TryGetProperty("Count"u8, out var count)); - Assert.Equal(4, count); - Assert.False(FakeHost.IsDestroyed(L, handle)); - } - - Assert.True(FakeHost.IsDestroyed(L, handle)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void TryDestroy_returns_the_status_and_marks_disposed_even_when_destroy_raises() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using LuaFrame frame = new(L); - Owned stubborn = new(FakeHost.CreateObject(L, "Stubborn")); - - var status = stubborn.TryDestroy(L); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("refuses to be destroyed", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); - Assert.Equal(frame.Top + 1, L.Top); - Assert.True(stubborn.IsDisposed); - Assert.Equal(0, FakeHost.DestroyedCount(L)); - - // Never retried: a second attempt is a no-op that pushes nothing. - Assert.True(stubborn.TryDestroy(L).IsOk); - Assert.Equal(frame.Top + 1, L.Top); - stubborn.Dispose(); - Assert.Equal(frame.Top + 1, L.Top); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void TryDestroy_reports_success_and_a_disposed_wrapper_is_inert() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe"); - Owned owned = new(handle); - - Assert.True(owned.TryDestroy(L).IsOk); - Assert.Equal(0, L.Top); - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.True(owned.TryDestroy(L).IsOk); - Assert.Equal(1, FakeHost.DestroyedCount(L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Dispose_swallows_a_raising_destroy_and_restores_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - Owned stubborn = new(FakeHost.CreateObject(L, "Stubborn")); - - stubborn.Dispose(); - - Assert.True(stubborn.IsDisposed); - Assert.Equal(0, L.Top); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Transfer_moves_ownership_so_only_the_destination_owner_destroys() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = FakeHost.CreateObject(L, "Probe"); - Owned first = new(handle); - - var second = first.Transfer(); - first.Dispose(); - Assert.True(first.IsDisposed); - Assert.False(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(0, FakeHost.DestroyedCount(L)); - - using (second) - { - Assert.Equal(handle, second.Handle); - } - - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(1, FakeHost.DestroyedCount(L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Dispose_after_the_plugin_is_disabled_throws_and_retains_the_owner_for_explicit_abandonment() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - LuaState L; - CEObject handle; - Owned owned; - using (HostScope scope = new(state)) - { - L = scope.State; - handle = FakeHost.CreateObject(L, "Probe"); - owned = new Owned(handle); - } - - Assert.False(LuaRuntime.IsAttached); - Assert.Throws(owned.Dispose); - - Assert.False(owned.IsDisposed); - Assert.False(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(0, FakeHost.DestroyedCount(L)); - Assert.Equal(0, L.Top); - Assert.Equal(handle, owned.Abandon()); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_borrowed_view_stays_usable_while_the_owner_lives() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - using Owned owned = new(FakeHost.CreateObject(L, "Probe", "o.props.Count = 8")); - - var borrowed = owned.ToBorrowed(); - Assert.True(borrowed.TryGetProperty("Count"u8, out var count)); - Assert.Equal(8, count); - Assert.True(borrowed.TryCallMethod("getCount"u8, out count)); - Assert.Equal(8, count); - } + [Fact] + public void A_null_handle_cannot_be_owned() + { + ArgumentException exception = Assert.Throws(() => new Owned(CEObject.Null)); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void Construction_is_internal_so_a_consumer_cannot_wrap_an_arbitrary_borrowed_handle() + { + Assert.Empty(typeof(Owned).GetConstructors()); + } + + [Fact] + public void Transfer_moves_the_owner_and_abandon_returns_only_a_borrowed_handle() + { + CEObject handle = new(0x1234); + Owned source = new(handle); + + Assert.False(source.IsDisposed); + Assert.Equal(handle, source.Value); + Assert.Equal(handle, source.Handle); + Assert.Equal(handle, source.ToBorrowed()); + Assert.Equal("Owned(CEObject@0x1234)", source.ToString()); + + Owned destination = source.Transfer(); + Assert.True(source.IsDisposed); + Assert.Equal("Owned(disposed)", source.ToString()); + Assert.Throws(() => source.Value); + Assert.Throws(() => source.Handle); + Assert.Throws(() => source.ToBorrowed()); + Assert.Throws(() => source.Transfer()); + + Assert.False(destination.IsDisposed); + Assert.Equal(handle, destination.Abandon()); + Assert.True(destination.IsDisposed); + Assert.Throws(() => destination.Abandon()); + } + + [Fact] + public void Dispose_while_detached_throws_and_retains_the_owner() + { + LuaRuntime.Detach(); + Owned owned = new(new CEObject(0x1234)); + + Assert.Throws(owned.Dispose); + + Assert.False(owned.IsDisposed); + Assert.Equal(new CEObject(0x1234), owned.Value); + Assert.Equal(new CEObject(0x1234), owned.Abandon()); + } + + [Fact] + public void TryDestroy_while_detached_throws_before_touching_the_state_and_retains_the_owner() + { + LuaRuntime.Detach(); + Owned owned = new(new CEObject(0x1234)); + + // The null state view is never dereferenced: the runtime admission reports detached state first. + Assert.Throws(() => owned.TryDestroy(default)); + + Assert.False(owned.IsDisposed); + Assert.Equal(new CEObject(0x1234), owned.Abandon()); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_detached_owner_can_be_retried_after_its_original_host_binding_is_reattached() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + LuaRuntime.Detach(); + Assert.Throws(owned.Dispose); + Assert.False(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + + LuaRuntime.Attach(scope.Binding); + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(1, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Dispose_with_a_binding_that_has_no_pusher_throws_and_retains_the_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state, false); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + Assert.Throws(owned.Dispose); + + Assert.False(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + Assert.Equal(handle, owned.Abandon()); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void TryDestroy_with_a_binding_that_has_no_pusher_throws_and_retains_the_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state, false); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + Assert.Throws(() => owned.TryDestroy(L)); + + Assert.False(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + Assert.Throws(() => owned.TryDestroy(L)); + Assert.Equal(0, L.Top); + Assert.Equal(handle, owned.Abandon()); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Dispose_destroys_the_object_exactly_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(1, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + + owned.Dispose(); + Assert.Equal(1, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void The_using_pattern_destroys_at_the_end_of_the_block() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe", "o.props.Count = 4"); + + using (Owned owned = new(handle)) + { + Assert.True(owned.Value.TryGetProperty("Count"u8, out int count)); + Assert.Equal(4, count); + Assert.False(FakeHost.IsDestroyed(L, handle)); + } + + Assert.True(FakeHost.IsDestroyed(L, handle)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void TryDestroy_returns_the_status_and_marks_disposed_even_when_destroy_raises() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + Owned stubborn = new(FakeHost.CreateObject(L, "Stubborn")); + + LuaStatus status = stubborn.TryDestroy(L); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("refuses to be destroyed", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); + Assert.Equal(frame.Top + 1, L.Top); + Assert.True(stubborn.IsDisposed); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + + // Never retried: a second attempt is a no-op that pushes nothing. + Assert.True(stubborn.TryDestroy(L).IsOk); + Assert.Equal(frame.Top + 1, L.Top); + stubborn.Dispose(); + Assert.Equal(frame.Top + 1, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void TryDestroy_reports_success_and_a_disposed_wrapper_is_inert() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + Assert.True(owned.TryDestroy(L).IsOk); + Assert.Equal(0, L.Top); + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.True(owned.TryDestroy(L).IsOk); + Assert.Equal(1, FakeHost.DestroyedCount(L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Dispose_swallows_a_raising_destroy_and_restores_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + Owned stubborn = new(FakeHost.CreateObject(L, "Stubborn")); + + stubborn.Dispose(); + + Assert.True(stubborn.IsDisposed); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Transfer_moves_ownership_so_only_the_destination_owner_destroys() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned first = new(handle); + + Owned second = first.Transfer(); + first.Dispose(); + Assert.True(first.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + + using (second) + { + Assert.Equal(handle, second.Handle); + } + + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(1, FakeHost.DestroyedCount(L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Dispose_after_the_plugin_is_disabled_throws_and_retains_the_owner_for_explicit_abandonment() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L; + CEObject handle; + Owned owned; + using (HostScope scope = new(state)) + { + L = scope.State; + handle = FakeHost.CreateObject(L, "Probe"); + owned = new Owned(handle); + } + + Assert.False(LuaRuntime.IsAttached); + Assert.Throws(owned.Dispose); + + Assert.False(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + Assert.Equal(handle, owned.Abandon()); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_borrowed_view_stays_usable_while_the_owner_lives() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using Owned owned = new(FakeHost.CreateObject(L, "Probe", "o.props.Count = 8")); + + CEObject borrowed = owned.ToBorrowed(); + Assert.True(borrowed.TryGetProperty("Count"u8, out int count)); + Assert.Equal(8, count); + Assert.True(borrowed.TryCallMethod("getCount"u8, out count)); + Assert.Equal(8, count); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/StringListTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/StringListTests.cs index 83f8c52a..ffdf5cf1 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/StringListTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/StringListTests.cs @@ -3,6 +3,7 @@ using CheatEngine.SDK.Engine.Tests.Scanning.Aob; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Objects; @@ -11,151 +12,151 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; [Trait("Category", "NativeLua")] public sealed class StringListTests { - [Fact] - public void Handle_properties_and_zero_based_indexer_round_trip_without_stack_residue() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateList(L); - var list = StringList.FromHandle(handle); - - Assert.Equal(list, StringList.FromHandle(handle)); - Assert.False(list.IsNull); - Assert.True(list.TryGetCount(out var count)); - Assert.Equal(2, count); - Assert.True(list.TryGetItem(0, out var first)); - Assert.Equal("00401000", first); - Assert.True(list.TrySetItem(1, "00402000")); - Assert.True(list.TryGetItem(1, out var replacement)); - Assert.Equal("00402000", replacement); - - Assert.True(list.TryGetSorted(out var sorted)); - Assert.False(sorted); - Assert.True(list.TrySetSorted(value: true)); - Assert.True(list.TryGetSorted(out sorted)); - Assert.True(sorted); - Assert.True(list.TryGetCaseSensitive(out var caseSensitive)); - Assert.True(caseSensitive); - Assert.True(list.TrySetCaseSensitive(value: false)); - Assert.True(list.TryGetCaseSensitive(out caseSensitive)); - Assert.False(caseSensitive); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Strings_methods_preserve_CE_zero_based_indices_and_update_the_list() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); - - Assert.True(list.TryAdd("00403000", out var addedIndex)); - Assert.Equal(2, addedIndex); - Assert.True(list.TryIndexOf("00403000", out var foundIndex)); - Assert.Equal(2, foundIndex); - Assert.True(list.TryIndexOf("missing", out var missingIndex)); - Assert.Equal(-1, missingIndex); - Assert.True(list.TryGetText(out var text)); - Assert.Equal("00401000\n7FF6A1B2C3D4\n00403000", text); - - Assert.True(list.TryDelete(1)); - Assert.True(list.TryGetCount(out var count)); - Assert.Equal(2, count); - Assert.True(list.TrySetText("only line")); - Assert.True(list.TryGetItem(0, out var onlyLine)); - Assert.Equal("only line", onlyLine); - Assert.True(list.TryClear()); - Assert.True(list.TryGetCount(out count)); - Assert.Equal(0, count); - Assert.Equal(0, L.Top); - } - - [Theory] - [InlineData(DuplicateHandling.Ignore)] - [InlineData(DuplicateHandling.Accept)] - [InlineData(DuplicateHandling.Error)] - public void Duplicates_property_round_trips_each_numeric_CE_enum_value(DuplicateHandling expected) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); - - Assert.True(list.TryGetDuplicates(out var duplicates)); - Assert.Equal(DuplicateHandling.Accept, duplicates); - Assert.True(list.TrySetDuplicates(expected)); - Assert.True(list.TryGetDuplicates(out duplicates)); - Assert.Equal(expected, duplicates); - Assert.Equal(0, L.Top); - } - - [Theory] - [InlineData(-1)] - [InlineData(3)] - public void TrySetDuplicates_value_is_not_defined_throws_before_lua(int rawValue) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); - var providerCalls = FakeHost.ProviderCalls; - var pusherCalls = FakeHost.PusherCalls; - - Assert.Throws(() => list.TrySetDuplicates((DuplicateHandling)rawValue)); - - Assert.Equal(providerCalls, FakeHost.ProviderCalls); - Assert.Equal(pusherCalls, FakeHost.PusherCalls); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Negative_indices_are_rejected_before_touching_the_lua_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); - - Assert.Throws(() => list.TryGetItem(-1, out _)); - Assert.Throws(() => list.TrySetItem(-1, "x")); - Assert.Throws(() => list.TryDelete(-1)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Factory_returns_an_owned_list_that_destroys_deterministically() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateList(L); - AobStringListTestHost.InstallStringListFactory(L, handle); - - Assert.True(StringLists.TryCreate(out var created)); - var owned = Assert.IsType>(created); - Assert.Equal(handle, owned.Handle); - owned.Dispose(); - - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Factory_and_handle_methods_throw_while_the_runtime_is_detached() - { - LuaRuntime.Detach(); - StringList list = new(new CEObject(0x1234)); - - Assert.Throws(() => StringLists.TryCreate(out _)); - Assert.Throws(() => list.TryGetCount(out _)); - } + [Fact] + public void Handle_properties_and_zero_based_indexer_round_trip_without_stack_residue() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + StringList list = StringList.FromHandle(handle); + + Assert.Equal(list, StringList.FromHandle(handle)); + Assert.False(list.IsNull); + Assert.True(list.TryGetCount(out int count)); + Assert.Equal(2, count); + Assert.True(list.TryGetItem(0, out string? first)); + Assert.Equal("00401000", first); + Assert.True(list.TrySetItem(1, "00402000")); + Assert.True(list.TryGetItem(1, out string? replacement)); + Assert.Equal("00402000", replacement); + + Assert.True(list.TryGetSorted(out bool sorted)); + Assert.False(sorted); + Assert.True(list.TrySetSorted(true)); + Assert.True(list.TryGetSorted(out sorted)); + Assert.True(sorted); + Assert.True(list.TryGetCaseSensitive(out bool caseSensitive)); + Assert.True(caseSensitive); + Assert.True(list.TrySetCaseSensitive(false)); + Assert.True(list.TryGetCaseSensitive(out caseSensitive)); + Assert.False(caseSensitive); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Strings_methods_preserve_CE_zero_based_indices_and_update_the_list() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + StringList list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); + + Assert.True(list.TryAdd("00403000", out int addedIndex)); + Assert.Equal(2, addedIndex); + Assert.True(list.TryIndexOf("00403000", out int foundIndex)); + Assert.Equal(2, foundIndex); + Assert.True(list.TryIndexOf("missing", out int missingIndex)); + Assert.Equal(-1, missingIndex); + Assert.True(list.TryGetText(out string? text)); + Assert.Equal("00401000\n7FF6A1B2C3D4\n00403000", text); + + Assert.True(list.TryDelete(1)); + Assert.True(list.TryGetCount(out int count)); + Assert.Equal(2, count); + Assert.True(list.TrySetText("only line")); + Assert.True(list.TryGetItem(0, out string? onlyLine)); + Assert.Equal("only line", onlyLine); + Assert.True(list.TryClear()); + Assert.True(list.TryGetCount(out count)); + Assert.Equal(0, count); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData(DuplicateHandling.Ignore)] + [InlineData(DuplicateHandling.Accept)] + [InlineData(DuplicateHandling.Error)] + public void Duplicates_property_round_trips_each_numeric_CE_enum_value(DuplicateHandling expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + StringList list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); + + Assert.True(list.TryGetDuplicates(out DuplicateHandling duplicates)); + Assert.Equal(DuplicateHandling.Accept, duplicates); + Assert.True(list.TrySetDuplicates(expected)); + Assert.True(list.TryGetDuplicates(out duplicates)); + Assert.Equal(expected, duplicates); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData(-1)] + [InlineData(3)] + public void TrySetDuplicates_value_is_not_defined_throws_before_lua(int rawValue) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + StringList list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); + int providerCalls = FakeHost.ProviderCalls; + int pusherCalls = FakeHost.PusherCalls; + + Assert.Throws(() => list.TrySetDuplicates((DuplicateHandling) rawValue)); + + Assert.Equal(providerCalls, FakeHost.ProviderCalls); + Assert.Equal(pusherCalls, FakeHost.PusherCalls); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Negative_indices_are_rejected_before_touching_the_lua_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + StringList list = StringList.FromHandle(AobStringListTestHost.CreateList(L)); + + Assert.Throws(() => list.TryGetItem(-1, out _)); + Assert.Throws(() => list.TrySetItem(-1, "x")); + Assert.Throws(() => list.TryDelete(-1)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Factory_returns_an_owned_list_that_destroys_deterministically() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallStringListFactory(L, handle); + + Assert.True(StringLists.TryCreate(out Owned? created)); + Owned owned = Assert.IsType>(created); + Assert.Equal(handle, owned.Handle); + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Factory_and_handle_methods_throw_while_the_runtime_is_detached() + { + LuaRuntime.Detach(); + StringList list = new(new CEObject(0x1234)); + + Assert.Throws(() => StringLists.TryCreate(out _)); + Assert.Throws(() => list.TryGetCount(out _)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/ZeroAllocationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/ZeroAllocationTests.cs index 4b4d7fe1..00e1cb5e 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/ZeroAllocationTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/ZeroAllocationTests.cs @@ -11,115 +11,174 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; [Trait("Category", "NativeLua")] public sealed class ZeroAllocationTests { - [Fact] - public void A_typed_property_get_allocates_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var probe = FakeHost.CreateObject(scope.State, "Probe", "o.props.Count = 3; o.props.Result = '00400000'"); - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!probe.TryGetProperty("Count"u8, out var count) || count != 3) - Assert.Fail("Count"); - if (!probe.TryGetProperty("Result"u8, out var result) || result.Value != 0x400000) - Assert.Fail("Result"); - sink += count + (long)result.Value; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void A_typed_property_set_allocates_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var probe = FakeHost.CreateObject(scope.State, "Probe"); - var i = 0; - - AllocationGate.AssertZero(() => - { - if (!probe.TrySetProperty("Count"u8, ++i)) Assert.Fail("Count"); - if (!probe.TrySetProperty>("Name"u8, "renamed"u8)) Assert.Fail("Name"); - }); - - Assert.True(probe.TryGetProperty("Count"u8, out var count)); - Assert.Equal(i, count); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void A_typed_method_call_allocates_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var probe = FakeHost.CreateObject(scope.State, "Probe", "o.props.Count = 6"); - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!probe.TryCallMethod("getCount"u8, out var count) || count != 6) - Assert.Fail("getCount"); - if (!probe.TryCallMethod("getClassName"u8)) Assert.Fail("getClassName"); - sink += count; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void The_stack_level_primitives_allocate_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3; o.items = { 'a' }"); - long sink = 0; - - AllocationGate.AssertZero(() => - { - using LuaFrame frame = new(L); - if (!probe.TryGetProperty(L, "Count"u8).IsOk) Assert.Fail("get"); - if (!L.TryReadInteger(-1, out var count)) Assert.Fail("read count"); - L.PushInteger(count + 1); - if (!probe.TrySetProperty(L, "Count"u8).IsOk) Assert.Fail("set"); - if (!probe.TryGetIndex(L, 0).IsOk) Assert.Fail("index"); - L.PushInteger(1); - L.PushInteger(2); - if (!probe.TryCallMethod(L, "add"u8, 2, 1).IsOk) Assert.Fail("call"); - if (!L.TryReadInteger(-1, out var sum)) Assert.Fail("read sum"); - probe.Push(L); - if (!CEObject.TryRead(L, -1, out var back) || back != probe) Assert.Fail("read"); - sink += count + sum; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void A_failing_access_allocates_nothing_either() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var probe = FakeHost.CreateObject(scope.State, "Probe", "o.getters.Bad = function() error('x') end"); - - AllocationGate.AssertZero(() => - { - if (probe.TryGetProperty("Bad"u8, out _)) Assert.Fail("Bad"); - if (probe.TryCallMethod("raise"u8)) Assert.Fail("raise"); - if (probe.TryCallMethod("notAMethod"u8)) Assert.Fail("notAMethod"); - }); - - Assert.Equal(0, scope.State.Top); - } + [Fact] + public void A_typed_property_get_allocates_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + CEObject probe = FakeHost.CreateObject(scope.State, "Probe", "o.props.Count = 3; o.props.Result = '00400000'"); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!probe.TryGetProperty("Count"u8, out int count) || count != 3) + { + Assert.Fail("Count"); + } + + if (!probe.TryGetProperty("Result"u8, out Address result) || result.Value != 0x400000) + { + Assert.Fail("Result"); + } + + sink += count + (long) result.Value; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void A_typed_property_set_allocates_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + CEObject probe = FakeHost.CreateObject(scope.State, "Probe"); + int i = 0; + + AllocationGate.AssertZero(() => + { + if (!probe.TrySetProperty("Count"u8, ++i)) + { + Assert.Fail("Count"); + } + + if (!probe.TrySetProperty>("Name"u8, "renamed"u8)) + { + Assert.Fail("Name"); + } + }); + + Assert.True(probe.TryGetProperty("Count"u8, out int count)); + Assert.Equal(i, count); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void A_typed_method_call_allocates_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + CEObject probe = FakeHost.CreateObject(scope.State, "Probe", "o.props.Count = 6"); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!probe.TryCallMethod("getCount"u8, out int count) || count != 6) + { + Assert.Fail("getCount"); + } + + if (!probe.TryCallMethod("getClassName"u8)) + { + Assert.Fail("getClassName"); + } + + sink += count; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void The_stack_level_primitives_allocate_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject probe = FakeHost.CreateObject(L, "Probe", "o.props.Count = 3; o.items = { 'a' }"); + long sink = 0; + + AllocationGate.AssertZero(() => + { + using LuaFrame frame = new(L); + if (!probe.TryGetProperty(L, "Count"u8).IsOk) + { + Assert.Fail("get"); + } + + if (!L.TryReadInteger(-1, out long count)) + { + Assert.Fail("read count"); + } + + L.PushInteger(count + 1); + if (!probe.TrySetProperty(L, "Count"u8).IsOk) + { + Assert.Fail("set"); + } + + if (!probe.TryGetIndex(L, 0).IsOk) + { + Assert.Fail("index"); + } + + L.PushInteger(1); + L.PushInteger(2); + if (!probe.TryCallMethod(L, "add"u8, 2, 1).IsOk) + { + Assert.Fail("call"); + } + + if (!L.TryReadInteger(-1, out long sum)) + { + Assert.Fail("read sum"); + } + + probe.Push(L); + if (!CEObject.TryRead(L, -1, out CEObject back) || back != probe) + { + Assert.Fail("read"); + } + + sink += count + sum; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + [Fact] + public void A_failing_access_allocates_nothing_either() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + CEObject probe = FakeHost.CreateObject(scope.State, "Probe", "o.getters.Bad = function() error('x') end"); + + AllocationGate.AssertZero(() => + { + if (probe.TryGetProperty("Bad"u8, out _)) + { + Assert.Fail("Bad"); + } + + if (probe.TryCallMethod("raise"u8)) + { + Assert.Fail("raise"); + } + + if (probe.TryCallMethod("notAMethod"u8)) + { + Assert.Fail("notAMethod"); + } + }); + + Assert.Equal(0, scope.State.Top); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs index d4e8b001..7e0dc2a5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs @@ -1,4 +1,5 @@ using System.Text; + using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Processes; using CheatEngine.SDK.Engine.Runtime; @@ -14,282 +15,290 @@ namespace CheatEngine.SDK.Engine.Tests.Processes; [Trait("Category", "NativeLua")] public sealed class RuntimeProcessOperationsTests { - [Fact] - public void RuntimeHostOperations_known_globals_decode_copied_runtime_facts() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, """ - function getCEVersion() return 7.7 end - function getSystemArchitecture() return 1 end - function getABI() return 0 end - """u8); - - LuaOperationStatus versionStatus = RuntimeHostOperations.TryGetCheatEngineVersion(out var version); - LuaOperationStatus architectureStatus = RuntimeHostOperations.TryGetSystemArchitecture(out var architecture); - LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out var abi); - - Assert.True(versionStatus.IsSuccess); - Assert.Equal(7.7d, version); - Assert.True(architectureStatus.IsSuccess); - Assert.Equal(CheatEngineArchitecture.X64, architecture); - Assert.True(abiStatus.IsSuccess); - Assert.Equal(TargetAbi.Windows, abi); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void RuntimeHostOperations_unknown_discriminants_are_invalid_and_leave_decoded_values_unknown() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, """ - function getSystemArchitecture() return 99 end - function getABI() return 99 end - """u8); - - LuaOperationStatus architectureStatus = RuntimeHostOperations.TryGetSystemArchitecture(out var architecture); - LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out var abi); - - Assert.Equal(LuaOperationStatusKind.InvalidResult, architectureStatus.Kind); - Assert.Equal(CheatEngineArchitecture.Unknown, architecture); - Assert.Equal(LuaOperationStatusKind.InvalidResult, abiStatus.Kind); - Assert.Equal(TargetAbi.Unknown, abi); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void RuntimeHostOperations_missing_throwing_and_malformed_globals_keep_distinct_statuses() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, """ - function getCEVersion() error('fixture version failure') end - function getSystemArchitecture() return 'x64' end - function getABI() return nil end - """u8); - - LuaOperationStatus versionStatus = RuntimeHostOperations.TryGetCheatEngineVersion(out _); - LuaOperationStatus architectureStatus = RuntimeHostOperations.TryGetSystemArchitecture(out _); - LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out _); - - Assert.Equal(LuaOperationStatusKind.LuaFailure, versionStatus.Kind); - Assert.Equal(LuaStatus.RuntimeError, versionStatus.LuaStatus); - Assert.Equal(LuaOperationStatusKind.InvalidResult, architectureStatus.Kind); - Assert.Equal(LuaOperationStatusKind.NilResult, abiStatus.Kind); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void RuntimeHostOperations_absent_global_reports_unavailability_without_entering_a_call() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - - LuaOperationStatus status = RuntimeHostOperations.TryGetCheatEngineVersion(out var version); - - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, status.Kind); - Assert.Equal(0d, version); - Assert.Equal(0, lua.Top); - } - - [Theory] - [InlineData(true, 8)] - [InlineData(false, 4)] - public void ObserveCurrent_target_bitness_preserves_pid_pointer_size_and_does_not_guess_isa( - bool is64Bit, - int expectedPointerBytes) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - InstallCurrentProcessGlobals(lua, 42, is64Bit); - - ProcessOperationStatus status = RuntimeProcessOperations.ObserveCurrent(out var observation); - - Assert.True(status.IsSuccess); - Assert.Equal(new TargetProcessId(42), observation.Id); - Assert.Equal(expectedPointerBytes, observation.PointerSize.Bytes); - Assert.True(observation.PointerSize.IsKnown); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void ObserveCurrent_no_selected_target_skips_target_bitness_global() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, """ - function getOpenedProcessID() return 0 end - function targetIs64Bit() error('must not be called') end - """u8); - - ProcessOperationStatus status = RuntimeProcessOperations.ObserveCurrent(out var observation); - - Assert.Equal(ProcessOperationStatusKind.TargetNotAttached, status.Kind); - Assert.Equal(default, observation); - Assert.Equal(0, lua.Top); - } - - [Theory] - [InlineData("nil")] - [InlineData("-1")] - [InlineData("2147483648")] - [InlineData("'42'")] - public void ObserveCurrent_malformed_process_identifier_is_not_converted_to_target_absence(string luaResult) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + luaResult + " end")); - - ProcessOperationStatus status = RuntimeProcessOperations.ObserveCurrent(out var observation); - - Assert.Equal(ProcessOperationStatusKind.InvalidResult, status.Kind); - Assert.Equal(default, observation); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void ObserveCurrent_absent_and_throwing_process_globals_keep_distinct_statuses() - { - EngineTest.RequireNativeLua(); - using NativeLuaState missingState = new(); - using (HostScope missingScope = new(missingState)) - { - ProcessOperationStatus missing = RuntimeProcessOperations.ObserveCurrent(out _); - - Assert.Equal(ProcessOperationStatusKind.GlobalUnavailable, missing.Kind); - Assert.Equal(0, missingScope.State.Top); - } - - using NativeLuaState throwingState = new(); - using HostScope throwingScope = new(throwingState); - EngineTest.Run(throwingScope.State, "function getOpenedProcessID() error('fixture process failure') end"u8); - - ProcessOperationStatus throwing = RuntimeProcessOperations.ObserveCurrent(out _); - - Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, throwing.Kind); - Assert.Equal(LuaStatus.RuntimeError, throwing.LuaStatus); - Assert.Equal(0, throwingScope.State.Top); - } - - [Fact] - public void ObserveCurrent_malformed_target_bitness_is_rejected() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, """ - function getOpenedProcessID() return 42 end - function targetIs64Bit() return 'yes' end - """u8); - - ProcessOperationStatus status = RuntimeProcessOperations.ObserveCurrent(out _); - - Assert.Equal(ProcessOperationStatusKind.InvalidResult, status.Kind); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void SelectAndObserve_explicit_pid_requires_the_next_ce_observation_to_match() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var lua = scope.State; - EngineTest.Run(lua, """ - opened = 0 - function openProcess(id) opened = id end - function getOpenedProcessID() return opened end - function targetIs64Bit() return true end - """u8); - - ProcessOperationStatus status = RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42), out var observation); - - Assert.True(status.IsSuccess); - Assert.Equal(new TargetProcessId(42), observation.Id); - Assert.Equal(8, observation.PointerSize.Bytes); - Assert.Equal(0, lua.Top); - } - - [Fact] - public void SelectAndObserve_unconfirmed_or_failing_selection_keeps_outcomes_distinct() - { - EngineTest.RequireNativeLua(); - using NativeLuaState mismatchState = new(); - using (HostScope mismatchScope = new(mismatchState)) - { - var lua = mismatchScope.State; - EngineTest.Run(lua, """ - function openProcess(_) end - function getOpenedProcessID() return 77 end - function targetIs64Bit() error('must not be called after a mismatched PID') end - """u8); - - ProcessOperationStatus mismatch = RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42), out var observation); - - Assert.Equal(ProcessOperationStatusKind.SelectionNotConfirmed, mismatch.Kind); - Assert.Equal(default, observation); - Assert.Equal(0, lua.Top); - } - - using NativeLuaState failureState = new(); - using HostScope failureScope = new(failureState); - EngineTest.Run(failureScope.State, "function openProcess(_) error('fixture selection failure') end"u8); - - ProcessOperationStatus failure = RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42), out _); - - Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, failure.Kind); - Assert.Equal(LuaStatus.RuntimeError, failure.LuaStatus); - Assert.Equal(0, failureScope.State.Top); - } - - [Fact] - public void SelectAndObserve_rejects_a_default_process_identifier_before_entering_the_lua_runtime() - { - Assert.Throws(() => - RuntimeProcessOperations.SelectAndObserve(default, out _)); - } - - [Fact] - public void RuntimeProcessOperations_detached_runtime_retains_the_lifecycle_admission_failure() - { - LuaRuntime.Detach(); - - Assert.Throws(() => RuntimeProcessOperations.ObserveCurrent(out _)); - } - - [Fact] - public void RuntimeProcess_capability_identifiers_are_stable_and_distinct() - { - Assert.Equal("Process.Current", RuntimeCapabilityId.CurrentProcess.Value); - Assert.Equal("Process.Selection", RuntimeCapabilityId.ProcessSelection.Value); - Assert.NotEqual(RuntimeCapabilityId.CurrentProcess, RuntimeCapabilityId.ProcessSelection); - } - - private static void InstallCurrentProcessGlobals(LuaState state, int processId, bool is64Bit) - { - string source = "function getOpenedProcessID() return " + processId + " end\n" + - "function targetIs64Bit() return " + LuaBoolean(is64Bit) + " end"; - EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); - } - - private static string LuaBoolean(bool value) - { - return value ? "true" : "false"; - } + [Fact] + public void RuntimeHostOperations_known_globals_decode_copied_runtime_facts() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, """ + function getCEVersion() return 7.7 end + function getSystemArchitecture() return 1 end + function getABI() return 0 end + """u8); + + LuaOperationStatus versionStatus = RuntimeHostOperations.TryGetCheatEngineVersion(out double version); + LuaOperationStatus architectureStatus = + RuntimeHostOperations.TryGetSystemArchitecture(out CheatEngineArchitecture architecture); + LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out TargetAbi abi); + + Assert.True(versionStatus.IsSuccess); + Assert.Equal(7.7d, version); + Assert.True(architectureStatus.IsSuccess); + Assert.Equal(CheatEngineArchitecture.X64, architecture); + Assert.True(abiStatus.IsSuccess); + Assert.Equal(TargetAbi.Windows, abi); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void RuntimeHostOperations_unknown_discriminants_are_invalid_and_leave_decoded_values_unknown() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, """ + function getSystemArchitecture() return 99 end + function getABI() return 99 end + """u8); + + LuaOperationStatus architectureStatus = + RuntimeHostOperations.TryGetSystemArchitecture(out CheatEngineArchitecture architecture); + LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out TargetAbi abi); + + Assert.Equal(LuaOperationStatusKind.InvalidResult, architectureStatus.Kind); + Assert.Equal(CheatEngineArchitecture.Unknown, architecture); + Assert.Equal(LuaOperationStatusKind.InvalidResult, abiStatus.Kind); + Assert.Equal(TargetAbi.Unknown, abi); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void RuntimeHostOperations_missing_throwing_and_malformed_globals_keep_distinct_statuses() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, """ + function getCEVersion() error('fixture version failure') end + function getSystemArchitecture() return 'x64' end + function getABI() return nil end + """u8); + + LuaOperationStatus versionStatus = RuntimeHostOperations.TryGetCheatEngineVersion(out _); + LuaOperationStatus architectureStatus = RuntimeHostOperations.TryGetSystemArchitecture(out _); + LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out _); + + Assert.Equal(LuaOperationStatusKind.LuaFailure, versionStatus.Kind); + Assert.Equal(LuaStatus.RuntimeError, versionStatus.LuaStatus); + Assert.Equal(LuaOperationStatusKind.InvalidResult, architectureStatus.Kind); + Assert.Equal(LuaOperationStatusKind.NilResult, abiStatus.Kind); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void RuntimeHostOperations_absent_global_reports_unavailability_without_entering_a_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + + LuaOperationStatus status = RuntimeHostOperations.TryGetCheatEngineVersion(out double version); + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, status.Kind); + Assert.Equal(0d, version); + Assert.Equal(0, lua.Top); + } + + [Theory] + [InlineData(true, 8)] + [InlineData(false, 4)] + public void ObserveCurrent_target_bitness_preserves_pid_pointer_size_and_does_not_guess_isa( + bool is64Bit, + int expectedPointerBytes) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + InstallCurrentProcessGlobals(lua, 42, is64Bit); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveCurrent(out CurrentProcessObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(new TargetProcessId(42), observation.Id); + Assert.Equal(expectedPointerBytes, observation.PointerSize.Bytes); + Assert.True(observation.PointerSize.IsKnown); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void ObserveCurrent_no_selected_target_skips_target_bitness_global() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, """ + function getOpenedProcessID() return 0 end + function targetIs64Bit() error('must not be called') end + """u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveCurrent(out CurrentProcessObservation observation); + + Assert.Equal(ProcessOperationStatusKind.TargetNotAttached, status.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, lua.Top); + } + + [Theory] + [InlineData("nil")] + [InlineData("-1")] + [InlineData("2147483648")] + [InlineData("'42'")] + public void ObserveCurrent_malformed_process_identifier_is_not_converted_to_target_absence(string luaResult) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + luaResult + " end")); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveCurrent(out CurrentProcessObservation observation); + + Assert.Equal(ProcessOperationStatusKind.InvalidResult, status.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void ObserveCurrent_absent_and_throwing_process_globals_keep_distinct_statuses() + { + EngineTest.RequireNativeLua(); + using NativeLuaState missingState = new(); + using (HostScope missingScope = new(missingState)) + { + ProcessOperationStatus missing = RuntimeProcessOperations.ObserveCurrent(out _); + + Assert.Equal(ProcessOperationStatusKind.GlobalUnavailable, missing.Kind); + Assert.Equal(0, missingScope.State.Top); + } + + using NativeLuaState throwingState = new(); + using HostScope throwingScope = new(throwingState); + EngineTest.Run(throwingScope.State, "function getOpenedProcessID() error('fixture process failure') end"u8); + + ProcessOperationStatus throwing = RuntimeProcessOperations.ObserveCurrent(out _); + + Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, throwing.Kind); + Assert.Equal(LuaStatus.RuntimeError, throwing.LuaStatus); + Assert.Equal(0, throwingScope.State.Top); + } + + [Fact] + public void ObserveCurrent_malformed_target_bitness_is_rejected() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, """ + function getOpenedProcessID() return 42 end + function targetIs64Bit() return 'yes' end + """u8); + + ProcessOperationStatus status = RuntimeProcessOperations.ObserveCurrent(out _); + + Assert.Equal(ProcessOperationStatusKind.InvalidResult, status.Kind); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void SelectAndObserve_explicit_pid_requires_the_next_ce_observation_to_match() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + EngineTest.Run(lua, """ + opened = 0 + function openProcess(id) opened = id end + function getOpenedProcessID() return opened end + function targetIs64Bit() return true end + """u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42), + out CurrentProcessObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(new TargetProcessId(42), observation.Id); + Assert.Equal(8, observation.PointerSize.Bytes); + Assert.Equal(0, lua.Top); + } + + [Fact] + public void SelectAndObserve_unconfirmed_or_failing_selection_keeps_outcomes_distinct() + { + EngineTest.RequireNativeLua(); + using NativeLuaState mismatchState = new(); + using (HostScope mismatchScope = new(mismatchState)) + { + LuaState lua = mismatchScope.State; + EngineTest.Run(lua, """ + function openProcess(_) end + function getOpenedProcessID() return 77 end + function targetIs64Bit() error('must not be called after a mismatched PID') end + """u8); + + ProcessOperationStatus mismatch = RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42), + out CurrentProcessObservation observation); + + Assert.Equal(ProcessOperationStatusKind.SelectionNotConfirmed, mismatch.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, lua.Top); + } + + using NativeLuaState failureState = new(); + using HostScope failureScope = new(failureState); + EngineTest.Run(failureScope.State, "function openProcess(_) error('fixture selection failure') end"u8); + + ProcessOperationStatus failure = RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42), out _); + + Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, failure.Kind); + Assert.Equal(LuaStatus.RuntimeError, failure.LuaStatus); + Assert.Equal(0, failureScope.State.Top); + } + + [Fact] + public void SelectAndObserve_rejects_a_default_process_identifier_before_entering_the_lua_runtime() + { + Assert.Throws(() => + RuntimeProcessOperations.SelectAndObserve(default, out _)); + } + + [Fact] + public void RuntimeProcessOperations_detached_runtime_retains_the_lifecycle_admission_failure() + { + LuaRuntime.Detach(); + + Assert.Throws(() => RuntimeProcessOperations.ObserveCurrent(out _)); + } + + [Fact] + public void RuntimeProcess_capability_identifiers_are_stable_and_distinct() + { + Assert.Equal("Process.Current", RuntimeCapabilityId.CurrentProcess.Value); + Assert.Equal("Process.Selection", RuntimeCapabilityId.ProcessSelection.Value); + Assert.NotEqual(RuntimeCapabilityId.CurrentProcess, RuntimeCapabilityId.ProcessSelection); + } + + private static void InstallCurrentProcessGlobals(LuaState state, int processId, bool is64Bit) + { + string source = "function getOpenedProcessID() return " + processId + " end\n" + + "function targetIs64Bit() return " + LuaBoolean(is64Bit) + " end"; + EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); + } + + private static string LuaBoolean(bool value) + { + return value ? "true" : "false"; + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs index 32f82ec9..9b46b994 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs @@ -5,208 +5,210 @@ namespace CheatEngine.SDK.Engine.Tests.Runtime; /// Pure managed contract tests for explicit Cheat Engine runtime facts and optional capabilities. public sealed class RuntimeContractsTests { - [Fact] - public void CheatEngineVersion_exact_components_compare_and_format_without_precision_loss() - { - var version = new CheatEngineVersion(7, 7, 0, 10621); - var olderBuild = new CheatEngineVersion(7, 7, 0, 10620); - var laterRelease = new CheatEngineVersion(7, 7, 1, 0); - - Assert.Equal(CheatEngineVersion.Ce77010621, version); - Assert.Equal(7, version.Major); - Assert.Equal(7, version.Minor); - Assert.Equal(0, version.Release); - Assert.Equal(10621, version.Build); - Assert.Equal("7.7.0.10621", version.ToString()); - Assert.True(version > olderBuild); - Assert.True(version < laterRelease); - Assert.True(version >= CheatEngineVersion.Ce77010621); - Assert.True(version <= CheatEngineVersion.Ce77010621); - Assert.NotEqual(version, olderBuild); - Assert.Equal(version.GetHashCode(), CheatEngineVersion.Ce77010621.GetHashCode()); - } - - [Theory] - [InlineData(-1, 0, 0, 0)] - [InlineData(0, -1, 0, 0)] - [InlineData(0, 0, -1, 0)] - [InlineData(0, 0, 0, -1)] - public void CheatEngineVersion_negative_component_is_rejected(int major, int minor, int release, int build) - { - Assert.Throws(() => new CheatEngineVersion(major, minor, release, build)); - } - - [Theory] - [InlineData(0, CheatEngineArchitecture.X86)] - [InlineData(1, CheatEngineArchitecture.X64)] - [InlineData(2, CheatEngineArchitecture.Arm32)] - [InlineData(3, CheatEngineArchitecture.Arm64)] - public void TryDecodeSystemArchitecture_known_CE77_codes_decode(int code, CheatEngineArchitecture expected) - { - Assert.True(RuntimeInfo.TryDecodeSystemArchitecture(code, out var architecture)); - Assert.Equal(expected, architecture); - } - - [Theory] - [InlineData(-1)] - [InlineData(4)] - [InlineData(99)] - public void TryDecodeSystemArchitecture_unknown_code_is_rejected(int code) - { - Assert.False(RuntimeInfo.TryDecodeSystemArchitecture(code, out var architecture)); - Assert.Equal(CheatEngineArchitecture.Unknown, architecture); - } - - [Theory] - [InlineData(0, TargetAbi.Windows)] - [InlineData(1, TargetAbi.Unix)] - public void TryDecodeTargetAbi_known_CE77_codes_decode(int code, TargetAbi expected) - { - Assert.True(RuntimeInfo.TryDecodeTargetAbi(code, out var abi)); - Assert.Equal(expected, abi); - } - - [Theory] - [InlineData(-1)] - [InlineData(2)] - public void TryDecodeTargetAbi_unknown_code_is_rejected(int code) - { - Assert.False(RuntimeInfo.TryDecodeTargetAbi(code, out var abi)); - Assert.Equal(TargetAbi.Unknown, abi); - } - - [Fact] - public void PointerSize_known_widths_expose_bits_and_reject_other_widths() - { - Assert.Equal(4, PointerSize.Bit32.Bytes); - Assert.Equal(32, PointerSize.Bit32.Bits); - Assert.Equal(8, PointerSize.Bit64.Bytes); - Assert.Equal(64, PointerSize.Bit64.Bits); - Assert.True(PointerSize.Bit64.IsKnown); - Assert.False(PointerSize.Unknown.IsKnown); - Assert.Equal(0, PointerSize.Unknown.Bits); - Assert.Equal(PointerSize.Bit32, PointerSize.FromArchitecture(CheatEngineArchitecture.X86)); - Assert.Equal(PointerSize.Bit32, PointerSize.FromArchitecture(CheatEngineArchitecture.Arm32)); - Assert.Equal(PointerSize.Bit64, PointerSize.FromArchitecture(CheatEngineArchitecture.X64)); - Assert.Equal(PointerSize.Bit64, PointerSize.FromArchitecture(CheatEngineArchitecture.Arm64)); - Assert.Equal(PointerSize.Unknown, PointerSize.FromArchitecture(CheatEngineArchitecture.Unknown)); - Assert.Throws(() => new PointerSize(0)); - Assert.Throws(() => new PointerSize(2)); - Assert.Throws(() => new PointerSize(16)); - } - - [Fact] - public void PointerSize_little_endian_primitives_use_the_explicit_target_width_and_preserve_destinations_on_rejection() - { - ReadOnlySpan bytes = [0x98, 0xBA, 0xDC, 0xFE, 0xEF, 0xCD, 0xAB, 0x89]; - Span narrow = stackalloc byte[4]; - Span wide = stackalloc byte[8]; - Span untouched = stackalloc byte[3]; - untouched.Fill(0xA5); - - Assert.True(PointerSize.Bit32.TryReadLittleEndian(bytes[..4], out var x86Pointer)); - Assert.Equal(0xFEDCBA98UL, x86Pointer); - Assert.True(PointerSize.Bit64.TryReadLittleEndian(bytes, out var x64Pointer)); - Assert.Equal(0x89ABCDEF_FEDCBA98UL, x64Pointer); - Assert.False(PointerSize.Bit32.TryReadLittleEndian(bytes, out _)); - Assert.False(PointerSize.Bit64.TryReadLittleEndian(bytes[..7], out _)); - Assert.False(PointerSize.Unknown.TryReadLittleEndian(ReadOnlySpan.Empty, out _)); - - Assert.True(PointerSize.Bit32.TryWriteLittleEndian(0xFEDCBA98UL, narrow)); - Assert.True(narrow.SequenceEqual(bytes[..4])); - Assert.True(PointerSize.Bit64.TryWriteLittleEndian(0x89ABCDEF_FEDCBA98UL, wide)); - Assert.True(wide.SequenceEqual(bytes)); - Assert.False(PointerSize.Bit32.TryWriteLittleEndian(0x1_0000_0000UL, narrow)); - Assert.False(PointerSize.Bit32.TryWriteLittleEndian(0x1234UL, untouched)); - Assert.True(untouched.SequenceEqual(new byte[] { 0xA5, 0xA5, 0xA5 })); - } - - [Fact] - public void RuntimeCapabilities_creation_copies_entries_and_preserves_full_contract_metadata() - { - var contract = new RuntimeCapabilityContract( - CheatEngineVersion.Ce77010621, - RuntimeArchitectureScope.Target, - RuntimeArchitectureRequirement.X64, - RuntimeThreadRequirement.MainThread, - RuntimeOwnership.Borrowed, - RuntimeReturnSemantics.OptionalValue); - RuntimeCapabilityAvailability[] source = - [ - new(RuntimeCapabilityId.TargetArchitecture, RuntimeCapabilityAvailabilityState.Available, contract), - new(RuntimeCapabilityId.TargetAbi, RuntimeCapabilityAvailabilityState.Unavailable, - RuntimeCapabilityContract.Unknown), - ]; - - var capabilities = RuntimeCapabilities.Create(source); - source[0] = new RuntimeCapabilityAvailability(RuntimeCapabilityId.TargetArchitecture, - RuntimeCapabilityAvailabilityState.Unknown, RuntimeCapabilityContract.Unknown); - - Assert.Equal(2, capabilities.Count); - Assert.Equal(RuntimeCapabilityAvailabilityState.Available, - capabilities.GetState(RuntimeCapabilityId.TargetArchitecture)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, - capabilities.GetState(RuntimeCapabilityId.SystemArchitecture)); - Assert.True(capabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, out var availability)); - Assert.True(availability.IsAvailable); - Assert.True(availability.IsKnown); - Assert.Equal(contract, availability.Contract); - Assert.Equal(CheatEngineVersion.Ce77010621, availability.Contract.MinimumCheatEngineVersion); - Assert.Equal(RuntimeArchitectureScope.Target, availability.Contract.ArchitectureScope); - Assert.Equal(RuntimeArchitectureRequirement.X64, availability.Contract.ArchitectureRequirement); - Assert.Equal(RuntimeThreadRequirement.MainThread, availability.Contract.ThreadRequirement); - Assert.Equal(RuntimeOwnership.Borrowed, availability.Contract.Ownership); - Assert.Equal(RuntimeReturnSemantics.OptionalValue, availability.Contract.ReturnSemantics); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, capabilities.Entries[1].State); - Assert.False(capabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, out _)); - } - - [Fact] - public void RuntimeCapabilities_empty_or_duplicate_identifiers_are_rejected() - { - RuntimeCapabilityAvailability[] emptyIdentifier = - [new(default, RuntimeCapabilityAvailabilityState.Unknown, RuntimeCapabilityContract.Unknown)]; - RuntimeCapabilityAvailability[] duplicateIdentifier = - [ - new(RuntimeCapabilityId.TargetAbi, RuntimeCapabilityAvailabilityState.Available, - RuntimeCapabilityContract.Unknown), - new(RuntimeCapabilityId.TargetAbi, RuntimeCapabilityAvailabilityState.Unavailable, - RuntimeCapabilityContract.Unknown), - ]; - - Assert.Throws(() => RuntimeCapabilities.Create(emptyIdentifier)); - Assert.Throws(() => RuntimeCapabilities.Create(duplicateIdentifier)); - Assert.Throws(() => new RuntimeCapabilityId(" ")); - } - - [Fact] - public void RuntimeInfo_constructor_preserves_explicit_runtime_facts_without_normalization() - { - var capabilities = RuntimeCapabilities.Create( - [ - new RuntimeCapabilityAvailability(RuntimeCapabilityId.SystemArchitecture, - RuntimeCapabilityAvailabilityState.Available, RuntimeCapabilityContract.Unknown), - ]); - var info = new RuntimeInfo( - CheatEngineVersion.Ce77010621, - CheatEngineArchitecture.X64, - CheatEngineArchitecture.Unknown, - PointerSize.Unknown, - TargetAbi.Unknown, - capabilities); - - Assert.Equal(CheatEngineVersion.Ce77010621, info.Version); - Assert.Equal(CheatEngineArchitecture.X64, info.SystemArchitecture); - Assert.Equal(CheatEngineArchitecture.Unknown, info.TargetArchitecture); - Assert.Equal(PointerSize.Unknown, info.PointerSize); - Assert.Equal(TargetAbi.Unknown, info.TargetAbi); - Assert.Same(capabilities, info.Capabilities); - Assert.Throws(() => new RuntimeInfo( - CheatEngineVersion.Ce77010621, - CheatEngineArchitecture.X64, - CheatEngineArchitecture.Unknown, - PointerSize.Unknown, - TargetAbi.Unknown, - null!)); - } + [Fact] + public void CheatEngineVersion_exact_components_compare_and_format_without_precision_loss() + { + CheatEngineVersion version = new(7, 7, 0, 10621); + CheatEngineVersion olderBuild = new(7, 7, 0, 10620); + CheatEngineVersion laterRelease = new(7, 7, 1, 0); + + Assert.Equal(CheatEngineVersion.Ce77010621, version); + Assert.Equal(7, version.Major); + Assert.Equal(7, version.Minor); + Assert.Equal(0, version.Release); + Assert.Equal(10621, version.Build); + Assert.Equal("7.7.0.10621", version.ToString()); + Assert.True(version > olderBuild); + Assert.True(version < laterRelease); + Assert.True(version >= CheatEngineVersion.Ce77010621); + Assert.True(version <= CheatEngineVersion.Ce77010621); + Assert.NotEqual(version, olderBuild); + Assert.Equal(version.GetHashCode(), CheatEngineVersion.Ce77010621.GetHashCode()); + } + + [Theory] + [InlineData(-1, 0, 0, 0)] + [InlineData(0, -1, 0, 0)] + [InlineData(0, 0, -1, 0)] + [InlineData(0, 0, 0, -1)] + public void CheatEngineVersion_negative_component_is_rejected(int major, int minor, int release, int build) + { + Assert.Throws(() => new CheatEngineVersion(major, minor, release, build)); + } + + [Theory] + [InlineData(0, CheatEngineArchitecture.X86)] + [InlineData(1, CheatEngineArchitecture.X64)] + [InlineData(2, CheatEngineArchitecture.Arm32)] + [InlineData(3, CheatEngineArchitecture.Arm64)] + public void TryDecodeSystemArchitecture_known_CE77_codes_decode(int code, CheatEngineArchitecture expected) + { + Assert.True(RuntimeInfo.TryDecodeSystemArchitecture(code, out CheatEngineArchitecture architecture)); + Assert.Equal(expected, architecture); + } + + [Theory] + [InlineData(-1)] + [InlineData(4)] + [InlineData(99)] + public void TryDecodeSystemArchitecture_unknown_code_is_rejected(int code) + { + Assert.False(RuntimeInfo.TryDecodeSystemArchitecture(code, out CheatEngineArchitecture architecture)); + Assert.Equal(CheatEngineArchitecture.Unknown, architecture); + } + + [Theory] + [InlineData(0, TargetAbi.Windows)] + [InlineData(1, TargetAbi.Unix)] + public void TryDecodeTargetAbi_known_CE77_codes_decode(int code, TargetAbi expected) + { + Assert.True(RuntimeInfo.TryDecodeTargetAbi(code, out TargetAbi abi)); + Assert.Equal(expected, abi); + } + + [Theory] + [InlineData(-1)] + [InlineData(2)] + public void TryDecodeTargetAbi_unknown_code_is_rejected(int code) + { + Assert.False(RuntimeInfo.TryDecodeTargetAbi(code, out TargetAbi abi)); + Assert.Equal(TargetAbi.Unknown, abi); + } + + [Fact] + public void PointerSize_known_widths_expose_bits_and_reject_other_widths() + { + Assert.Equal(4, PointerSize.Bit32.Bytes); + Assert.Equal(32, PointerSize.Bit32.Bits); + Assert.Equal(8, PointerSize.Bit64.Bytes); + Assert.Equal(64, PointerSize.Bit64.Bits); + Assert.True(PointerSize.Bit64.IsKnown); + Assert.False(PointerSize.Unknown.IsKnown); + Assert.Equal(0, PointerSize.Unknown.Bits); + Assert.Equal(PointerSize.Bit32, PointerSize.FromArchitecture(CheatEngineArchitecture.X86)); + Assert.Equal(PointerSize.Bit32, PointerSize.FromArchitecture(CheatEngineArchitecture.Arm32)); + Assert.Equal(PointerSize.Bit64, PointerSize.FromArchitecture(CheatEngineArchitecture.X64)); + Assert.Equal(PointerSize.Bit64, PointerSize.FromArchitecture(CheatEngineArchitecture.Arm64)); + Assert.Equal(PointerSize.Unknown, PointerSize.FromArchitecture(CheatEngineArchitecture.Unknown)); + Assert.Throws(() => new PointerSize(0)); + Assert.Throws(() => new PointerSize(2)); + Assert.Throws(() => new PointerSize(16)); + } + + [Fact] + public void + PointerSize_little_endian_primitives_use_the_explicit_target_width_and_preserve_destinations_on_rejection() + { + ReadOnlySpan bytes = [0x98, 0xBA, 0xDC, 0xFE, 0xEF, 0xCD, 0xAB, 0x89]; + Span narrow = stackalloc byte[4]; + Span wide = stackalloc byte[8]; + Span untouched = stackalloc byte[3]; + untouched.Fill(0xA5); + + Assert.True(PointerSize.Bit32.TryReadLittleEndian(bytes[..4], out ulong x86Pointer)); + Assert.Equal(0xFEDCBA98UL, x86Pointer); + Assert.True(PointerSize.Bit64.TryReadLittleEndian(bytes, out ulong x64Pointer)); + Assert.Equal(0x89ABCDEF_FEDCBA98UL, x64Pointer); + Assert.False(PointerSize.Bit32.TryReadLittleEndian(bytes, out _)); + Assert.False(PointerSize.Bit64.TryReadLittleEndian(bytes[..7], out _)); + Assert.False(PointerSize.Unknown.TryReadLittleEndian(ReadOnlySpan.Empty, out _)); + + Assert.True(PointerSize.Bit32.TryWriteLittleEndian(0xFEDCBA98UL, narrow)); + Assert.True(narrow.SequenceEqual(bytes[..4])); + Assert.True(PointerSize.Bit64.TryWriteLittleEndian(0x89ABCDEF_FEDCBA98UL, wide)); + Assert.True(wide.SequenceEqual(bytes)); + Assert.False(PointerSize.Bit32.TryWriteLittleEndian(0x1_0000_0000UL, narrow)); + Assert.False(PointerSize.Bit32.TryWriteLittleEndian(0x1234UL, untouched)); + Assert.True(untouched.SequenceEqual(new byte[] { 0xA5, 0xA5, 0xA5 })); + } + + [Fact] + public void RuntimeCapabilities_creation_copies_entries_and_preserves_full_contract_metadata() + { + RuntimeCapabilityContract contract = new( + CheatEngineVersion.Ce77010621, + RuntimeArchitectureScope.Target, + RuntimeArchitectureRequirement.X64, + RuntimeThreadRequirement.MainThread, + RuntimeOwnership.Borrowed, + RuntimeReturnSemantics.OptionalValue); + RuntimeCapabilityAvailability[] source = + [ + new(RuntimeCapabilityId.TargetArchitecture, RuntimeCapabilityAvailabilityState.Available, contract), + new(RuntimeCapabilityId.TargetAbi, RuntimeCapabilityAvailabilityState.Unavailable, + RuntimeCapabilityContract.Unknown) + ]; + + RuntimeCapabilities capabilities = RuntimeCapabilities.Create(source); + source[0] = new RuntimeCapabilityAvailability(RuntimeCapabilityId.TargetArchitecture, + RuntimeCapabilityAvailabilityState.Unknown, RuntimeCapabilityContract.Unknown); + + Assert.Equal(2, capabilities.Count); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, + capabilities.GetState(RuntimeCapabilityId.TargetArchitecture)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, + capabilities.GetState(RuntimeCapabilityId.SystemArchitecture)); + Assert.True(capabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability availability)); + Assert.True(availability.IsAvailable); + Assert.True(availability.IsKnown); + Assert.Equal(contract, availability.Contract); + Assert.Equal(CheatEngineVersion.Ce77010621, availability.Contract.MinimumCheatEngineVersion); + Assert.Equal(RuntimeArchitectureScope.Target, availability.Contract.ArchitectureScope); + Assert.Equal(RuntimeArchitectureRequirement.X64, availability.Contract.ArchitectureRequirement); + Assert.Equal(RuntimeThreadRequirement.MainThread, availability.Contract.ThreadRequirement); + Assert.Equal(RuntimeOwnership.Borrowed, availability.Contract.Ownership); + Assert.Equal(RuntimeReturnSemantics.OptionalValue, availability.Contract.ReturnSemantics); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, capabilities.Entries[1].State); + Assert.False(capabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, out _)); + } + + [Fact] + public void RuntimeCapabilities_empty_or_duplicate_identifiers_are_rejected() + { + RuntimeCapabilityAvailability[] emptyIdentifier = + [new(default, RuntimeCapabilityAvailabilityState.Unknown, RuntimeCapabilityContract.Unknown)]; + RuntimeCapabilityAvailability[] duplicateIdentifier = + [ + new(RuntimeCapabilityId.TargetAbi, RuntimeCapabilityAvailabilityState.Available, + RuntimeCapabilityContract.Unknown), + new(RuntimeCapabilityId.TargetAbi, RuntimeCapabilityAvailabilityState.Unavailable, + RuntimeCapabilityContract.Unknown) + ]; + + Assert.Throws(() => RuntimeCapabilities.Create(emptyIdentifier)); + Assert.Throws(() => RuntimeCapabilities.Create(duplicateIdentifier)); + Assert.Throws(() => new RuntimeCapabilityId(" ")); + } + + [Fact] + public void RuntimeInfo_constructor_preserves_explicit_runtime_facts_without_normalization() + { + RuntimeCapabilities capabilities = RuntimeCapabilities.Create( + [ + new RuntimeCapabilityAvailability(RuntimeCapabilityId.SystemArchitecture, + RuntimeCapabilityAvailabilityState.Available, RuntimeCapabilityContract.Unknown) + ]); + RuntimeInfo info = new( + CheatEngineVersion.Ce77010621, + CheatEngineArchitecture.X64, + CheatEngineArchitecture.Unknown, + PointerSize.Unknown, + TargetAbi.Unknown, + capabilities); + + Assert.Equal(CheatEngineVersion.Ce77010621, info.Version); + Assert.Equal(CheatEngineArchitecture.X64, info.SystemArchitecture); + Assert.Equal(CheatEngineArchitecture.Unknown, info.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, info.PointerSize); + Assert.Equal(TargetAbi.Unknown, info.TargetAbi); + Assert.Same(capabilities, info.Capabilities); + Assert.Throws(() => new RuntimeInfo( + CheatEngineVersion.Ce77010621, + CheatEngineArchitecture.X64, + CheatEngineArchitecture.Unknown, + PointerSize.Unknown, + TargetAbi.Unknown, + null!)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanOptionsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanOptionsTests.cs index 1ebd57e8..70f0abc5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanOptionsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanOptionsTests.cs @@ -6,40 +6,40 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; /// Pure contract tests for the optional CE AOBScan argument model. public sealed class AobScanOptionsTests { - [Fact] - public void Default_has_no_protection_or_alignment_arguments() - { - var options = AobScanOptions.Default; + [Fact] + public void Default_has_no_protection_or_alignment_arguments() + { + AobScanOptions options = AobScanOptions.Default; - Assert.Null(options.ProtectionFlags); - Assert.Equal(FastScanMethod.NotAligned, options.AlignmentMethod); - Assert.Null(options.AlignmentParameter); - Assert.Equal(options, new AobScanOptions()); - } + Assert.Null(options.ProtectionFlags); + Assert.Equal(FastScanMethod.NotAligned, options.AlignmentMethod); + Assert.Null(options.AlignmentParameter); + Assert.Equal(options, new AobScanOptions()); + } - [Fact] - public void Constructor_preserves_the_exact_CE_argument_values() - { - AobScanOptions options = new("+X-C-W", FastScanMethod.LastDigits, "F0"); + [Fact] + public void Constructor_preserves_the_exact_CE_argument_values() + { + AobScanOptions options = new("+X-C-W", FastScanMethod.LastDigits, "F0"); - Assert.Equal("+X-C-W", options.ProtectionFlags); - Assert.Equal(FastScanMethod.LastDigits, options.AlignmentMethod); - Assert.Equal("F0", options.AlignmentParameter); - Assert.Equal(options, new AobScanOptions("+X-C-W", FastScanMethod.LastDigits, "F0")); - Assert.NotEqual(options, new AobScanOptions("+X-C-W", FastScanMethod.Aligned, "F0")); - } + Assert.Equal("+X-C-W", options.ProtectionFlags); + Assert.Equal(FastScanMethod.LastDigits, options.AlignmentMethod); + Assert.Equal("F0", options.AlignmentParameter); + Assert.Equal(options, new AobScanOptions("+X-C-W", FastScanMethod.LastDigits, "F0")); + Assert.NotEqual(options, new AobScanOptions("+X-C-W", FastScanMethod.Aligned, "F0")); + } - [Fact] - public void Constructor_rejects_an_unknown_alignment_method_before_a_scan() - { - Assert.Throws(() => new AobScanOptions(protectionFlags: null, alignmentMethod: (FastScanMethod)3, alignmentParameter: null)); - } + [Fact] + public void Constructor_rejects_an_unknown_alignment_method_before_a_scan() + { + Assert.Throws(() => new AobScanOptions(null, (FastScanMethod) 3, null)); + } - [Fact] - public void Constructor_requires_a_nonempty_parameter_for_nondefault_alignment() - { - Assert.Throws(() => new AobScanOptions(protectionFlags: null, alignmentMethod: FastScanMethod.Aligned, alignmentParameter: null)); - Assert.Throws(() => new AobScanOptions(protectionFlags: null, alignmentMethod: FastScanMethod.LastDigits, alignmentParameter: string.Empty)); - Assert.Throws(() => new AobScanOptions(protectionFlags: null, alignmentMethod: FastScanMethod.NotAligned, alignmentParameter: "10")); - } + [Fact] + public void Constructor_requires_a_nonempty_parameter_for_nondefault_alignment() + { + Assert.Throws(() => new AobScanOptions(null, FastScanMethod.Aligned, null)); + Assert.Throws(() => new AobScanOptions(null, FastScanMethod.LastDigits, string.Empty)); + Assert.Throws(() => new AobScanOptions(null, FastScanMethod.NotAligned, "10")); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs index ff83f109..213ce1f9 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs @@ -2,7 +2,9 @@ using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Aob; using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; @@ -11,346 +13,348 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; [Trait("Category", "NativeLua")] public sealed class AobScannerTests { - [Fact] - public void TryScan_default_options_passes_only_the_pattern_and_returns_a_caller_owned_list() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateList(L); - AobStringListTestHost.InstallAobScan(L, handle); - - Assert.True(AobScanner.TryScan("48 8B ?? 89", out var results)); - var owned = Assert.IsType>(results); - Assert.True(owned.Value.TryGetCount(out var count)); - Assert.Equal(2, count); - Assert.True(owned.Value.TryGetItem(1, out var second)); - Assert.Equal("7FF6A1B2C3D4", second); - Assert.Equal(0, L.Top); - - EngineTest.Run(L, "return aob_argument_count, aob_pattern, aob_protection"u8, 3); - Assert.Equal(1, EngineTest.ReadInteger(L, -3)); - Assert.Equal("48 8B ?? 89", EngineTest.ReadString(L, -2)); - Assert.True(L.IsNil(-1)); - L.SetTop(0); - - owned.Dispose(); - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryScan_alignment_preserves_a_nil_protection_slot_and_all_four_CE_positions() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateList(L); - AobStringListTestHost.InstallAobScan(L, handle); - AobScanOptions options = new(protectionFlags: null, alignmentMethod: FastScanMethod.Aligned, alignmentParameter: "16"); - - Assert.True(AobScanner.TryScan("90 90", options, out var results)); - var owned = Assert.IsType>(results); - EngineTest.Run(L, "return aob_argument_count, aob_protection, aob_alignment, aob_alignment_parameter"u8, 4); - Assert.Equal(4, EngineTest.ReadInteger(L, -4)); - Assert.True(L.IsNil(-3)); - Assert.Equal((long)FastScanMethod.Aligned, EngineTest.ReadInteger(L, -2)); - Assert.Equal("16", EngineTest.ReadString(L, -1)); - L.SetTop(0); - - owned.Dispose(); - Assert.True(FakeHost.IsDestroyed(L, handle)); - } - - [Fact] - public void TryScan_protection_only_passes_two_CE_positions_without_synthesizing_alignment_values() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateList(L); - AobStringListTestHost.InstallAobScan(L, handle); - AobScanOptions options = new(protectionFlags: "+X-C-W", alignmentMethod: FastScanMethod.NotAligned, alignmentParameter: null); - - Assert.True(AobScanner.TryScan("CC", options, out var results)); - var owned = Assert.IsType>(results); - EngineTest.Run(L, "return aob_argument_count, aob_protection, aob_alignment"u8, 3); - Assert.Equal(2, EngineTest.ReadInteger(L, -3)); - Assert.Equal("+X-C-W", EngineTest.ReadString(L, -2)); - Assert.True(L.IsNil(-1)); - L.SetTop(0); - - owned.Dispose(); - Assert.True(FakeHost.IsDestroyed(L, handle)); - } - - [Fact] - public void TryScan_nil_or_raising_result_returns_false_and_restores_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); - - Assert.False(AobScanner.TryScan("nil-result", out var nilResults)); - Assert.Null(nilResults); - Assert.Equal(0, L.Top); - - Assert.False(AobScanner.TryScan("raise", out var raisedResults)); - Assert.Null(raisedResults); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryScanDetailed_distinguishes_nil_lua_failure_and_invalid_non_nil_results() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); - var top = L.Top; - - var status = AobScanner.TryScanDetailed("nil-result", out var nilResults); - - Assert.Equal(AobScanStatus.NoResult, status); - Assert.Null(nilResults); - Assert.Equal(top, L.Top); - - status = AobScanner.TryScanDetailed("raise", out var raisedResults); - - Assert.Equal(AobScanStatus.LuaFailure, status); - Assert.Null(raisedResults); - Assert.Equal(top, L.Top); - - status = AobScanner.TryScanDetailed("invalid-result", out var invalidResults); - - Assert.Equal(AobScanStatus.InvalidResult, status); - Assert.Null(invalidResults); - Assert.Equal(top, L.Top); - } - - [Fact] - public void TryScanDetailed_empty_string_list_is_a_successful_caller_owned_result() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateEmptyList(L); - AobStringListTestHost.InstallAobScan(L, handle); - var top = L.Top; - - var status = AobScanner.TryScanDetailed("48 8B", out var results); - - Assert.Equal(AobScanStatus.Success, status); - var owned = Assert.IsType>(results); - Assert.True(owned.Value.TryGetCount(out var count)); - Assert.Equal(0, count); - Assert.Equal(top, L.Top); - - owned.Dispose(); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(top, L.Top); - } - - [Fact] - public void TryScanDetailed_unavailable_global_does_not_enter_lua() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var top = L.Top; - - var status = AobScanner.TryScanDetailed("48 8B", out var results); - - Assert.Equal(AobScanStatus.GlobalUnavailable, status); - Assert.Null(results); - Assert.Equal(top, L.Top); - } - - [Fact] - public void TryScanOutcome_reports_matches_and_keeps_the_sole_owner_alive_until_the_caller_copies_and_disposes() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateList(L); - AobStringListTestHost.InstallAobScan(L, handle); - var top = L.Top; - - var outcome = AobScanner.TryScanOutcome("48 8B ?? 89", out var results); - - Assert.Equal(AobScanOutcomeKind.Matches, outcome.Kind); - Assert.True(outcome.IsSuccess); - Assert.True(outcome.HasResultCount); - Assert.Equal(2, outcome.ResultCount); - Assert.Equal(CheatEngine.SDK.Lua.Calls.LuaStatus.Ok, outcome.LuaStatus); - var owned = Assert.IsType>(results); - Assert.True(owned.Value.TryGetItem(0, out var first)); - Assert.True(owned.Value.TryGetItem(1, out var second)); - Assert.Equal("00401000", first); - Assert.Equal("7FF6A1B2C3D4", second); - Assert.Equal(top, L.Top); - - owned.Dispose(); - - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal("00401000", first); - Assert.Equal("7FF6A1B2C3D4", second); - Assert.Equal(top, L.Top); - } - - [Fact] - public void TryScanOutcome_classifies_only_a_valid_empty_list_as_no_matches() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateEmptyList(L); - AobStringListTestHost.InstallAobScan(L, handle); - var top = L.Top; - - var outcome = AobScanner.TryScanOutcome("48 8B", out var results); - - Assert.Equal(AobScanOutcomeKind.NoMatches, outcome.Kind); - Assert.True(outcome.IsSuccess); - Assert.True(outcome.HasResultCount); - Assert.Equal(0, outcome.ResultCount); - var owned = Assert.IsType>(results); - Assert.Equal(top, L.Top); - - owned.Dispose(); - - Assert.True(owned.IsDisposed); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(top, L.Top); - } - - [Fact] - public void TryScanOutcome_keeps_nil_lua_failure_and_invalid_scalar_as_distinct_non_match_outcomes() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); - var top = L.Top; - - var nilOutcome = AobScanner.TryScanOutcome("nil-result", out var nilResults); - var luaOutcome = AobScanner.TryScanOutcome("raise", out var luaResults); - var invalidOutcome = AobScanner.TryScanOutcome("invalid-result", out var invalidResults); - - Assert.Equal(AobScanOutcomeKind.NoResult, nilOutcome.Kind); - Assert.False(nilOutcome.IsSuccess); - Assert.False(nilOutcome.HasResultCount); - Assert.Null(nilResults); - Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, luaOutcome.Kind); - Assert.Equal(CheatEngine.SDK.Lua.Calls.LuaStatus.RuntimeError, luaOutcome.LuaStatus); - Assert.False(luaOutcome.IsSuccess); - Assert.Null(luaResults); - Assert.Equal(AobScanOutcomeKind.InvalidResult, invalidOutcome.Kind); - Assert.False(invalidOutcome.IsSuccess); - Assert.Null(invalidResults); - Assert.Equal(top, L.Top); - } - - [Fact] - public void TryScanOutcome_distinguishes_missing_global_protected_lookup_failure_and_malformed_userdata() - { - EngineTest.RequireNativeLua(); - using NativeLuaState missingState = new(); - using HostScope missingScope = new(missingState); - var missing = missingScope.State; - var missingTop = missing.Top; - - var missingOutcome = AobScanner.TryScanOutcome("48 8B", out var missingResults); - - Assert.Equal(AobScanOutcomeKind.GlobalUnavailable, missingOutcome.Kind); - Assert.Null(missingResults); - Assert.Equal(missingTop, missing.Top); - - using NativeLuaState lookupState = new(); - using HostScope lookupScope = new(lookupState); - var lookup = lookupScope.State; - EngineTest.Run(lookup, """ - setmetatable(_G, { - __index = function(_, name) - if name == 'AOBScan' then error('AOBScan lookup failed') end - end - }) - """u8); - var lookupTop = lookup.Top; - - var lookupOutcome = AobScanner.TryScanOutcome("48 8B", out var lookupResults); - - Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, lookupOutcome.Kind); - Assert.Equal(CheatEngine.SDK.Lua.Calls.LuaStatus.RuntimeError, lookupOutcome.LuaStatus); - Assert.Null(lookupResults); - Assert.Equal(lookupTop, lookup.Top); - - using NativeLuaState malformedState = new(); - using HostScope malformedScope = new(malformedState); - var malformed = malformedScope.State; - AobStringListTestHost.InstallAobScan(malformed, AobStringListTestHost.CreateList(malformed)); - _ = malformed.NewUserdata(1); - Assert.True(malformed.TrySetGlobal("aob_malformed"u8).IsOk); - var malformedTop = malformed.Top; - - var malformedOutcome = AobScanner.TryScanOutcome("malformed-result", out var malformedResults); - - Assert.Equal(AobScanOutcomeKind.InvalidResult, malformedOutcome.Kind); - Assert.Null(malformedResults); - Assert.Equal(malformedTop, malformed.Top); - } - - [Fact] - public void TryScanOutcome_releases_a_valid_host_object_when_its_count_is_unreadable() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var handle = AobStringListTestHost.CreateInvalidCountList(L); - AobStringListTestHost.InstallAobScan(L, handle); - var top = L.Top; - - var outcome = AobScanner.TryScanOutcome("48 8B", out var results); - - Assert.Equal(AobScanOutcomeKind.ResultListCountUnavailable, outcome.Kind); - Assert.False(outcome.IsSuccess); - Assert.False(outcome.HasResultCount); - Assert.Null(results); - Assert.True(FakeHost.IsDestroyed(L, handle)); - Assert.Equal(top, L.Top); - } - - [Fact] - public void AobScanOutcome_default_is_unknown_and_never_reports_a_successful_match() - { - var outcome = default(AobScanOutcome); - - Assert.Equal(AobScanOutcomeKind.Unknown, outcome.Kind); - Assert.False(outcome.HasResultCount); - Assert.False(outcome.IsSuccess); - Assert.Equal(0, outcome.ResultCount); - Assert.Equal(CheatEngine.SDK.Lua.Calls.LuaStatus.Ok, outcome.LuaStatus); - } - - [Fact] - public void TryScan_while_detached_throws_without_attempting_lua_access() - { - LuaRuntime.Detach(); - - Assert.Throws(() => AobScanner.TryScan("90", out _)); - } + [Fact] + public void TryScan_default_options_passes_only_the_pattern_and_returns_a_caller_owned_list() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallAobScan(L, handle); + + Assert.True(AobScanner.TryScan("48 8B ?? 89", out Owned? results)); + Owned owned = Assert.IsType>(results); + Assert.True(owned.Value.TryGetCount(out int count)); + Assert.Equal(2, count); + Assert.True(owned.Value.TryGetItem(1, out string? second)); + Assert.Equal("7FF6A1B2C3D4", second); + Assert.Equal(0, L.Top); + + EngineTest.Run(L, "return aob_argument_count, aob_pattern, aob_protection"u8, 3); + Assert.Equal(1, EngineTest.ReadInteger(L, -3)); + Assert.Equal("48 8B ?? 89", EngineTest.ReadString(L, -2)); + Assert.True(L.IsNil(-1)); + L.SetTop(0); + + owned.Dispose(); + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryScan_alignment_preserves_a_nil_protection_slot_and_all_four_CE_positions() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallAobScan(L, handle); + AobScanOptions options = new(null, FastScanMethod.Aligned, "16"); + + Assert.True(AobScanner.TryScan("90 90", options, out Owned? results)); + Owned owned = Assert.IsType>(results); + EngineTest.Run(L, "return aob_argument_count, aob_protection, aob_alignment, aob_alignment_parameter"u8, 4); + Assert.Equal(4, EngineTest.ReadInteger(L, -4)); + Assert.True(L.IsNil(-3)); + Assert.Equal((long) FastScanMethod.Aligned, EngineTest.ReadInteger(L, -2)); + Assert.Equal("16", EngineTest.ReadString(L, -1)); + L.SetTop(0); + + owned.Dispose(); + Assert.True(FakeHost.IsDestroyed(L, handle)); + } + + [Fact] + public void TryScan_protection_only_passes_two_CE_positions_without_synthesizing_alignment_values() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallAobScan(L, handle); + AobScanOptions options = new("+X-C-W", FastScanMethod.NotAligned, null); + + Assert.True(AobScanner.TryScan("CC", options, out Owned? results)); + Owned owned = Assert.IsType>(results); + EngineTest.Run(L, "return aob_argument_count, aob_protection, aob_alignment"u8, 3); + Assert.Equal(2, EngineTest.ReadInteger(L, -3)); + Assert.Equal("+X-C-W", EngineTest.ReadString(L, -2)); + Assert.True(L.IsNil(-1)); + L.SetTop(0); + + owned.Dispose(); + Assert.True(FakeHost.IsDestroyed(L, handle)); + } + + [Fact] + public void TryScan_nil_or_raising_result_returns_false_and_restores_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + + Assert.False(AobScanner.TryScan("nil-result", out Owned? nilResults)); + Assert.Null(nilResults); + Assert.Equal(0, L.Top); + + Assert.False(AobScanner.TryScan("raise", out Owned? raisedResults)); + Assert.Null(raisedResults); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryScanDetailed_distinguishes_nil_lua_failure_and_invalid_non_nil_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + int top = L.Top; + + AobScanStatus status = AobScanner.TryScanDetailed("nil-result", out Owned? nilResults); + + Assert.Equal(AobScanStatus.NoResult, status); + Assert.Null(nilResults); + Assert.Equal(top, L.Top); + + status = AobScanner.TryScanDetailed("raise", out Owned? raisedResults); + + Assert.Equal(AobScanStatus.LuaFailure, status); + Assert.Null(raisedResults); + Assert.Equal(top, L.Top); + + status = AobScanner.TryScanDetailed("invalid-result", out Owned? invalidResults); + + Assert.Equal(AobScanStatus.InvalidResult, status); + Assert.Null(invalidResults); + Assert.Equal(top, L.Top); + } + + [Fact] + public void TryScanDetailed_empty_string_list_is_a_successful_caller_owned_result() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateEmptyList(L); + AobStringListTestHost.InstallAobScan(L, handle); + int top = L.Top; + + AobScanStatus status = AobScanner.TryScanDetailed("48 8B", out Owned? results); + + Assert.Equal(AobScanStatus.Success, status); + Owned owned = Assert.IsType>(results); + Assert.True(owned.Value.TryGetCount(out int count)); + Assert.Equal(0, count); + Assert.Equal(top, L.Top); + + owned.Dispose(); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(top, L.Top); + } + + [Fact] + public void TryScanDetailed_unavailable_global_does_not_enter_lua() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + int top = L.Top; + + AobScanStatus status = AobScanner.TryScanDetailed("48 8B", out Owned? results); + + Assert.Equal(AobScanStatus.GlobalUnavailable, status); + Assert.Null(results); + Assert.Equal(top, L.Top); + } + + [Fact] + public void TryScanOutcome_reports_matches_and_keeps_the_sole_owner_alive_until_the_caller_copies_and_disposes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallAobScan(L, handle); + int top = L.Top; + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B ?? 89", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.Matches, outcome.Kind); + Assert.True(outcome.IsSuccess); + Assert.True(outcome.HasResultCount); + Assert.Equal(2, outcome.ResultCount); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Owned owned = Assert.IsType>(results); + Assert.True(owned.Value.TryGetItem(0, out string? first)); + Assert.True(owned.Value.TryGetItem(1, out string? second)); + Assert.Equal("00401000", first); + Assert.Equal("7FF6A1B2C3D4", second); + Assert.Equal(top, L.Top); + + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal("00401000", first); + Assert.Equal("7FF6A1B2C3D4", second); + Assert.Equal(top, L.Top); + } + + [Fact] + public void TryScanOutcome_classifies_only_a_valid_empty_list_as_no_matches() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateEmptyList(L); + AobStringListTestHost.InstallAobScan(L, handle); + int top = L.Top; + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.NoMatches, outcome.Kind); + Assert.True(outcome.IsSuccess); + Assert.True(outcome.HasResultCount); + Assert.Equal(0, outcome.ResultCount); + Owned owned = Assert.IsType>(results); + Assert.Equal(top, L.Top); + + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(top, L.Top); + } + + [Fact] + public void TryScanOutcome_keeps_nil_lua_failure_and_invalid_scalar_as_distinct_non_match_outcomes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + int top = L.Top; + + AobScanOutcome nilOutcome = AobScanner.TryScanOutcome("nil-result", out Owned? nilResults); + AobScanOutcome luaOutcome = AobScanner.TryScanOutcome("raise", out Owned? luaResults); + AobScanOutcome invalidOutcome = + AobScanner.TryScanOutcome("invalid-result", out Owned? invalidResults); + + Assert.Equal(AobScanOutcomeKind.NoResult, nilOutcome.Kind); + Assert.False(nilOutcome.IsSuccess); + Assert.False(nilOutcome.HasResultCount); + Assert.Null(nilResults); + Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, luaOutcome.Kind); + Assert.Equal(LuaStatus.RuntimeError, luaOutcome.LuaStatus); + Assert.False(luaOutcome.IsSuccess); + Assert.Null(luaResults); + Assert.Equal(AobScanOutcomeKind.InvalidResult, invalidOutcome.Kind); + Assert.False(invalidOutcome.IsSuccess); + Assert.Null(invalidResults); + Assert.Equal(top, L.Top); + } + + [Fact] + public void TryScanOutcome_distinguishes_missing_global_protected_lookup_failure_and_malformed_userdata() + { + EngineTest.RequireNativeLua(); + using NativeLuaState missingState = new(); + using HostScope missingScope = new(missingState); + LuaState missing = missingScope.State; + int missingTop = missing.Top; + + AobScanOutcome missingOutcome = AobScanner.TryScanOutcome("48 8B", out Owned? missingResults); + + Assert.Equal(AobScanOutcomeKind.GlobalUnavailable, missingOutcome.Kind); + Assert.Null(missingResults); + Assert.Equal(missingTop, missing.Top); + + using NativeLuaState lookupState = new(); + using HostScope lookupScope = new(lookupState); + LuaState lookup = lookupScope.State; + EngineTest.Run(lookup, """ + setmetatable(_G, { + __index = function(_, name) + if name == 'AOBScan' then error('AOBScan lookup failed') end + end + }) + """u8); + int lookupTop = lookup.Top; + + AobScanOutcome lookupOutcome = AobScanner.TryScanOutcome("48 8B", out Owned? lookupResults); + + Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, lookupOutcome.Kind); + Assert.Equal(LuaStatus.RuntimeError, lookupOutcome.LuaStatus); + Assert.Null(lookupResults); + Assert.Equal(lookupTop, lookup.Top); + + using NativeLuaState malformedState = new(); + using HostScope malformedScope = new(malformedState); + LuaState malformed = malformedScope.State; + AobStringListTestHost.InstallAobScan(malformed, AobStringListTestHost.CreateList(malformed)); + _ = malformed.NewUserdata(1); + Assert.True(malformed.TrySetGlobal("aob_malformed"u8).IsOk); + int malformedTop = malformed.Top; + + AobScanOutcome malformedOutcome = + AobScanner.TryScanOutcome("malformed-result", out Owned? malformedResults); + + Assert.Equal(AobScanOutcomeKind.InvalidResult, malformedOutcome.Kind); + Assert.Null(malformedResults); + Assert.Equal(malformedTop, malformed.Top); + } + + [Fact] + public void TryScanOutcome_releases_a_valid_host_object_when_its_count_is_unreadable() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateInvalidCountList(L); + AobStringListTestHost.InstallAobScan(L, handle); + int top = L.Top; + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.ResultListCountUnavailable, outcome.Kind); + Assert.False(outcome.IsSuccess); + Assert.False(outcome.HasResultCount); + Assert.Null(results); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(top, L.Top); + } + + [Fact] + public void AobScanOutcome_default_is_unknown_and_never_reports_a_successful_match() + { + AobScanOutcome outcome = default; + + Assert.Equal(AobScanOutcomeKind.Unknown, outcome.Kind); + Assert.False(outcome.HasResultCount); + Assert.False(outcome.IsSuccess); + Assert.Equal(0, outcome.ResultCount); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + } + + [Fact] + public void TryScan_while_detached_throws_without_attempting_lua_access() + { + LuaRuntime.Detach(); + + Assert.Throws(() => AobScanner.TryScan("90", out _)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs index 505e5768..9fca570c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs @@ -7,101 +7,101 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; /// CE 7.7-shaped Lua stand-ins for the AOB/StringList vertical slice, over the normal fake host userdata. internal static class AobStringListTestHost { - /// Creates a fresh host object with the CE StringList members this slice invokes. - public static CEObject CreateList(LuaState state) - { - return FakeHost.CreateObject(state, "Probe", """ - o.props.Count = 2 - o.props.Sorted = false - o.props.Duplicates = 1 - o.setters.Duplicates = function(o, value) - o.props.Duplicates = math.tointeger(value) or 0 - end - o.props.CaseSensitive = true - o.items = { '00401000', '7FF6A1B2C3D4' } - o.getters.add = function(o) - return function(value) - o.items[#o.items + 1] = value - o.props.Count = #o.items - return #o.items - 1 - end - end - o.getters.clear = function(o) - return function() - o.items = {} - o.props.Count = 0 - end - end - o.getters.delete = function(o) - return function(index) - table.remove(o.items, index + 1) - o.props.Count = #o.items - end - end - o.getters.getText = function(o) - return function() return table.concat(o.items, '\n') end - end - o.getters.setText = function(o) - return function(value) - o.items = { value } - o.props.Count = 1 - end - end - o.getters.indexOf = function(o) - return function(value) - for i, candidate in ipairs(o.items) do - if candidate == value then return i - 1 end - end - return -1 - end - end - """); - } + /// Creates a fresh host object with the CE StringList members this slice invokes. + public static CEObject CreateList(LuaState state) + { + return FakeHost.CreateObject(state, "Probe", """ + o.props.Count = 2 + o.props.Sorted = false + o.props.Duplicates = 1 + o.setters.Duplicates = function(o, value) + o.props.Duplicates = math.tointeger(value) or 0 + end + o.props.CaseSensitive = true + o.items = { '00401000', '7FF6A1B2C3D4' } + o.getters.add = function(o) + return function(value) + o.items[#o.items + 1] = value + o.props.Count = #o.items + return #o.items - 1 + end + end + o.getters.clear = function(o) + return function() + o.items = {} + o.props.Count = 0 + end + end + o.getters.delete = function(o) + return function(index) + table.remove(o.items, index + 1) + o.props.Count = #o.items + end + end + o.getters.getText = function(o) + return function() return table.concat(o.items, '\n') end + end + o.getters.setText = function(o) + return function(value) + o.items = { value } + o.props.Count = 1 + end + end + o.getters.indexOf = function(o) + return function(value) + for i, candidate in ipairs(o.items) do + if candidate == value then return i - 1 end + end + return -1 + end + end + """); + } - /// Creates the CE-shaped empty StringList result used to prove that an empty scan remains successful. - public static CEObject CreateEmptyList(LuaState state) - { - return FakeHost.CreateObject(state, "Probe", "o.props.Count = 0"); - } + /// Creates the CE-shaped empty StringList result used to prove that an empty scan remains successful. + public static CEObject CreateEmptyList(LuaState state) + { + return FakeHost.CreateObject(state, "Probe", "o.props.Count = 0"); + } - /// Creates a CE-shaped object whose Count is deliberately invalid for result-shape failure coverage. - public static CEObject CreateInvalidCountList(LuaState state) - { - return FakeHost.CreateObject(state, "Probe", "o.props.Count = -1"); - } + /// Creates a CE-shaped object whose Count is deliberately invalid for result-shape failure coverage. + public static CEObject CreateInvalidCountList(LuaState state) + { + return FakeHost.CreateObject(state, "Probe", "o.props.Count = -1"); + } - /// Publishes a fake host object as a Lua global through the protected setter. - public static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) - { - using LuaFrame frame = new(state); - value.Push(state); - Assert.True(state.TrySetGlobal(name).IsOk); - } + /// Publishes a fake host object as a Lua global through the protected setter. + public static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) + { + using LuaFrame frame = new(state); + value.Push(state); + Assert.True(state.TrySetGlobal(name).IsOk); + } - /// Installs a string-form AOBScan stand-in that records every optional argument and returns the given list. - public static void InstallAobScan(LuaState state, CEObject results) - { - SetGlobalObject(state, "aob_results"u8, results); - EngineTest.Run(state, """ - function AOBScan(...) - aob_argument_count = select('#', ...) - aob_pattern = select(1, ...) - aob_protection = select(2, ...) - aob_alignment = select(3, ...) - aob_alignment_parameter = select(4, ...) - if aob_pattern == 'nil-result' then return nil end - if aob_pattern == 'raise' then error('AOBScan stand-in raised') end - if aob_pattern == 'invalid-result' then return 42 end - if aob_pattern == 'malformed-result' then return aob_malformed end - return aob_results - end - """u8); - } + /// Installs a string-form AOBScan stand-in that records every optional argument and returns the given list. + public static void InstallAobScan(LuaState state, CEObject results) + { + SetGlobalObject(state, "aob_results"u8, results); + EngineTest.Run(state, """ + function AOBScan(...) + aob_argument_count = select('#', ...) + aob_pattern = select(1, ...) + aob_protection = select(2, ...) + aob_alignment = select(3, ...) + aob_alignment_parameter = select(4, ...) + if aob_pattern == 'nil-result' then return nil end + if aob_pattern == 'raise' then error('AOBScan stand-in raised') end + if aob_pattern == 'invalid-result' then return 42 end + if aob_pattern == 'malformed-result' then return aob_malformed end + return aob_results + end + """u8); + } - /// Installs the exact CE factory spelling and makes it return the supplied fresh fake list. - public static void InstallStringListFactory(LuaState state, CEObject created) - { - SetGlobalObject(state, "created_string_list"u8, created); - EngineTest.Run(state, "function createStringlist() return created_string_list end"u8); - } + /// Installs the exact CE factory spelling and makes it return the supplied fresh fake list. + public static void InstallStringListFactory(LuaState state, CEObject created) + { + SetGlobalObject(state, "created_string_list"u8, created); + EngineTest.Run(state, "function createStringlist() return created_string_list end"u8); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs index 3c1f1ad8..0a3f4c2c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs @@ -1,9 +1,8 @@ -using System; using System.Text; + using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Values; using CheatEngine.SDK.Engine.Tests.Support; -using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -13,318 +12,320 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning; [Trait("Category", "NativeLua")] public sealed class MemoryScanSessionFactoryTests { - [Fact] - public void TryCreate_when_both_factories_return_host_objects_transfers_ownership_to_the_session() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - var foundList = CreateFoundList(L); - InstallFactories(L, scanner, foundList); - - Assert.True(MemoryScanSessions.TryCreate(out var created)); - var session = Assert.IsType(created); - Assert.Equal(scanner, session.Scanner.Handle); - Assert.Equal(0, L.Top); - - session.Dispose(); - - Assert.True(FakeHost.IsDestroyed(L, foundList)); - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,factory.list,list.destroy,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreate_when_the_child_factory_is_unavailable_rolls_back_the_created_parent() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - SetGlobalObject(L, "factory_scan"u8, scanner); - EngineTest.Run(L, "trace = {}; function createMemScan() table.insert(trace, 'factory.scan'); return factory_scan end"u8); - - Assert.Equal(MemoryScanCreationStatus.GlobalUnavailable, - MemoryScanSessions.TryCreateDetailed(out var created)); - Assert.Null(created); - - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreate_when_the_child_factory_raises_rolls_back_the_created_parent_and_restores_the_stack() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - SetGlobalObject(L, "factory_scan"u8, scanner); - EngineTest.Run(L, """ - trace = {} - function createMemScan() - table.insert(trace, 'factory.scan') - return factory_scan - end - function createFoundList(scan) - table.insert(trace, 'factory.list') - error('found-list creation failed') - end - """u8); - - Assert.Equal(MemoryScanCreationStatus.LuaFailure, - MemoryScanSessions.TryCreateDetailed(out var created)); - Assert.Null(created); - - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreate_when_the_child_factory_returns_a_nonobject_rolls_back_the_created_parent() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - SetGlobalObject(L, "factory_scan"u8, scanner); - EngineTest.Run(L, """ - trace = {} - function createMemScan() - table.insert(trace, 'factory.scan') - return factory_scan - end - function createFoundList(scan) - table.insert(trace, 'factory.list') - return 42 - end - """u8); - - Assert.Equal(MemoryScanCreationStatus.InvalidFoundListResult, - MemoryScanSessions.TryCreateDetailed(out var created)); - Assert.Null(created); - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreate_when_the_child_factory_aliases_the_parent_rolls_back_without_creating_a_second_owner() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - SetGlobalObject(L, "factory_scan"u8, scanner); - EngineTest.Run(L, """ - trace = {} - function createMemScan() - table.insert(trace, 'factory.scan') - return factory_scan - end - function createFoundList(scan) - table.insert(trace, 'factory.list') - return factory_scan - end - """u8); - - Assert.Equal(MemoryScanCreationStatus.AliasedFoundList, - MemoryScanSessions.TryCreateDetailed(out var created)); - Assert.Null(created); - - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreate_when_internal_adoption_fails_rolls_back_the_child_before_the_parent() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - var foundList = CreateFoundList(L); - InstallFactories(L, scanner, foundList); - - var failure = Assert.Throws(() => MemoryScanSessions.TryCreateCore( - out _, static (_, _) => throw new InvalidOperationException("injected adoption failure"))); - - Assert.Equal("injected adoption failure", failure.Message); - Assert.True(FakeHost.IsDestroyed(L, foundList)); - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,factory.list,list.destroy,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreate_when_the_parent_factory_returns_a_nonobject_does_not_publish_a_session() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "function createMemScan() return 42 end"u8); - - Assert.Equal(MemoryScanCreationStatus.InvalidScannerResult, - MemoryScanSessions.TryCreateDetailed(out var created)); - Assert.Null(created); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreateDetailed_when_a_factory_returns_nil_keeps_absence_distinct_from_a_Lua_failure() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, "function createMemScan() return nil end"u8); - - var status = MemoryScanSessions.TryCreateDetailed(out var created); - - Assert.Equal(MemoryScanCreationStatus.NoScannerResult, status); - Assert.Null(created); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreateDetailed_when_the_child_factory_returns_nil_reports_absence_and_releases_the_parent() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = CreateScanner(L); - SetGlobalObject(L, "factory_scan"u8, scanner); - EngineTest.Run(L, Encoding.UTF8.GetBytes($$""" - trace = {} - function createMemScan() - table.insert(trace, 'factory.scan') - return factory_scan - end - function createFoundList(scan) - table.insert(trace, 'factory.list') - return nil - end - function getOpenedProcessID() - return {{Environment.ProcessId}} - end - """)); - - var status = MemoryScanSessions.TryCreateDetailed(out var created); - - Assert.Equal(MemoryScanCreationStatus.NoFoundListResult, status); - Assert.Null(created); - Assert.True(FakeHost.IsDestroyed(L, scanner)); - Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void TryCreateDetailed_when_rollback_destroy_is_not_confirmed_reports_that_fact_without_retrying() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var scanner = FakeHost.CreateObject(L, "Object", """ - o.getters.destroy = function(o) - return function() - table.insert(trace, 'scan.destroy') - error('fixture destroy failure') - end - end - """); - SetGlobalObject(L, "factory_scan"u8, scanner); - EngineTest.Run(L, Encoding.UTF8.GetBytes($$""" - trace = {} - function createMemScan() - table.insert(trace, 'factory.scan') - return factory_scan - end - function createFoundList(scan) - table.insert(trace, 'factory.list') - return nil - end - function getOpenedProcessID() - return {{Environment.ProcessId}} - end - """)); - - var status = MemoryScanSessions.TryCreateDetailed(out var created); - - Assert.Equal(MemoryScanCreationStatus.RollbackUnconfirmed, status); - Assert.Null(created); - Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); - Assert.Equal(0, L.Top); - } - - private static CEObject CreateScanner(LuaState state) - { - return FakeHost.CreateObject(state, "Object", """ - o.getters.destroy = function(o) - return function() - o.destroyed = true - table.insert(trace, 'scan.destroy') - end - end - """); - } - - private static CEObject CreateFoundList(LuaState state) - { - return FakeHost.CreateObject(state, "Object", """ - o.getters.destroy = function(o) - return function() - o.destroyed = true - table.insert(trace, 'list.destroy') - end - end - """); - } - - private static void InstallFactories(LuaState state, CEObject scanner, CEObject foundList) - { - SetGlobalObject(state, "factory_scan"u8, scanner); - SetGlobalObject(state, "factory_found_list"u8, foundList); - EngineTest.Run(state, Encoding.UTF8.GetBytes($$""" - trace = {} - function createMemScan() - table.insert(trace, 'factory.scan') - return factory_scan - end - function createFoundList(scan) - table.insert(trace, 'factory.list') - return factory_found_list - end - function getOpenedProcessID() - return {{Environment.ProcessId}} - end - """)); - } - - private static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) - { - using LuaFrame frame = new(state); - CEObject.Push(state, value); - Assert.True(state.TrySetGlobal(name).IsOk); - } - - private static string ReadTrace(LuaState state) - { - using LuaFrame frame = new(state); - EngineTest.Run(state, "return table.concat(trace, ',')"u8, 1); - return EngineTest.ReadString(state, -1); - } + [Fact] + public void TryCreate_when_both_factories_return_host_objects_transfers_ownership_to_the_session() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + CEObject foundList = CreateFoundList(L); + InstallFactories(L, scanner, foundList); + + Assert.True(MemoryScanSessions.TryCreate(out MemoryScanSession? created)); + MemoryScanSession session = Assert.IsType(created); + Assert.Equal(scanner, session.Scanner.Handle); + Assert.Equal(0, L.Top); + + session.Dispose(); + + Assert.True(FakeHost.IsDestroyed(L, foundList)); + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,list.destroy,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreate_when_the_child_factory_is_unavailable_rolls_back_the_created_parent() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, + "trace = {}; function createMemScan() table.insert(trace, 'factory.scan'); return factory_scan end"u8); + + Assert.Equal(MemoryScanCreationStatus.GlobalUnavailable, + MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created)); + Assert.Null(created); + + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreate_when_the_child_factory_raises_rolls_back_the_created_parent_and_restores_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, """ + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + error('found-list creation failed') + end + """u8); + + Assert.Equal(MemoryScanCreationStatus.LuaFailure, + MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created)); + Assert.Null(created); + + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreate_when_the_child_factory_returns_a_nonobject_rolls_back_the_created_parent() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, """ + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + return 42 + end + """u8); + + Assert.Equal(MemoryScanCreationStatus.InvalidFoundListResult, + MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created)); + Assert.Null(created); + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreate_when_the_child_factory_aliases_the_parent_rolls_back_without_creating_a_second_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, """ + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + return factory_scan + end + """u8); + + Assert.Equal(MemoryScanCreationStatus.AliasedFoundList, + MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created)); + Assert.Null(created); + + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreate_when_internal_adoption_fails_rolls_back_the_child_before_the_parent() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + CEObject foundList = CreateFoundList(L); + InstallFactories(L, scanner, foundList); + + InvalidOperationException failure = Assert.Throws(() => + MemoryScanSessions.TryCreateCore( + out _, static (_, _) => throw new InvalidOperationException("injected adoption failure"))); + + Assert.Equal("injected adoption failure", failure.Message); + Assert.True(FakeHost.IsDestroyed(L, foundList)); + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,list.destroy,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreate_when_the_parent_factory_returns_a_nonobject_does_not_publish_a_session() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "function createMemScan() return 42 end"u8); + + Assert.Equal(MemoryScanCreationStatus.InvalidScannerResult, + MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created)); + Assert.Null(created); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreateDetailed_when_a_factory_returns_nil_keeps_absence_distinct_from_a_Lua_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "function createMemScan() return nil end"u8); + + MemoryScanCreationStatus status = MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created); + + Assert.Equal(MemoryScanCreationStatus.NoScannerResult, status); + Assert.Null(created); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreateDetailed_when_the_child_factory_returns_nil_reports_absence_and_releases_the_parent() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, Encoding.UTF8.GetBytes($$""" + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + return nil + end + function getOpenedProcessID() + return {{Environment.ProcessId}} + end + """)); + + MemoryScanCreationStatus status = MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created); + + Assert.Equal(MemoryScanCreationStatus.NoFoundListResult, status); + Assert.Null(created); + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryCreateDetailed_when_rollback_destroy_is_not_confirmed_reports_that_fact_without_retrying() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = FakeHost.CreateObject(L, "Object", """ + o.getters.destroy = function(o) + return function() + table.insert(trace, 'scan.destroy') + error('fixture destroy failure') + end + end + """); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, Encoding.UTF8.GetBytes($$""" + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + return nil + end + function getOpenedProcessID() + return {{Environment.ProcessId}} + end + """)); + + MemoryScanCreationStatus status = MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created); + + Assert.Equal(MemoryScanCreationStatus.RollbackUnconfirmed, status); + Assert.Null(created); + Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + private static CEObject CreateScanner(LuaState state) + { + return FakeHost.CreateObject(state, "Object", """ + o.getters.destroy = function(o) + return function() + o.destroyed = true + table.insert(trace, 'scan.destroy') + end + end + """); + } + + private static CEObject CreateFoundList(LuaState state) + { + return FakeHost.CreateObject(state, "Object", """ + o.getters.destroy = function(o) + return function() + o.destroyed = true + table.insert(trace, 'list.destroy') + end + end + """); + } + + private static void InstallFactories(LuaState state, CEObject scanner, CEObject foundList) + { + SetGlobalObject(state, "factory_scan"u8, scanner); + SetGlobalObject(state, "factory_found_list"u8, foundList); + EngineTest.Run(state, Encoding.UTF8.GetBytes($$""" + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + return factory_found_list + end + function getOpenedProcessID() + return {{Environment.ProcessId}} + end + """)); + } + + private static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) + { + using LuaFrame frame = new(state); + CEObject.Push(state, value); + Assert.True(state.TrySetGlobal(name).IsOk); + } + + private static string ReadTrace(LuaState state) + { + using LuaFrame frame = new(state); + EngineTest.Run(state, "return table.concat(trace, ',')"u8, 1); + return EngineTest.ReadString(state, -1); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs index 552e4483..1963f1fd 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs @@ -1,7 +1,7 @@ -using System; using System.Diagnostics.CodeAnalysis; -using System.Threading; +using System.Globalization; using System.Text; + using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Values; @@ -22,630 +22,639 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning; [Trait("Category", "NativeLua")] public sealed class MemoryScanSessionTests { - [Fact] - public void First_scan_wait_and_read_follow_the_documented_CE_sequence() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - Assert.Equal(MemoryScanState.Scanning, session.State); - session.WaitForCompletion(); - Assert.Equal(MemoryScanState.ResultsReady, session.State); - Assert.Equal(2UL, session.ResultCount); - - Assert.True(session.TryGetAddress(0, out var first)); - Assert.Equal(new Address(0x1234), first); - Assert.True(session.TryGetAddress(1, out var second)); - Assert.Equal(new Address(0xFFFF_FFFF_FFFF_FFFF), second); - Assert.False(session.TryGetAddress(2, out _)); - Assert.True(session.TryGetValue(0, out var value)); - Assert.Equal("100", value); - Assert.Equal( - "scan.first:14,scan.wait,list.initialize,results.getCount,results.getCount,results.getAddress:0,results.getCount,results.getAddress:1,results.getCount,results.getCount,results.getValue:0", - ReadTrace(scope.State)); - } - - [Fact] - public void Next_scan_is_rejected_until_a_first_scan_completed_and_omits_an_unspecified_optional_argument() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - - var beforeFirst = Assert.Throws(() => - session.StartNextScan(NextScanRequest.ExactValue("90"))); - Assert.Equal(MemoryScanState.New, beforeFirst.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - - session.StartNextScan(NextScanRequest.ExactValue("90")); - - Assert.Equal(MemoryScanState.Scanning, session.State); - Assert.Equal("list.deinitialize,scan.next:9", ReadTrace(scope.State)); - } - - [Fact] - public void Next_scan_passes_a_present_saved_result_name_as_the_tenth_argument() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - - session.StartNextScan(new NextScanRequest( - ScanOption.ExactValue, - RoundingType.Rounded, - "90", - string.Empty, - isHexadecimalInput: false, - isNotBinaryString: false, - isUnicodeScan: false, - isCaseSensitive: false, - isPercentageScan: false, - "baseline")); - - Assert.Equal(MemoryScanState.Scanning, session.State); - Assert.Equal("list.deinitialize,scan.next:10", ReadTrace(scope.State)); - } - - [Fact] - public void Wait_error_invalidates_the_session_so_reset_can_recover() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, waitRaises: true); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Grouped, "100")); - - var failure = Assert.Throws(session.WaitForCompletion); - - Assert.Equal(MemoryScanFailureKind.LuaError, failure.FailureKind); - Assert.Equal("MemoryScan.WaitForCompletion", failure.Operation); - Assert.Equal(MemoryScanState.Invalidated, session.State); - session.Reset(); - Assert.Equal(MemoryScanState.New, session.State); - Assert.Equal("scan.first:14,scan.wait,list.deinitialize,scan.new", ReadTrace(scope.State)); - } - - [Fact] - public void Dispose_releases_and_destroys_the_child_before_the_parent() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var session = CreateSession(scope.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - - session.Dispose(); - session.Dispose(); - - Assert.Equal(MemoryScanState.Disposed, session.State); - Assert.Equal("list.deinitialize,list.destroy,scan.destroy", ReadTrace(scope.State)); - Assert.Throws(() => _ = session.Scanner); - } - - [Fact] - public void Dispose_attempts_the_parent_cleanup_after_an_unconfirmed_child_destroy() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var session = CreateSession(scope.State, foundListDestroyRaises: true); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - var scanner = session.Scanner.Handle; - EngineTest.Run(scope.State, "trace = {}"u8); - - session.Dispose(); - - Assert.Equal(MemoryScanState.Disposed, session.State); - Assert.True(FakeHost.IsDestroyed(scope.State, scanner)); - Assert.Equal("list.deinitialize,list.destroy,scan.destroy", ReadTrace(scope.State)); - } - - [Fact] - public void Dispose_after_a_detach_and_reattach_refuses_to_use_the_prior_runtime_and_can_be_abandoned() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var session = CreateSession(scope.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - var scanner = session.Scanner.Handle; - var foundList = session.Results.Handle; - - LuaRuntime.Detach(); - try - { - Assert.Throws(session.Dispose); - Assert.Equal(MemoryScanState.ResultsReady, session.State); - Assert.False(FakeHost.IsDestroyed(scope.State, scanner)); - Assert.False(FakeHost.IsDestroyed(scope.State, foundList)); - } - finally - { - LuaRuntime.Attach(scope.Binding); - } - - var invalidated = Assert.Throws(session.Dispose); - - Assert.Equal(MemoryScanFailureKind.RuntimeInvalidated, invalidated.FailureKind); - Assert.Equal(MemoryScanState.Invalidated, session.State); - session.Abandon(); - Assert.Equal(MemoryScanState.Disposed, session.State); - Assert.False(FakeHost.IsDestroyed(scope.State, foundList)); - Assert.False(FakeHost.IsDestroyed(scope.State, scanner)); - } - - [Fact] - public void A_large_found_list_count_is_preserved_without_widening_row_indices() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, resultCountLiteral: "3000000000"); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - - Assert.Equal(3_000_000_000UL, session.ResultCount); - Assert.True(session.Results.TryGetCount(out var rawCount)); - Assert.Equal(3_000_000_000UL, rawCount); - Assert.True(session.TryGetValue(0, out var value)); - Assert.Equal("100", value); - } - - [Fact] - public void A_negative_found_list_count_is_rejected_by_raw_and_session_reads() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, resultCountLiteral: "-1"); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - - Assert.False(session.Results.TryGetCount(out var rawCount)); - Assert.Equal(0UL, rawCount); - var failure = Assert.Throws(() => _ = session.ResultCount); - Assert.Equal(MemoryScanFailureKind.UnexpectedResult, failure.FailureKind); - Assert.Equal("MemoryScan.ResultCount", failure.Operation); - } - - [Fact] - public void An_empty_found_list_does_not_call_address_or_value_methods_out_of_range() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, resultCountLiteral: "0"); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - - Assert.Equal(0UL, session.ResultCount); - Assert.True(session.Results.TryGetCount(out var rawCount)); - Assert.Equal(0UL, rawCount); - EngineTest.Run(scope.State, "trace = {}"u8); - - Assert.False(session.TryGetAddress(0, out var address)); - Assert.Equal(default, address); - Assert.False(session.TryGetValue(0, out var value)); - Assert.Null(value); - Assert.Equal("results.getCount,results.getCount", ReadTrace(scope.State)); - } - - [Fact] - public void Wait_for_completion_requests_zero_Lua_results_from_wait_till_done() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - using var probe = FakeHost.ReplaceWaitTillDoneWithPCallProbe(scope.State, session.Scanner.Handle); - - session.WaitForCompletion(); - - Assert.Equal(MemoryScanState.ResultsReady, session.State); - Assert.Equal(1, probe.WaitCallCount); - Assert.Equal(0, probe.WaitArgumentCount); - Assert.Equal(0, probe.WaitResultCount); - } - - [Fact] - public void Failed_protected_scan_invalidates_the_session_until_reset_succeeds() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, firstScanRaises: true); - - var failure = Assert.Throws(() => - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); - - Assert.Equal(MemoryScanFailureKind.LuaError, failure.FailureKind); - Assert.Equal("MemoryScan.FirstScan", failure.Operation); - Assert.DoesNotContain("first scan rejected", failure.Message, StringComparison.Ordinal); - Assert.IsType(failure.InnerException); - Assert.Equal(MemoryScanState.Invalidated, session.State); - Assert.Throws(() => - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); - - session.Reset(); - - Assert.Equal(MemoryScanState.New, session.State); - Assert.Equal("scan.first:14,list.deinitialize,scan.new", ReadTrace(scope.State)); - } - - [Fact] - public void A_main_thread_only_scan_operation_is_rejected_before_it_touches_CE() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - - var failure = EngineTest.RunOnWorker(() => - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); - - var exception = Assert.IsType(failure); - Assert.Contains("main thread", exception.Message, StringComparison.Ordinal); - Assert.Equal(MemoryScanState.New, session.State); - Assert.Equal(string.Empty, ReadTrace(scope.State)); - } - - [Fact] - public void Disposal_on_a_worker_is_rejected_before_child_or_parent_cleanup() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var session = CreateSession(scope.State); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - - var failure = EngineTest.RunOnWorker(session.Dispose); - - var exception = Assert.IsType(failure); - Assert.Contains("main thread", exception.Message, StringComparison.Ordinal); - Assert.Equal(MemoryScanState.ResultsReady, session.State); - Assert.Equal(string.Empty, ReadTrace(scope.State)); - - session.Dispose(); - } - - [Fact] - public void Adopt_transfers_the_source_owners_and_keeps_borrowed_handle_identity() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallCurrentTarget(scope.State); - var scan = FakeHost.CreateObject(scope.State, "Object", - ScanInitializer(firstScanRaises: false, waitRaises: false)); - var foundList = FakeHost.CreateObject(scope.State, "Object", FoundListInitializer()); - var scanOwner = new Owned(MemScan.FromHandle(scan)); - var foundListOwner = new Owned(FoundList.FromHandle(foundList)); - - using var session = MemoryScanSession.Adopt(scanOwner, foundListOwner); - - Assert.True(scanOwner.IsDisposed); - Assert.True(foundListOwner.IsDisposed); - Assert.Equal(scan, session.Scanner.Handle); - } - - [Fact] - public void Adopt_when_session_publication_fails_keeps_the_source_owners_for_child_before_parent_cleanup() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, "trace = {}"u8); - var scan = FakeHost.CreateObject(scope.State, "Object", ScanInitializer(firstScanRaises: false, waitRaises: false)); - var foundList = FakeHost.CreateObject(scope.State, "Object", FoundListInitializer()); - var scanOwner = new Owned(MemScan.FromHandle(scan)); - var foundListOwner = new Owned(FoundList.FromHandle(foundList)); - - var exception = Assert.Throws(() => MemoryScanSession.AdoptCore(scanOwner, - foundListOwner, static (_, _) => throw new InvalidOperationException("injected session publication failure"))); - - Assert.Equal("injected session publication failure", exception.Message); - Assert.False(scanOwner.IsDisposed); - Assert.False(foundListOwner.IsDisposed); - foundListOwner.Dispose(); - scanOwner.Dispose(); - Assert.Equal("list.destroy,scan.destroy", ReadTrace(scope.State)); - } - - [Fact] - public void A_non_hexadecimal_address_text_is_a_stable_unexpected_host_result() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, invalidAddress: true); - - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - - var failure = Assert.Throws(() => session.TryGetAddress(0, out _)); - - Assert.Equal(MemoryScanFailureKind.UnexpectedResult, failure.FailureKind); - Assert.Equal("MemoryScan.ResultAddress", failure.Operation); - } - - [Fact] - public void TryCopyResults_refuses_an_insufficient_destination_before_reading_any_row() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - var retained = new MemoryScanResult(new Address(0xA11CE), "retained"); - MemoryScanResult[] destination = [retained]; - - var status = session.TryCopyResults(destination, out var totalCount, out var written); - - Assert.Equal(MemoryScanMaterializationStatus.DestinationTooSmall, status); - Assert.Equal(2UL, totalCount); - Assert.Equal(0, written); - Assert.Equal(retained, destination[0]); - Assert.Equal("results.getCount", ReadTrace(scope.State)); - } - - [Fact] - public void TryCopyResults_creates_a_complete_non_streaming_snapshot_within_the_caller_bound() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - MemoryScanResult[] destination = new MemoryScanResult[2]; - - var status = session.TryCopyResults(destination, out var totalCount, out var written); - - Assert.Equal(MemoryScanMaterializationStatus.Success, status); - Assert.Equal(2UL, totalCount); - Assert.Equal(2, written); - Assert.Equal(new MemoryScanResult(new Address(0x1234), "100"), destination[0]); - Assert.Equal(new MemoryScanResult(new Address(0xFFFF_FFFF_FFFF_FFFF), "100"), destination[1]); - Assert.Equal("results.getCount,results.getAddress:0,results.getValue:0,results.getAddress:1,results.getValue:1", - ReadTrace(scope.State)); - } - - [Fact] - public void TryCopyResults_keeps_an_empty_found_list_distinct_from_an_invalid_result() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, resultCountLiteral: "0"); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - var retained = new MemoryScanResult(new Address(0xA11CE), "retained"); - MemoryScanResult[] destination = [retained]; - - var status = session.TryCopyResults(destination, out var totalCount, out var written); - - Assert.Equal(MemoryScanMaterializationStatus.NoResults, status); - Assert.Equal(0UL, totalCount); - Assert.Equal(0, written); - Assert.Equal(retained, destination[0]); - Assert.Equal("results.getCount", ReadTrace(scope.State)); - } - - [Fact] - public void TryCopyResults_reports_a_malformed_row_without_publishing_a_partial_snapshot() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State, invalidAddress: true); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - var retained = new MemoryScanResult(new Address(0xA11CE), "retained"); - MemoryScanResult[] destination = [retained, retained]; - - var status = session.TryCopyResults(destination, out var totalCount, out var written); - - Assert.Equal(MemoryScanMaterializationStatus.InvalidResult, status); - Assert.Equal(2UL, totalCount); - Assert.Equal(0, written); - Assert.Equal(retained, destination[0]); - Assert.Equal(retained, destination[1]); - Assert.Equal("results.getCount,results.getAddress:0", ReadTrace(scope.State)); - } - - [Fact] - [SuppressMessage("xUnit.Analyzers", "xUnit1051", - Justification = "The fixture must start with a deliberately cancelled token to prove no Lua row call begins.")] - public void TryCopyResults_reports_a_preexisting_cancellation_without_reading_or_publishing_rows() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - session.WaitForCompletion(); - EngineTest.Run(scope.State, "trace = {}"u8); - var retained = new MemoryScanResult(new Address(0xA11CE), "retained"); - MemoryScanResult[] destination = [retained, retained]; - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - var status = CopyWithCancellation(session, destination, cancellation.Token, out var totalCount, out var written); - - Assert.Equal(MemoryScanMaterializationStatus.Cancelled, status); - Assert.Equal(0UL, totalCount); - Assert.Equal(0, written); - Assert.Equal(retained, destination[0]); - Assert.Equal(retained, destination[1]); - Assert.Equal(MemoryScanCancellationMilestone.CancelledBeforeNativeCall, session.LastCancellationMilestone); - Assert.Equal(string.Empty, ReadTrace(scope.State)); - } - - [Fact] - [SuppressMessage("xUnit.Analyzers", "xUnit1051", - Justification = "The fixture must start with a deliberately cancelled token to prove no CE scan call begins.")] - public void A_cancellable_first_scan_honors_preexisting_cancellation_without_claiming_to_interrupt_CE() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - Assert.Throws(() => StartFirstWithCancellation(session, - FirstScanRequest.ExactValue(VariableType.Dword, "100"), cancellation.Token)); - - Assert.Equal(MemoryScanState.New, session.State); - Assert.Equal(MemoryScanCancellationMilestone.CancelledBeforeNativeCall, session.LastCancellationMilestone); - Assert.Equal(string.Empty, ReadTrace(scope.State)); - } - - [Fact] - public void A_cancellable_wait_records_post_call_cancellation_without_falsely_claiming_to_interrupt_CE() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - using var session = CreateSession(scope.State); - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); - using CancellationTokenSource cancellation = new(); - using var probe = FakeHost.ReplaceWaitTillDoneWithPCallProbe(scope.State, session.Scanner.Handle, - cancellation.Cancel); - - WaitWithCancellation(session, cancellation.Token); - - Assert.Equal(1, probe.WaitCallCount); - Assert.Equal(MemoryScanState.ResultsReady, session.State); - Assert.Equal(MemoryScanCancellationMilestone.ObservedAfterNativeCall, session.LastCancellationMilestone); - } - - [Fact] - public void A_session_refuses_scan_work_when_the_original_target_is_no_longer_selected() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var session = CreateSession(scope.State); - EngineTest.Run(scope.State, "opened_process_id = 0"u8); - EngineTest.Run(scope.State, "trace = {}"u8); - - var failure = Assert.Throws(() => - session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); - - Assert.Equal(MemoryScanFailureKind.TargetIdentityUnavailable, failure.FailureKind); - Assert.Equal(MemoryScanState.New, session.State); - Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, session.LastTargetCheck!.Value.Kind); - Assert.Equal(MemoryScanInvalidationReason.None, session.InvalidationReason); - Assert.Equal(string.Empty, ReadTrace(scope.State)); - session.Abandon(); - } - - private static MemoryScanMaterializationStatus CopyWithCancellation(MemoryScanSession session, - Span destination, CancellationToken cancellationToken, out ulong totalCount, out int written) - { - return session.TryCopyResultsCancellable(destination, out totalCount, out written, cancellationToken); - } - - private static void StartFirstWithCancellation(MemoryScanSession session, in FirstScanRequest request, - CancellationToken cancellationToken) - { - session.StartFirstScanCancellable(in request, cancellationToken); - } - - private static void WaitWithCancellation(MemoryScanSession session, CancellationToken cancellationToken) - { - session.WaitForCompletionCancellable(cancellationToken); - } - - private static MemoryScanSession CreateSession(LuaState state, bool firstScanRaises = false, - bool invalidAddress = false, bool waitRaises = false, string resultCountLiteral = "2", - bool foundListDestroyRaises = false) - { - EngineTest.Run(state, "trace = {}"u8); - InstallCurrentTarget(state); - var scan = FakeHost.CreateObject(state, "Object", - ScanInitializer(firstScanRaises, waitRaises)); - var foundList = - FakeHost.CreateObject(state, "Object", FoundListInitializer(invalidAddress, resultCountLiteral, - foundListDestroyRaises)); - return MemoryScanSession.Adopt( - new Owned(MemScan.FromHandle(scan)), - new Owned(FoundList.FromHandle(foundList))); - } - - private static void InstallCurrentTarget(LuaState state) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("opened_process_id = " + - Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + - "; function getOpenedProcessID() return opened_process_id end")); - } - - private static string ScanInitializer(bool firstScanRaises, bool waitRaises) - { - var raiseFirstScan = firstScanRaises ? "; error('first scan rejected')" : string.Empty; - var raiseWait = waitRaises ? "; error('wait rejected')" : string.Empty; - return - "o.props.firstScan = function(...) local n = select('#', ...); if n ~= 14 then error('firstScan argument count') end; local scanoption, vartype, roundingtype, input1, input2, startAddress, stopAddress, protectionflags, alignmenttype, alignmentparam, hexadecimal, nonbinary, unicode, casesensitive = ...; if scanoption ~= 1 or (vartype ~= 2 and vartype ~= 14) or roundingtype ~= 0 or input1 ~= '100' or input2 ~= '' or startAddress ~= 0 or stopAddress ~= -1 or protectionflags ~= '' or alignmenttype ~= 0 or alignmentparam ~= '' or hexadecimal ~= false or nonbinary ~= false or unicode ~= false or casesensitive ~= false then error('firstScan argument values') end; table.insert(trace, 'scan.first:' .. n)" + - raiseFirstScan + " end\n" + - "o.props.nextScan = function(...) local n = select('#', ...); if n ~= 9 and n ~= 10 then error('nextScan argument count') end; local scanoption, roundingtype, input1, input2, hexadecimal, nonbinary, unicode, casesensitive, percentage, savedresultname = ...; if scanoption ~= 1 or roundingtype ~= 0 or input1 ~= '90' or input2 ~= '' or hexadecimal ~= false or nonbinary ~= false or unicode ~= false or casesensitive ~= false or percentage ~= false or (n == 9 and savedresultname ~= nil) or (n == 10 and savedresultname ~= 'baseline') then error('nextScan argument values') end; table.insert(trace, 'scan.next:' .. n) end\n" + - "o.props.waitTillDone = function() table.insert(trace, 'scan.wait')" + raiseWait + " end\n" + - "o.props.newScan = function() table.insert(trace, 'scan.new') end\n" + - "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'scan.destroy') end end"; - } - - private static string FoundListInitializer(bool invalidAddress = false, string resultCountLiteral = "2", - bool destroyRaises = false) - { - var firstAddress = invalidAddress ? "'not-an-address'" : "'00001234'"; - var destroyFailure = destroyRaises ? "; error('found-list destroy rejected')" : string.Empty; - return "o.props.initialize = function() table.insert(trace, 'list.initialize') end\n" + - "o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end\n" + - "o.props.Count = " + resultCountLiteral + "\n" + - "o.props.getCount = function() table.insert(trace, 'results.getCount'); return o.props.Count end\n" + - "o.props.getAddress = function(index) table.insert(trace, 'results.getAddress:' .. index); if index == 0 then return " + - firstAddress + " end; return 'FFFFFFFFFFFFFFFF' end\n" + - "o.props.getValue = function(index) table.insert(trace, 'results.getValue:' .. index); return '100' end\n" + - "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + - destroyFailure + " end end"; - } - - private static string ReadTrace(LuaState state) - { - using LuaFrame frame = new(state); - EngineTest.Run(state, "return table.concat(trace, ',')"u8, 1); - return EngineTest.ReadString(state, -1); - } + [Fact] + public void First_scan_wait_and_read_follow_the_documented_CE_sequence() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + Assert.Equal(MemoryScanState.Scanning, session.State); + session.WaitForCompletion(); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(2UL, session.ResultCount); + + Assert.True(session.TryGetAddress(0, out Address first)); + Assert.Equal(new Address(0x1234), first); + Assert.True(session.TryGetAddress(1, out Address second)); + Assert.Equal(new Address(0xFFFF_FFFF_FFFF_FFFF), second); + Assert.False(session.TryGetAddress(2, out _)); + Assert.True(session.TryGetValue(0, out string? value)); + Assert.Equal("100", value); + Assert.Equal( + "scan.first:14,scan.wait,list.initialize,results.getCount,results.getCount,results.getAddress:0,results.getCount,results.getAddress:1,results.getCount,results.getCount,results.getValue:0", + ReadTrace(scope.State)); + } + + [Fact] + public void Next_scan_is_rejected_until_a_first_scan_completed_and_omits_an_unspecified_optional_argument() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + + MemoryScanStateException beforeFirst = Assert.Throws(() => + session.StartNextScan(NextScanRequest.ExactValue("90"))); + Assert.Equal(MemoryScanState.New, beforeFirst.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + + session.StartNextScan(NextScanRequest.ExactValue("90")); + + Assert.Equal(MemoryScanState.Scanning, session.State); + Assert.Equal("list.deinitialize,scan.next:9", ReadTrace(scope.State)); + } + + [Fact] + public void Next_scan_passes_a_present_saved_result_name_as_the_tenth_argument() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + + session.StartNextScan(new NextScanRequest( + ScanOption.ExactValue, + RoundingType.Rounded, + "90", + string.Empty, + false, + false, + false, + false, + false, + "baseline")); + + Assert.Equal(MemoryScanState.Scanning, session.State); + Assert.Equal("list.deinitialize,scan.next:10", ReadTrace(scope.State)); + } + + [Fact] + public void Wait_error_invalidates_the_session_so_reset_can_recover() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, waitRaises: true); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Grouped, "100")); + + MemoryScanException failure = Assert.Throws(session.WaitForCompletion); + + Assert.Equal(MemoryScanFailureKind.LuaError, failure.FailureKind); + Assert.Equal("MemoryScan.WaitForCompletion", failure.Operation); + Assert.Equal(MemoryScanState.Invalidated, session.State); + session.Reset(); + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal("scan.first:14,scan.wait,list.deinitialize,scan.new", ReadTrace(scope.State)); + } + + [Fact] + public void Dispose_releases_and_destroys_the_child_before_the_parent() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + MemoryScanSession session = CreateSession(scope.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + + session.Dispose(); + session.Dispose(); + + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal("list.deinitialize,list.destroy,scan.destroy", ReadTrace(scope.State)); + Assert.Throws(() => _ = session.Scanner); + } + + [Fact] + public void Dispose_attempts_the_parent_cleanup_after_an_unconfirmed_child_destroy() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + MemoryScanSession session = CreateSession(scope.State, foundListDestroyRaises: true); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + CEObject scanner = session.Scanner.Handle; + EngineTest.Run(scope.State, "trace = {}"u8); + + session.Dispose(); + + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.True(FakeHost.IsDestroyed(scope.State, scanner)); + Assert.Equal("list.deinitialize,list.destroy,scan.destroy", ReadTrace(scope.State)); + } + + [Fact] + public void Dispose_after_a_detach_and_reattach_refuses_to_use_the_prior_runtime_and_can_be_abandoned() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + MemoryScanSession session = CreateSession(scope.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + CEObject scanner = session.Scanner.Handle; + CEObject foundList = session.Results.Handle; + + LuaRuntime.Detach(); + try + { + Assert.Throws(session.Dispose); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.False(FakeHost.IsDestroyed(scope.State, scanner)); + Assert.False(FakeHost.IsDestroyed(scope.State, foundList)); + } + finally + { + LuaRuntime.Attach(scope.Binding); + } + + MemoryScanException invalidated = Assert.Throws(session.Dispose); + + Assert.Equal(MemoryScanFailureKind.RuntimeInvalidated, invalidated.FailureKind); + Assert.Equal(MemoryScanState.Invalidated, session.State); + session.Abandon(); + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.False(FakeHost.IsDestroyed(scope.State, foundList)); + Assert.False(FakeHost.IsDestroyed(scope.State, scanner)); + } + + [Fact] + public void A_large_found_list_count_is_preserved_without_widening_row_indices() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, resultCountLiteral: "3000000000"); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + + Assert.Equal(3_000_000_000UL, session.ResultCount); + Assert.True(session.Results.TryGetCount(out ulong rawCount)); + Assert.Equal(3_000_000_000UL, rawCount); + Assert.True(session.TryGetValue(0, out string? value)); + Assert.Equal("100", value); + } + + [Fact] + public void A_negative_found_list_count_is_rejected_by_raw_and_session_reads() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, resultCountLiteral: "-1"); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + + Assert.False(session.Results.TryGetCount(out ulong rawCount)); + Assert.Equal(0UL, rawCount); + MemoryScanException failure = Assert.Throws(() => _ = session.ResultCount); + Assert.Equal(MemoryScanFailureKind.UnexpectedResult, failure.FailureKind); + Assert.Equal("MemoryScan.ResultCount", failure.Operation); + } + + [Fact] + public void An_empty_found_list_does_not_call_address_or_value_methods_out_of_range() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, resultCountLiteral: "0"); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + + Assert.Equal(0UL, session.ResultCount); + Assert.True(session.Results.TryGetCount(out ulong rawCount)); + Assert.Equal(0UL, rawCount); + EngineTest.Run(scope.State, "trace = {}"u8); + + Assert.False(session.TryGetAddress(0, out Address address)); + Assert.Equal(default, address); + Assert.False(session.TryGetValue(0, out string? value)); + Assert.Null(value); + Assert.Equal("results.getCount,results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void Wait_for_completion_requests_zero_Lua_results_from_wait_till_done() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + using FakeHost.PCallProbe probe = + FakeHost.ReplaceWaitTillDoneWithPCallProbe(scope.State, session.Scanner.Handle); + + session.WaitForCompletion(); + + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(1, probe.WaitCallCount); + Assert.Equal(0, probe.WaitArgumentCount); + Assert.Equal(0, probe.WaitResultCount); + } + + [Fact] + public void Failed_protected_scan_invalidates_the_session_until_reset_succeeds() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, true); + + MemoryScanException failure = Assert.Throws(() => + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); + + Assert.Equal(MemoryScanFailureKind.LuaError, failure.FailureKind); + Assert.Equal("MemoryScan.FirstScan", failure.Operation); + Assert.DoesNotContain("first scan rejected", failure.Message, StringComparison.Ordinal); + Assert.IsType(failure.InnerException); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Throws(() => + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); + + session.Reset(); + + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal("scan.first:14,list.deinitialize,scan.new", ReadTrace(scope.State)); + } + + [Fact] + public void A_main_thread_only_scan_operation_is_rejected_before_it_touches_CE() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + + Exception? failure = EngineTest.RunOnWorker(() => + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); + + InvalidOperationException exception = Assert.IsType(failure); + Assert.Contains("main thread", exception.Message, StringComparison.Ordinal); + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + } + + [Fact] + public void Disposal_on_a_worker_is_rejected_before_child_or_parent_cleanup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + MemoryScanSession session = CreateSession(scope.State); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + + Exception? failure = EngineTest.RunOnWorker(session.Dispose); + + InvalidOperationException exception = Assert.IsType(failure); + Assert.Contains("main thread", exception.Message, StringComparison.Ordinal); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + + session.Dispose(); + } + + [Fact] + public void Adopt_transfers_the_source_owners_and_keeps_borrowed_handle_identity() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallCurrentTarget(scope.State); + CEObject scan = FakeHost.CreateObject(scope.State, "Object", + ScanInitializer(false, false)); + CEObject foundList = FakeHost.CreateObject(scope.State, "Object", FoundListInitializer()); + Owned scanOwner = new(MemScan.FromHandle(scan)); + Owned foundListOwner = new(FoundList.FromHandle(foundList)); + + using MemoryScanSession session = MemoryScanSession.Adopt(scanOwner, foundListOwner); + + Assert.True(scanOwner.IsDisposed); + Assert.True(foundListOwner.IsDisposed); + Assert.Equal(scan, session.Scanner.Handle); + } + + [Fact] + public void Adopt_when_session_publication_fails_keeps_the_source_owners_for_child_before_parent_cleanup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, "trace = {}"u8); + CEObject scan = FakeHost.CreateObject(scope.State, "Object", ScanInitializer(false, false)); + CEObject foundList = FakeHost.CreateObject(scope.State, "Object", FoundListInitializer()); + Owned scanOwner = new(MemScan.FromHandle(scan)); + Owned foundListOwner = new(FoundList.FromHandle(foundList)); + + InvalidOperationException exception = Assert.Throws(() => + MemoryScanSession.AdoptCore(scanOwner, + foundListOwner, + static (_, _) => throw new InvalidOperationException("injected session publication failure"))); + + Assert.Equal("injected session publication failure", exception.Message); + Assert.False(scanOwner.IsDisposed); + Assert.False(foundListOwner.IsDisposed); + foundListOwner.Dispose(); + scanOwner.Dispose(); + Assert.Equal("list.destroy,scan.destroy", ReadTrace(scope.State)); + } + + [Fact] + public void A_non_hexadecimal_address_text_is_a_stable_unexpected_host_result() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, invalidAddress: true); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + + MemoryScanException failure = Assert.Throws(() => session.TryGetAddress(0, out _)); + + Assert.Equal(MemoryScanFailureKind.UnexpectedResult, failure.FailureKind); + Assert.Equal("MemoryScan.ResultAddress", failure.Operation); + } + + [Fact] + public void TryCopyResults_refuses_an_insufficient_destination_before_reading_any_row() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + MemoryScanMaterializationStatus status = + session.TryCopyResults(destination, out ulong totalCount, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.DestinationTooSmall, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal("results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResults_creates_a_complete_non_streaming_snapshot_within_the_caller_bound() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult[] destination = new MemoryScanResult[2]; + + MemoryScanMaterializationStatus status = + session.TryCopyResults(destination, out ulong totalCount, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.Success, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(2, written); + Assert.Equal(new MemoryScanResult(new Address(0x1234), "100"), destination[0]); + Assert.Equal(new MemoryScanResult(new Address(0xFFFF_FFFF_FFFF_FFFF), "100"), destination[1]); + Assert.Equal("results.getCount,results.getAddress:0,results.getValue:0,results.getAddress:1,results.getValue:1", + ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResults_keeps_an_empty_found_list_distinct_from_an_invalid_result() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, resultCountLiteral: "0"); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + MemoryScanMaterializationStatus status = + session.TryCopyResults(destination, out ulong totalCount, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.NoResults, status); + Assert.Equal(0UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal("results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResults_reports_a_malformed_row_without_publishing_a_partial_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, invalidAddress: true); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained, retained]; + + MemoryScanMaterializationStatus status = + session.TryCopyResults(destination, out ulong totalCount, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.InvalidResult, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal(retained, destination[1]); + Assert.Equal("results.getCount,results.getAddress:0", ReadTrace(scope.State)); + } + + [Fact] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = "The fixture must start with a deliberately cancelled token to prove no Lua row call begins.")] + public void TryCopyResults_reports_a_preexisting_cancellation_without_reading_or_publishing_rows() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained, retained]; + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + MemoryScanMaterializationStatus status = CopyWithCancellation(session, destination, cancellation.Token, + out ulong totalCount, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.Cancelled, status); + Assert.Equal(0UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal(retained, destination[1]); + Assert.Equal(MemoryScanCancellationMilestone.CancelledBeforeNativeCall, session.LastCancellationMilestone); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + } + + [Fact] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = "The fixture must start with a deliberately cancelled token to prove no CE scan call begins.")] + public void A_cancellable_first_scan_honors_preexisting_cancellation_without_claiming_to_interrupt_CE() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + Assert.Throws(() => StartFirstWithCancellation(session, + FirstScanRequest.ExactValue(VariableType.Dword, "100"), cancellation.Token)); + + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal(MemoryScanCancellationMilestone.CancelledBeforeNativeCall, session.LastCancellationMilestone); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + } + + [Fact] + public void A_cancellable_wait_records_post_call_cancellation_without_falsely_claiming_to_interrupt_CE() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + using CancellationTokenSource cancellation = new(); + using FakeHost.PCallProbe probe = FakeHost.ReplaceWaitTillDoneWithPCallProbe(scope.State, + session.Scanner.Handle, + cancellation.Cancel); + + WaitWithCancellation(session, cancellation.Token); + + Assert.Equal(1, probe.WaitCallCount); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(MemoryScanCancellationMilestone.ObservedAfterNativeCall, session.LastCancellationMilestone); + } + + [Fact] + public void A_session_refuses_scan_work_when_the_original_target_is_no_longer_selected() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + MemoryScanSession session = CreateSession(scope.State); + EngineTest.Run(scope.State, "opened_process_id = 0"u8); + EngineTest.Run(scope.State, "trace = {}"u8); + + MemoryScanException failure = Assert.Throws(() => + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100"))); + + Assert.Equal(MemoryScanFailureKind.TargetIdentityUnavailable, failure.FailureKind); + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, session.LastTargetCheck!.Value.Kind); + Assert.Equal(MemoryScanInvalidationReason.None, session.InvalidationReason); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + session.Abandon(); + } + + private static MemoryScanMaterializationStatus CopyWithCancellation(MemoryScanSession session, + Span destination, CancellationToken cancellationToken, out ulong totalCount, out int written) + { + return session.TryCopyResultsCancellable(destination, out totalCount, out written, cancellationToken); + } + + private static void StartFirstWithCancellation(MemoryScanSession session, in FirstScanRequest request, + CancellationToken cancellationToken) + { + session.StartFirstScanCancellable(in request, cancellationToken); + } + + private static void WaitWithCancellation(MemoryScanSession session, CancellationToken cancellationToken) + { + session.WaitForCompletionCancellable(cancellationToken); + } + + private static MemoryScanSession CreateSession(LuaState state, bool firstScanRaises = false, + bool invalidAddress = false, bool waitRaises = false, string resultCountLiteral = "2", + bool foundListDestroyRaises = false) + { + EngineTest.Run(state, "trace = {}"u8); + InstallCurrentTarget(state); + CEObject scan = FakeHost.CreateObject(state, "Object", + ScanInitializer(firstScanRaises, waitRaises)); + CEObject foundList = + FakeHost.CreateObject(state, "Object", FoundListInitializer(invalidAddress, resultCountLiteral, + foundListDestroyRaises)); + return MemoryScanSession.Adopt( + new Owned(MemScan.FromHandle(scan)), + new Owned(FoundList.FromHandle(foundList))); + } + + private static void InstallCurrentTarget(LuaState state) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("opened_process_id = " + + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + + "; function getOpenedProcessID() return opened_process_id end")); + } + + private static string ScanInitializer(bool firstScanRaises, bool waitRaises) + { + string raiseFirstScan = firstScanRaises ? "; error('first scan rejected')" : string.Empty; + string raiseWait = waitRaises ? "; error('wait rejected')" : string.Empty; + return + "o.props.firstScan = function(...) local n = select('#', ...); if n ~= 14 then error('firstScan argument count') end; local scanoption, vartype, roundingtype, input1, input2, startAddress, stopAddress, protectionflags, alignmenttype, alignmentparam, hexadecimal, nonbinary, unicode, casesensitive = ...; if scanoption ~= 1 or (vartype ~= 2 and vartype ~= 14) or roundingtype ~= 0 or input1 ~= '100' or input2 ~= '' or startAddress ~= 0 or stopAddress ~= -1 or protectionflags ~= '' or alignmenttype ~= 0 or alignmentparam ~= '' or hexadecimal ~= false or nonbinary ~= false or unicode ~= false or casesensitive ~= false then error('firstScan argument values') end; table.insert(trace, 'scan.first:' .. n)" + + raiseFirstScan + " end\n" + + "o.props.nextScan = function(...) local n = select('#', ...); if n ~= 9 and n ~= 10 then error('nextScan argument count') end; local scanoption, roundingtype, input1, input2, hexadecimal, nonbinary, unicode, casesensitive, percentage, savedresultname = ...; if scanoption ~= 1 or roundingtype ~= 0 or input1 ~= '90' or input2 ~= '' or hexadecimal ~= false or nonbinary ~= false or unicode ~= false or casesensitive ~= false or percentage ~= false or (n == 9 and savedresultname ~= nil) or (n == 10 and savedresultname ~= 'baseline') then error('nextScan argument values') end; table.insert(trace, 'scan.next:' .. n) end\n" + + "o.props.waitTillDone = function() table.insert(trace, 'scan.wait')" + raiseWait + " end\n" + + "o.props.newScan = function() table.insert(trace, 'scan.new') end\n" + + "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'scan.destroy') end end"; + } + + private static string FoundListInitializer(bool invalidAddress = false, string resultCountLiteral = "2", + bool destroyRaises = false) + { + string firstAddress = invalidAddress ? "'not-an-address'" : "'00001234'"; + string destroyFailure = destroyRaises ? "; error('found-list destroy rejected')" : string.Empty; + return "o.props.initialize = function() table.insert(trace, 'list.initialize') end\n" + + "o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end\n" + + "o.props.Count = " + resultCountLiteral + "\n" + + "o.props.getCount = function() table.insert(trace, 'results.getCount'); return o.props.Count end\n" + + "o.props.getAddress = function(index) table.insert(trace, 'results.getAddress:' .. index); if index == 0 then return " + + firstAddress + " end; return 'FFFFFFFFFFFFFFFF' end\n" + + "o.props.getValue = function(index) table.insert(trace, 'results.getValue:' .. index); return '100' end\n" + + "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + + destroyFailure + " end end"; + } + + private static string ReadTrace(LuaState state) + { + using LuaFrame frame = new(state); + EngineTest.Run(state, "return table.concat(trace, ',')"u8, 1); + return EngineTest.ReadString(state, -1); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/AllocationGate.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/AllocationGate.cs index 35e1d0ee..c4c62b37 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/AllocationGate.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/AllocationGate.cs @@ -9,16 +9,22 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// internal static class AllocationGate { - public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) - { - for (var i = 0; i < warmUp; i++) body(); + public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) + { + for (int i = 0; i < warmUp; i++) + { + body(); + } - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < iterations; i++) body(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + body(); + } - var allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(allocated == 0, - string.Create(CultureInfo.InvariantCulture, - $"{allocated} bytes were allocated over {iterations} iterations ({(double)allocated / iterations:F1} per call).")); - } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated == 0, + string.Create(CultureInfo.InvariantCulture, + $"{allocated} bytes were allocated over {iterations} iterations ({(double) allocated / iterations:F1} per call).")); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertFailedException.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertFailedException.cs index 8345f882..702b636d 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertFailedException.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertFailedException.cs @@ -3,17 +3,17 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// Thrown by in place of the process-ending failure of a Debug assertion. public sealed class DebugAssertFailedException : Exception { - public DebugAssertFailedException() - { - } + public DebugAssertFailedException() + { + } - public DebugAssertFailedException(string message) - : base(message) - { - } + public DebugAssertFailedException(string message) + : base(message) + { + } - public DebugAssertFailedException(string message, Exception innerException) - : base(message, innerException) - { - } + public DebugAssertFailedException(string message, Exception innerException) + : base(message, innerException) + { + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertScope.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertScope.cs index 19f5d203..16e93c81 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertScope.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/DebugAssertScope.cs @@ -12,40 +12,40 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// internal sealed class DebugAssertScope : IDisposable { - private readonly TraceListener[] _previous; - - public DebugAssertScope() - { - _previous = new TraceListener[Trace.Listeners.Count]; - Trace.Listeners.CopyTo(_previous, 0); - Trace.Listeners.Clear(); - Trace.Listeners.Add(new ThrowingListener()); - } - - public void Dispose() - { - Trace.Listeners.Clear(); - Trace.Listeners.AddRange(_previous); - } - - private sealed class ThrowingListener : TraceListener - { - public override void Write(string? message) - { - } - - public override void WriteLine(string? message) - { - } - - public override void Fail(string? message) - { - throw new DebugAssertFailedException(string.IsNullOrEmpty(message) ? "A Debug assertion failed." : message); - } - - public override void Fail(string? message, string? detailMessage) - { - throw new DebugAssertFailedException(string.IsNullOrEmpty(message) ? "A Debug assertion failed." : message); - } - } + private readonly TraceListener[] _previous; + + public DebugAssertScope() + { + _previous = new TraceListener[Trace.Listeners.Count]; + Trace.Listeners.CopyTo(_previous, 0); + Trace.Listeners.Clear(); + Trace.Listeners.Add(new ThrowingListener()); + } + + public void Dispose() + { + Trace.Listeners.Clear(); + Trace.Listeners.AddRange(_previous); + } + + private sealed class ThrowingListener : TraceListener + { + public override void Write(string? message) + { + } + + public override void WriteLine(string? message) + { + } + + public override void Fail(string? message) + { + throw new DebugAssertFailedException(string.IsNullOrEmpty(message) ? "A Debug assertion failed." : message); + } + + public override void Fail(string? message, string? detailMessage) + { + throw new DebugAssertFailedException(string.IsNullOrEmpty(message) ? "A Debug assertion failed." : message); + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs index f4ac55cd..ffc733b5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -8,86 +9,86 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// What the NativeLua tests share: the skip guard, chunk execution through the public API and error reading. internal static class EngineTest { - /// - /// Whether this assembly, and therefore the SDK it was built with, is a Debug build: the configuration in which - /// Debug.Assert guards exist. Tests of a Debug-only guard skip in Release. - /// + /// + /// Whether this assembly, and therefore the SDK it was built with, is a Debug build: the configuration in which + /// Debug.Assert guards exist. Tests of a Debug-only guard skip in Release. + /// #if DEBUG public const bool IsDebugBuild = true; #else - public const bool IsDebugBuild = false; + public const bool IsDebugBuild = false; #endif - /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. - public static void RequireNativeLua() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - } + /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. + public static void RequireNativeLua() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + } - /// - /// Runs on a fresh thread, with Debug assertions turned into exceptions, and returns what - /// it threw ( when it completed). The calling thread waits for it, so nothing runs - /// concurrently. - /// - public static Exception? RunOnWorker(Action work) - { - Exception? failure = null; - using DebugAssertScope guard = new(); - Thread worker = new(() => - { - try - { - work(); - } - catch (Exception exception) - { - failure = exception; - } - }); - worker.Start(); - worker.Join(); - return failure; - } + /// + /// Runs on a fresh thread, with Debug assertions turned into exceptions, and returns what + /// it threw ( when it completed). The calling thread waits for it, so nothing runs + /// concurrently. + /// + public static Exception? RunOnWorker(Action work) + { + Exception? failure = null; + using DebugAssertScope guard = new(); + Thread worker = new(() => + { + try + { + work(); + } + catch (Exception exception) + { + failure = exception; + } + }); + worker.Start(); + worker.Join(); + return failure; + } - /// Wraps the fixture's state in the SDK view. - public static LuaState View(NativeLuaState state) - { - return new LuaState(state.Pointer); - } + /// Wraps the fixture's state in the SDK view. + public static LuaState View(NativeLuaState state) + { + return new LuaState(state.Pointer); + } - /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. - public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) - { - var status = L.TryExecute(source, resultCount, "=test"u8); - if (!status.IsOk) - { - var error = LuaError.FromStack(L, status); - Assert.Fail("The chunk failed: " + error); - } - } + /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. + public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) + { + LuaStatus status = L.TryExecute(source, resultCount, "=test"u8); + if (!status.IsOk) + { + LuaError error = LuaError.FromStack(L, status); + Assert.Fail("The chunk failed: " + error); + } + } - /// Reads the error value on top after a failed status, without popping it. - public static string ErrorMessage(LuaState L, LuaStatus status) - { - Assert.False(status.IsOk, "The operation succeeded; there is no error to read."); - return LuaError.FromStack(L, status).Message; - } + /// Reads the error value on top after a failed status, without popping it. + public static string ErrorMessage(LuaState L, LuaStatus status) + { + Assert.False(status.IsOk, "The operation succeeded; there is no error to read."); + return LuaError.FromStack(L, status).Message; + } - /// Reads the string at , or fails the test when the value is not a string. - public static string ReadString(LuaState L, int index) - { - Assert.True(L.TryReadString(index, out var value), - "The value at " + index.ToString(CultureInfo.InvariantCulture) + " is a " + L.TypeOf(index) + - ", not a string."); - return value; - } + /// Reads the string at , or fails the test when the value is not a string. + public static string ReadString(LuaState L, int index) + { + Assert.True(L.TryReadString(index, out string? value), + "The value at " + index.ToString(CultureInfo.InvariantCulture) + " is a " + L.TypeOf(index) + + ", not a string."); + return value; + } - /// Reads the integer at , or fails the test when the value is not an integer. - public static long ReadInteger(LuaState L, int index) - { - Assert.True(L.TryReadInteger(index, out var value), - "The value at " + index.ToString(CultureInfo.InvariantCulture) + " is a " + L.TypeOf(index) + - ", not an integer."); - return value; - } + /// Reads the integer at , or fails the test when the value is not an integer. + public static long ReadInteger(LuaState L, int index) + { + Assert.True(L.TryReadInteger(index, out long value), + "The value at " + index.ToString(CultureInfo.InvariantCulture) + " is a " + L.TypeOf(index) + + ", not an integer."); + return value; + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs index b1653eb5..4b9e099a 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; + using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; @@ -10,6 +11,7 @@ using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Engine.Tests.Support; @@ -30,328 +32,355 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// internal static unsafe class FakeHost { - // Registry keys: light userdata whose values are the addresses of these bytes (stable for the process). - private const int MetatableKey = 0; - private const int ObjectsKey = 1; - private const int HostKey = 2; - private static readonly nint s_keys = RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(FakeHost), 3); - - private static lua_State* s_state; - private static int s_providerCalls; - private static int s_providerSuppressed; - private static int s_pusherCalls; - private static long s_nextPointer = 0x7FF0_0000_1000; - private static nint s_forwardedPCall; - private static PCallProbe? s_activePCallProbe; - - /// - /// The Lua side of the fake host: returns the metatable, the pointer-to-object table and the host bookkeeping - /// table. Class methods receive the object table as their first argument and are handed to Lua callers as - /// closures bound to it. - /// - private static ReadOnlySpan Model => """ - local getuservalue = debug.getuservalue - local host = { destroyed = 0, classes = {} } - local objects = {} - local mt = {} - - local function state(ud) - local o = getuservalue(ud) - if o == nil then error("not a host object") end - return o - end - - mt.__index = function(ud, k) - local o = state(ud) - if type(k) == "number" then return o.items[k + 1] end - local getter = o.getters[k] - if getter ~= nil then return getter(o) end - local member = host.classes[o.class][k] - if type(member) == "function" then return function(...) return member(o, ...) end end - if member ~= nil then return member end - return o.props[k] - end - - mt.__newindex = function(ud, k, v) - local o = state(ud) - if type(k) == "number" then o.items[k + 1] = v return end - local setter = o.setters[k] - if setter ~= nil then setter(o, v) return end - o.props[k] = v - end - - host.classes.Object = { - destroy = function(o) - if o.destroyed then error("object already destroyed") end - o.destroyed = true - host.destroyed = host.destroyed + 1 - end, - getClassName = function(o) return o.class end, - } - - host.classes.Probe = setmetatable({ - getCount = function(o) return o.props.Count end, - add = function(o, a, b) return a + b end, - echo = function(o, ...) return ... end, - raise = function(o) error("raised by the host object") end, - getAddress = function(o, i) return string.format("%08X", o.addresses[i + 1]) end, - getAddressNumber = function(o, i) return o.addresses[i + 1] end, - setOther = function(o, other) o.props.Other = other end, - notAMethod = 42, - }, { __index = host.classes.Object }) - - host.classes.Stubborn = setmetatable({ - destroy = function(o) error("refuses to be destroyed") end, - }, { __index = host.classes.Object }) - - return mt, objects, host - """u8; - - public static int ProviderCalls => Volatile.Read(ref s_providerCalls); - - public static int PusherCalls => Volatile.Read(ref s_pusherCalls); - - public static nint ProviderAddress => (nint)(delegate* unmanaged[Stdcall])&Provide; - - public static nint PusherAddress => (nint)(delegate* unmanaged[Stdcall])&PushObject; - - /// - /// Points the provider at the fixture state, installs the Lua model in it and builds a binding for the calling - /// thread as main thread. - /// - public static LuaHostBinding CreateBinding(NativeLuaState state, bool withPusher = true) - { - s_state = state.L; - s_providerCalls = 0; - s_providerSuppressed = 0; - s_pusherCalls = 0; - Install(new LuaState(state.Pointer)); - delegate* unmanaged[Stdcall] provider = &Provide; - delegate* unmanaged[Stdcall] pusher = withPusher ? &PushObject : null; - return new LuaHostBinding(provider, pusher, Environment.CurrentManagedThreadId); - } - - /// - /// Makes the attached fixture binding report that this thread has no Lua state until the returned scope is - /// disposed. This exercises host-state-provider loss without detaching the runtime or changing its epoch. - /// - public static IDisposable SuppressStateProvider() - { - Volatile.Write(ref s_providerSuppressed, 1); - return new StateProviderSuppression(); - } - - /// A pointer no object has had before. Never dereferenced. - public static nint NewPointer() - { - return (nint)Interlocked.Add(ref s_nextPointer, 0x10); - } - - /// - /// Registers a fake object of (Probe, Stubborn or Object) - /// and returns its handle. is Lua code run with the object table as o: - /// o.props.Count = 3, o.getters.Bad = function() error("x") end, o.addresses = {...}. - /// - public static CEObject CreateObject(LuaState L, string className, string initializer = "") - { - var pointer = NewPointer(); - var source = - "local objects, key, ptr = ...\n" + - "local o = { ptr = ptr, class = '" + className + - "', props = {}, items = {}, getters = {}, setters = {}, addresses = {} }\n" + - initializer + "\n" + - "objects[key] = o"; - var utf8 = Encoding.UTF8.GetBytes(source); - - using LuaFrame frame = new(L); - var status = L.TryLoad(utf8, "=fakehost"u8); - FailIfNotOk(L, status, "loading the object initializer"); - Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); - L.PushLightUserdata(pointer); - L.PushInteger(pointer); - FailIfNotOk(L, L.TryCall(3, 0), "running the object initializer"); - return new CEObject(pointer); - } - - /// How many objects have been destroyed through the model's destroy since the model was installed. - public static long DestroyedCount(LuaState L) - { - using LuaFrame frame = new(L); - Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + HostKey)); - FailIfNotOk(L, L.TryGetField(-1, "destroyed"u8), "reading host.destroyed"); - return EngineTest.ReadInteger(L, -1); - } - - /// Whether the object was destroyed through the model's destroy. - public static bool IsDestroyed(LuaState L, CEObject obj) - { - using LuaFrame frame = new(L); - Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); - Assert.Equal(LuaType.Table, L.RawGetPointer(-1, obj.Value)); - FailIfNotOk(L, L.TryGetField(-1, "destroyed"u8), "reading o.destroyed"); - return L.ToBoolean(-1); - } - - /// - /// Replaces a fake scan's waitTillDone member with a bare C function and observes the protected call that - /// reaches it. This pins the managed call's Lua argument and result counts without changing production dispatch. - /// - public static PCallProbe ReplaceWaitTillDoneWithPCallProbe(LuaState L, CEObject scan, - Action? afterWaitTillDone = null) - { - using LuaFrame frame = new(L); - Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); - Assert.Equal(LuaType.Table, L.RawGetPointer(-1, scan.Value)); - Assert.True(L.TryGetField(-1, "props"u8).IsOk); - L.PushString("waitTillDone"u8); - L.PushUncheckedFunction( - new LuaNativeFunction((nint)(delegate* unmanaged[Cdecl])&WaitTillDone)); - Assert.True(L.TryRawSet(-3)); - return new PCallProbe(afterWaitTillDone); - } - - private static void Install(LuaState L) - { - using LuaFrame frame = new(L); - var status = L.TryExecute(Model, 3, "=fakehost"u8); - FailIfNotOk(L, status, "installing the fake host model"); - - // Results: mt, objects, host. rawsetp pops the top each time, so the keys are assigned in reverse. - L.RawSetPointer(LuaState.RegistryIndex, s_keys + HostKey); - L.RawSetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey); - L.RawSetPointer(LuaState.RegistryIndex, s_keys + MetatableKey); - } - - private static void FailIfNotOk(LuaState L, LuaStatus status, string what) - { - if (!status.IsOk) Assert.Fail("The fake host failed while " + what + ": " + LuaError.FromStack(L, status)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* Provide() - { - Interlocked.Increment(ref s_providerCalls); - return Volatile.Read(ref s_providerSuppressed) == 0 ? s_state : null; - } - - private sealed class StateProviderSuppression : IDisposable - { - public void Dispose() - { - Volatile.Write(ref s_providerSuppressed, 0); - } - } - - // What LuaPushClassInstance is assumed to do: a full userdata holding the object pointer, with the class metatable. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void PushObject(void* L, void* nativeObject) - { - Interlocked.Increment(ref s_pusherCalls); - var state = (lua_State*)L; - - var block = lua_newuserdata(state, (nuint)sizeof(nint)); - *(nint*)block = (nint)nativeObject; - - // [ud] -> [ud objects] -> [ud objects o] -> setuservalue(ud) -> [ud objects] -> [ud] - if (lua_rawgetp(state, LUA_REGISTRYINDEX, (void*)(s_keys + ObjectsKey)) == LUA_TTABLE) - { - _ = lua_rawgetp(state, -1, nativeObject); - lua_setuservalue(state, -3); - } - - lua_settop(state, -2); - - if (lua_rawgetp(state, LUA_REGISTRYINDEX, (void*)(s_keys + MetatableKey)) == LUA_TTABLE) - _ = lua_setmetatable(state, -2); - else - lua_settop(state, -2); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int WaitTillDone(lua_State* state) - { - s_activePCallProbe?.AfterWaitTillDone(); - return 0; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ObservePCall(lua_State* state, int argumentCount, int resultCount, int errorFunction, - nint context, nint continuation) - { - var probe = s_activePCallProbe; - if (probe is not null && - (nint)lua_tocfunction(state, -argumentCount - 1) == - (nint)(delegate* unmanaged[Cdecl])&WaitTillDone) - probe.Observe(argumentCount, resultCount); - - return ((delegate* unmanaged[Cdecl])s_forwardedPCall)( - state, - argumentCount, - resultCount, - errorFunction, - context, - continuation); - } - - /// Captures protected calls specifically to the fake no-result waitTillDone function. - internal sealed class PCallProbe : IDisposable - { - private readonly FieldInfo _pcallField; - private readonly object _table; - private readonly Action? _afterWaitTillDone; - - internal PCallProbe(Action? afterWaitTillDone) - { - if (s_activePCallProbe is not null) - throw new InvalidOperationException("Only one fake-host protected-call probe can be active."); - - var tableField = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("The Lua API table was not available for probing."); - _table = tableField.GetValue(null) - ?? throw new InvalidOperationException("The Lua API table was not initialized for probing."); - _pcallField = _table.GetType().GetField("lua_pcallk", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - "The Lua protected-call slot was not available for probing."); - s_forwardedPCall = (nint)(_pcallField.GetValue(_table) - ?? throw new InvalidOperationException("The Lua protected-call slot was null.")); - _pcallField.SetValue(_table, - (nint)(delegate* unmanaged[Cdecl])&ObservePCall); - tableField.SetValue(null, _table); - s_activePCallProbe = this; - _afterWaitTillDone = afterWaitTillDone; - } - - /// Gets how many protected calls reached the probe's waitTillDone function. - public int WaitCallCount { get; private set; } - - /// Gets the Lua argument count of the observed waitTillDone call. - public int WaitArgumentCount { get; private set; } - - /// Gets the Lua result count of the observed waitTillDone call. - public int WaitResultCount { get; private set; } - - /// - public void Dispose() - { - if (s_activePCallProbe != this) return; - - _pcallField.SetValue(_table, s_forwardedPCall); - var tableField = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic)!; - tableField.SetValue(null, _table); - s_forwardedPCall = 0; - s_activePCallProbe = null; - } - - internal void Observe(int argumentCount, int resultCount) - { - WaitCallCount++; - WaitArgumentCount = argumentCount; - WaitResultCount = resultCount; - } - - internal void AfterWaitTillDone() - { - _afterWaitTillDone?.Invoke(); - } - } + // Registry keys: light userdata whose values are the addresses of these bytes (stable for the process). + private const int MetatableKey = 0; + private const int ObjectsKey = 1; + private const int HostKey = 2; + private static readonly nint s_keys = RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(FakeHost), 3); + + private static lua_State* s_state; + private static int s_providerCalls; + private static int s_providerSuppressed; + private static int s_pusherCalls; + private static long s_nextPointer = 0x7FF0_0000_1000; + private static nint s_forwardedPCall; + private static PCallProbe? s_activePCallProbe; + + /// + /// The Lua side of the fake host: returns the metatable, the pointer-to-object table and the host bookkeeping + /// table. Class methods receive the object table as their first argument and are handed to Lua callers as + /// closures bound to it. + /// + private static ReadOnlySpan Model => """ + local getuservalue = debug.getuservalue + local host = { destroyed = 0, classes = {} } + local objects = {} + local mt = {} + + local function state(ud) + local o = getuservalue(ud) + if o == nil then error("not a host object") end + return o + end + + mt.__index = function(ud, k) + local o = state(ud) + if type(k) == "number" then return o.items[k + 1] end + local getter = o.getters[k] + if getter ~= nil then return getter(o) end + local member = host.classes[o.class][k] + if type(member) == "function" then return function(...) return member(o, ...) end end + if member ~= nil then return member end + return o.props[k] + end + + mt.__newindex = function(ud, k, v) + local o = state(ud) + if type(k) == "number" then o.items[k + 1] = v return end + local setter = o.setters[k] + if setter ~= nil then setter(o, v) return end + o.props[k] = v + end + + host.classes.Object = { + destroy = function(o) + if o.destroyed then error("object already destroyed") end + o.destroyed = true + host.destroyed = host.destroyed + 1 + end, + getClassName = function(o) return o.class end, + } + + host.classes.Probe = setmetatable({ + getCount = function(o) return o.props.Count end, + add = function(o, a, b) return a + b end, + echo = function(o, ...) return ... end, + raise = function(o) error("raised by the host object") end, + getAddress = function(o, i) return string.format("%08X", o.addresses[i + 1]) end, + getAddressNumber = function(o, i) return o.addresses[i + 1] end, + setOther = function(o, other) o.props.Other = other end, + notAMethod = 42, + }, { __index = host.classes.Object }) + + host.classes.Stubborn = setmetatable({ + destroy = function(o) error("refuses to be destroyed") end, + }, { __index = host.classes.Object }) + + return mt, objects, host + """u8; + + public static int ProviderCalls => Volatile.Read(ref s_providerCalls); + + public static int PusherCalls => Volatile.Read(ref s_pusherCalls); + + public static nint ProviderAddress => (nint) (delegate* unmanaged[Stdcall]) &Provide; + + public static nint PusherAddress => (nint) (delegate* unmanaged[Stdcall]) &PushObject; + + /// + /// Points the provider at the fixture state, installs the Lua model in it and builds a binding for the calling + /// thread as main thread. + /// + public static LuaHostBinding CreateBinding(NativeLuaState state, bool withPusher = true) + { + s_state = state.L; + s_providerCalls = 0; + s_providerSuppressed = 0; + s_pusherCalls = 0; + Install(new LuaState(state.Pointer)); + delegate* unmanaged[Stdcall] provider = &Provide; + delegate* unmanaged[Stdcall] pusher = withPusher ? &PushObject : null; + return new LuaHostBinding(provider, pusher, Environment.CurrentManagedThreadId); + } + + /// + /// Makes the attached fixture binding report that this thread has no Lua state until the returned scope is + /// disposed. This exercises host-state-provider loss without detaching the runtime or changing its epoch. + /// + public static IDisposable SuppressStateProvider() + { + Volatile.Write(ref s_providerSuppressed, 1); + return new StateProviderSuppression(); + } + + /// A pointer no object has had before. Never dereferenced. + public static nint NewPointer() + { + return (nint) Interlocked.Add(ref s_nextPointer, 0x10); + } + + /// + /// Registers a fake object of (Probe, Stubborn or Object) + /// and returns its handle. is Lua code run with the object table as o: + /// o.props.Count = 3, o.getters.Bad = function() error("x") end, o.addresses = {...}. + /// + public static CEObject CreateObject(LuaState L, string className, string initializer = "") + { + IntPtr pointer = NewPointer(); + string source = + "local objects, key, ptr = ...\n" + + "local o = { ptr = ptr, class = '" + className + + "', props = {}, items = {}, getters = {}, setters = {}, addresses = {} }\n" + + initializer + "\n" + + "objects[key] = o"; + byte[] utf8 = Encoding.UTF8.GetBytes(source); + + using LuaFrame frame = new(L); + LuaStatus status = L.TryLoad(utf8, "=fakehost"u8); + FailIfNotOk(L, status, "loading the object initializer"); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); + L.PushLightUserdata(pointer); + L.PushInteger(pointer); + FailIfNotOk(L, L.TryCall(3, 0), "running the object initializer"); + return new CEObject(pointer); + } + + /// How many objects have been destroyed through the model's destroy since the model was installed. + public static long DestroyedCount(LuaState L) + { + using LuaFrame frame = new(L); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + HostKey)); + FailIfNotOk(L, L.TryGetField(-1, "destroyed"u8), "reading host.destroyed"); + return EngineTest.ReadInteger(L, -1); + } + + /// Whether the object was destroyed through the model's destroy. + public static bool IsDestroyed(LuaState L, CEObject obj) + { + using LuaFrame frame = new(L); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); + Assert.Equal(LuaType.Table, L.RawGetPointer(-1, obj.Value)); + FailIfNotOk(L, L.TryGetField(-1, "destroyed"u8), "reading o.destroyed"); + return L.ToBoolean(-1); + } + + /// + /// Replaces a fake scan's waitTillDone member with a bare C function and observes the protected call that + /// reaches it. This pins the managed call's Lua argument and result counts without changing production dispatch. + /// + public static PCallProbe ReplaceWaitTillDoneWithPCallProbe(LuaState L, CEObject scan, + Action? afterWaitTillDone = null) + { + using LuaFrame frame = new(L); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); + Assert.Equal(LuaType.Table, L.RawGetPointer(-1, scan.Value)); + Assert.True(L.TryGetField(-1, "props"u8).IsOk); + L.PushString("waitTillDone"u8); + L.PushUncheckedFunction( + new LuaNativeFunction((nint) (delegate* unmanaged[Cdecl]) &WaitTillDone)); + Assert.True(L.TryRawSet(-3)); + return new PCallProbe(afterWaitTillDone); + } + + private static void Install(LuaState L) + { + using LuaFrame frame = new(L); + LuaStatus status = L.TryExecute(Model, 3, "=fakehost"u8); + FailIfNotOk(L, status, "installing the fake host model"); + + // Results: mt, objects, host. rawsetp pops the top each time, so the keys are assigned in reverse. + L.RawSetPointer(LuaState.RegistryIndex, s_keys + HostKey); + L.RawSetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey); + L.RawSetPointer(LuaState.RegistryIndex, s_keys + MetatableKey); + } + + private static void FailIfNotOk(LuaState L, LuaStatus status, string what) + { + if (!status.IsOk) + { + Assert.Fail("The fake host failed while " + what + ": " + LuaError.FromStack(L, status)); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* Provide() + { + Interlocked.Increment(ref s_providerCalls); + return Volatile.Read(ref s_providerSuppressed) == 0 ? s_state : null; + } + + // What LuaPushClassInstance is assumed to do: a full userdata holding the object pointer, with the class metatable. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void PushObject(void* L, void* nativeObject) + { + Interlocked.Increment(ref s_pusherCalls); + lua_State* state = (lua_State*) L; + + void* block = lua_newuserdata(state, (nuint) sizeof(nint)); + *(nint*) block = (nint) nativeObject; + + // [ud] -> [ud objects] -> [ud objects o] -> setuservalue(ud) -> [ud objects] -> [ud] + if (lua_rawgetp(state, LUA_REGISTRYINDEX, (void*) (s_keys + ObjectsKey)) == LUA_TTABLE) + { + _ = lua_rawgetp(state, -1, nativeObject); + lua_setuservalue(state, -3); + } + + lua_settop(state, -2); + + if (lua_rawgetp(state, LUA_REGISTRYINDEX, (void*) (s_keys + MetatableKey)) == LUA_TTABLE) + { + _ = lua_setmetatable(state, -2); + } + else + { + lua_settop(state, -2); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int WaitTillDone(lua_State* state) + { + s_activePCallProbe?.AfterWaitTillDone(); + return 0; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ObservePCall(lua_State* state, int argumentCount, int resultCount, int errorFunction, + nint context, nint continuation) + { + PCallProbe? probe = s_activePCallProbe; + if (probe is not null && + (nint) lua_tocfunction(state, -argumentCount - 1) == + (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) + { + probe.Observe(argumentCount, resultCount); + } + + return ((delegate* unmanaged[Cdecl]) s_forwardedPCall)( + state, + argumentCount, + resultCount, + errorFunction, + context, + continuation); + } + + private sealed class StateProviderSuppression : IDisposable + { + public void Dispose() + { + Volatile.Write(ref s_providerSuppressed, 0); + } + } + + /// Captures protected calls specifically to the fake no-result waitTillDone function. + internal sealed class PCallProbe : IDisposable + { + private readonly Action? _afterWaitTillDone; + private readonly FieldInfo _pcallField; + private readonly object _table; + + internal PCallProbe(Action? afterWaitTillDone) + { + if (s_activePCallProbe is not null) + { + throw new InvalidOperationException("Only one fake-host protected-call probe can be active."); + } + + FieldInfo tableField = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + "The Lua API table was not available for probing."); + _table = tableField.GetValue(null) + ?? throw new InvalidOperationException("The Lua API table was not initialized for probing."); + _pcallField = _table.GetType().GetField("lua_pcallk", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + "The Lua protected-call slot was not available for probing."); + s_forwardedPCall = (nint) (_pcallField.GetValue(_table) + ?? throw new InvalidOperationException("The Lua protected-call slot was null.")); + _pcallField.SetValue(_table, + (nint) (delegate* unmanaged[Cdecl]) &ObservePCall); + tableField.SetValue(null, _table); + s_activePCallProbe = this; + _afterWaitTillDone = afterWaitTillDone; + } + + /// Gets how many protected calls reached the probe's waitTillDone function. + public int WaitCallCount + { + get; + private set; + } + + /// Gets the Lua argument count of the observed waitTillDone call. + public int WaitArgumentCount + { + get; + private set; + } + + /// Gets the Lua result count of the observed waitTillDone call. + public int WaitResultCount + { + get; + private set; + } + + /// + public void Dispose() + { + if (s_activePCallProbe != this) + { + return; + } + + _pcallField.SetValue(_table, s_forwardedPCall); + FieldInfo tableField = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic)!; + tableField.SetValue(null, _table); + s_forwardedPCall = 0; + s_activePCallProbe = null; + } + + internal void Observe(int argumentCount, int resultCount) + { + WaitCallCount++; + WaitArgumentCount = argumentCount; + WaitResultCount = resultCount; + } + + internal void AfterWaitTillDone() + { + _afterWaitTillDone?.Invoke(); + } + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/HostScope.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/HostScope.cs index 1d5a6135..935e4a7c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/HostScope.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/HostScope.cs @@ -10,20 +10,26 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// internal sealed class HostScope : IDisposable { - public HostScope(NativeLuaState state, bool withPusher = true) - { - State = EngineTest.View(state); - Binding = FakeHost.CreateBinding(state, withPusher); - LuaRuntime.Attach(Binding); - } + public HostScope(NativeLuaState state, bool withPusher = true) + { + State = EngineTest.View(state); + Binding = FakeHost.CreateBinding(state, withPusher); + LuaRuntime.Attach(Binding); + } - /// The fixture state as the SDK sees it. - public LuaState State { get; } + /// The fixture state as the SDK sees it. + public LuaState State + { + get; + } - public LuaHostBinding Binding { get; } + public LuaHostBinding Binding + { + get; + } - public void Dispose() - { - LuaRuntime.Detach(); - } + public void Dispose() + { + LuaRuntime.Detach(); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs index d39f778b..3e0b7ae2 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Engine.Tables; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Tables; @@ -9,57 +10,57 @@ namespace CheatEngine.SDK.Engine.Tests.Tables; [Trait("Category", "NativeLua")] public sealed class CheatTableFilesTests { - [Fact] - public void TryLoad_and_try_save_forward_opaque_path_and_merge_arguments() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - EngineTest.Run(L, """ - loaded = nil - saved = nil - loadTable = function(path, merge) loaded = { path = path, merge = merge } end - saveTable = function(path) saved = path end - """u8); + [Fact] + public void TryLoad_and_try_save_forward_opaque_path_and_merge_arguments() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + loaded = nil + saved = nil + loadTable = function(path, merge) loaded = { path = path, merge = merge } end + saveTable = function(path) saved = path end + """u8); - var top = L.Top; - var load = CheatTableFiles.TryLoad("../unrestricted.ct", merge: true); + int top = L.Top; + LuaOperationStatus load = CheatTableFiles.TryLoad("../unrestricted.ct", true); - Assert.Equal(LuaOperationStatusKind.Success, load.Kind); - Assert.Equal(top, L.Top); - EngineTest.Run(L, "assert(loaded.path == '../unrestricted.ct' and loaded.merge)"u8); - Assert.Equal(top, L.Top); + Assert.Equal(LuaOperationStatusKind.Success, load.Kind); + Assert.Equal(top, L.Top); + EngineTest.Run(L, "assert(loaded.path == '../unrestricted.ct' and loaded.merge)"u8); + Assert.Equal(top, L.Top); - var save = CheatTableFiles.TrySave("C:/profiles/current.ct"); + LuaOperationStatus save = CheatTableFiles.TrySave("C:/profiles/current.ct"); - Assert.Equal(LuaOperationStatusKind.Success, save.Kind); - Assert.Equal(top, L.Top); - EngineTest.Run(L, "assert(saved == 'C:/profiles/current.ct')"u8); - Assert.Equal(top, L.Top); - } + Assert.Equal(LuaOperationStatusKind.Success, save.Kind); + Assert.Equal(top, L.Top); + EngineTest.Run(L, "assert(saved == 'C:/profiles/current.ct')"u8); + Assert.Equal(top, L.Top); + } - [Fact] - public void Table_file_calls_distinguish_missing_globals_and_lua_failures() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - var L = scope.State; - var top = L.Top; + [Fact] + public void Table_file_calls_distinguish_missing_globals_and_lua_failures() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + int top = L.Top; - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, CheatTableFiles.TryLoad("missing.ct", merge: false).Kind); - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, CheatTableFiles.TrySave("missing.ct").Kind); - Assert.Equal(top, L.Top); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, CheatTableFiles.TryLoad("missing.ct", false).Kind); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, CheatTableFiles.TrySave("missing.ct").Kind); + Assert.Equal(top, L.Top); - EngineTest.Run(L, """ - loadTable = function() error('load rejected') end - saveTable = function() error('save rejected') end - """u8); + EngineTest.Run(L, """ + loadTable = function() error('load rejected') end + saveTable = function() error('save rejected') end + """u8); - Assert.Equal(LuaOperationStatusKind.LuaFailure, CheatTableFiles.TryLoad("broken.ct", merge: false).Kind); - Assert.Equal(top, L.Top); - Assert.Equal(LuaOperationStatusKind.LuaFailure, CheatTableFiles.TrySave("broken.ct").Kind); - Assert.Equal(top, L.Top); - } + Assert.Equal(LuaOperationStatusKind.LuaFailure, CheatTableFiles.TryLoad("broken.ct", false).Kind); + Assert.Equal(top, L.Top); + Assert.Equal(LuaOperationStatusKind.LuaFailure, CheatTableFiles.TrySave("broken.ct").Kind); + Assert.Equal(top, L.Top); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetProcessIncarnationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetProcessIncarnationTests.cs index f929230a..8314600b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetProcessIncarnationTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetProcessIncarnationTests.cs @@ -5,38 +5,39 @@ namespace CheatEngine.SDK.Engine.Tests.Targets; /// Value-contract tests for copied target process incarnations. public sealed class TargetProcessIncarnationTests { - [Theory] - [InlineData(0, 1, "processId")] - [InlineData(-1, 1, "processId")] - [InlineData(1, 0, "startedAtUtcTicks")] - [InlineData(1, -1, "startedAtUtcTicks")] - public void Constructor_with_an_invalid_identity_component_rejects_the_component(int processId, long startedAtUtcTicks, - string parameterName) - { - var exception = Assert.Throws(() => - new TargetProcessIncarnation(processId, startedAtUtcTicks)); + [Theory] + [InlineData(0, 1, "processId")] + [InlineData(-1, 1, "processId")] + [InlineData(1, 0, "startedAtUtcTicks")] + [InlineData(1, -1, "startedAtUtcTicks")] + public void Constructor_with_an_invalid_identity_component_rejects_the_component(int processId, + long startedAtUtcTicks, + string parameterName) + { + ArgumentOutOfRangeException exception = Assert.Throws(() => + new TargetProcessIncarnation(processId, startedAtUtcTicks)); - Assert.Equal(parameterName, exception.ParamName); - } + Assert.Equal(parameterName, exception.ParamName); + } - [Fact] - public void Value_identity_uses_both_the_PID_and_observed_creation_time() - { - TargetProcessIncarnation original = new(4101, 1001); - TargetProcessIncarnation sameIncarnation = new(4101, 1001); - TargetProcessIncarnation differentTarget = new(4102, 1001); - TargetProcessIncarnation reusedPid = new(4101, 2002); + [Fact] + public void Value_identity_uses_both_the_PID_and_observed_creation_time() + { + TargetProcessIncarnation original = new(4101, 1001); + TargetProcessIncarnation sameIncarnation = new(4101, 1001); + TargetProcessIncarnation differentTarget = new(4102, 1001); + TargetProcessIncarnation reusedPid = new(4101, 2002); - Assert.True(original.Equals(sameIncarnation)); - Assert.True(original.Equals((object)sameIncarnation)); - Assert.Equal(original.GetHashCode(), sameIncarnation.GetHashCode()); - Assert.True(original == sameIncarnation); - Assert.False(original != sameIncarnation); + Assert.True(original.Equals(sameIncarnation)); + Assert.True(original.Equals((object) sameIncarnation)); + Assert.Equal(original.GetHashCode(), sameIncarnation.GetHashCode()); + Assert.True(original == sameIncarnation); + Assert.False(original != sameIncarnation); - Assert.False(original.Equals(differentTarget)); - Assert.False(original.Equals(reusedPid)); - Assert.False(original.Equals((object?)null)); - Assert.True(original != differentTarget); - Assert.True(original != reusedPid); - } + Assert.False(original.Equals(differentTarget)); + Assert.False(original.Equals(reusedPid)); + Assert.False(original.Equals(null)); + Assert.True(original != differentTarget); + Assert.True(original != reusedPid); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs index 22f72139..6b15def7 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs @@ -6,50 +6,50 @@ namespace CheatEngine.SDK.Engine.Tests.Targets; /// Outcome-contract tests for target-bound cleanup refusals and uncertainty. public sealed class TargetReleaseOutcomeTests { - [Theory] - [InlineData(TargetIdentityCheckKind.NoTargetSelected, TargetReleaseStatus.RefusedNoTarget)] - [InlineData(TargetIdentityCheckKind.GlobalUnavailable, TargetReleaseStatus.RefusedIdentityUnavailable)] - [InlineData(TargetIdentityCheckKind.InvalidResult, TargetReleaseStatus.RefusedIdentityUnavailable)] - public void Refused_cleanup_preserves_the_specific_or_unavailable_target_fact(TargetIdentityCheckKind checkKind, - TargetReleaseStatus expectedStatus) - { - var observationStatus = checkKind switch - { - TargetIdentityCheckKind.NoTargetSelected => TargetSelectionObservationStatus.NoTargetSelected, - TargetIdentityCheckKind.GlobalUnavailable => TargetSelectionObservationStatus.GlobalUnavailable, - _ => TargetSelectionObservationStatus.InvalidResult, - }; - var check = new TargetIdentityCheck(checkKind, TargetSelectionObservation.FromStatus(observationStatus)); - - var outcome = TargetReleaseOutcome.Refused(check); - - Assert.Equal(expectedStatus, outcome.Status); - Assert.Equal(check, outcome.TargetCheck); - Assert.Null(outcome.FailureKind); - Assert.True(outcome.RequiresManualRecovery); - } - - [Fact] - public void Unconfirmed_cleanup_requires_manual_recovery_and_preserves_the_boundary_failure_kind() - { - var outcome = TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); - - Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); - Assert.Null(outcome.TargetCheck); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); - Assert.True(outcome.RequiresManualRecovery); - Assert.False(TargetReleaseOutcome.Released().RequiresManualRecovery); - } - - [Fact] - public void Cleanup_that_did_not_begin_requires_manual_recovery_without_target_or_failure_detail() - { - var outcome = TargetReleaseOutcome.NotInvoked(); - - Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); - Assert.Equal((byte)7, (byte)outcome.Status); - Assert.Null(outcome.TargetCheck); - Assert.Null(outcome.FailureKind); - Assert.True(outcome.RequiresManualRecovery); - } + [Theory] + [InlineData(TargetIdentityCheckKind.NoTargetSelected, TargetReleaseStatus.RefusedNoTarget)] + [InlineData(TargetIdentityCheckKind.GlobalUnavailable, TargetReleaseStatus.RefusedIdentityUnavailable)] + [InlineData(TargetIdentityCheckKind.InvalidResult, TargetReleaseStatus.RefusedIdentityUnavailable)] + public void Refused_cleanup_preserves_the_specific_or_unavailable_target_fact(TargetIdentityCheckKind checkKind, + TargetReleaseStatus expectedStatus) + { + TargetSelectionObservationStatus observationStatus = checkKind switch + { + TargetIdentityCheckKind.NoTargetSelected => TargetSelectionObservationStatus.NoTargetSelected, + TargetIdentityCheckKind.GlobalUnavailable => TargetSelectionObservationStatus.GlobalUnavailable, + _ => TargetSelectionObservationStatus.InvalidResult + }; + TargetIdentityCheck check = new(checkKind, TargetSelectionObservation.FromStatus(observationStatus)); + + TargetReleaseOutcome outcome = TargetReleaseOutcome.Refused(check); + + Assert.Equal(expectedStatus, outcome.Status); + Assert.Equal(check, outcome.TargetCheck); + Assert.Null(outcome.FailureKind); + Assert.True(outcome.RequiresManualRecovery); + } + + [Fact] + public void Unconfirmed_cleanup_requires_manual_recovery_and_preserves_the_boundary_failure_kind() + { + TargetReleaseOutcome outcome = TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); + + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); + Assert.Null(outcome.TargetCheck); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); + Assert.True(outcome.RequiresManualRecovery); + Assert.False(TargetReleaseOutcome.Released().RequiresManualRecovery); + } + + [Fact] + public void Cleanup_that_did_not_begin_requires_manual_recovery_without_target_or_failure_detail() + { + TargetReleaseOutcome outcome = TargetReleaseOutcome.NotInvoked(); + + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); + Assert.Equal((byte) 7, (byte) outcome.Status); + Assert.Null(outcome.TargetCheck); + Assert.Null(outcome.FailureKind); + Assert.True(outcome.RequiresManualRecovery); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs index 3133b2e9..ac004249 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs @@ -1,7 +1,8 @@ -using System; using System.Text; + using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Targets; @@ -10,163 +11,165 @@ namespace CheatEngine.SDK.Engine.Tests.Targets; [Trait("Category", "NativeLua")] public sealed class TargetSelectionTests { - [Fact] - public void ObserveCurrent_with_the_current_process_id_returns_a_qualified_incarnation() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallOpenedProcessId(scope.State, Environment.ProcessId); - - var observation = TargetSelection.ObserveCurrent(); - - Assert.Equal(TargetSelectionObservationStatus.CurrentTargetQualified, observation.Status); - Assert.Equal(Environment.ProcessId, observation.SelectedProcessId); - Assert.True(observation.IsQualified); - Assert.True(observation.Incarnation.HasValue); - Assert.Equal(Environment.ProcessId, observation.Incarnation.Value.ProcessId); - Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalProcessStartTime, - observation.Evidence); - Assert.True(TargetSelection.ValidateCurrent(observation.Incarnation.Value).IsCurrent); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ObserveCurrent_with_no_Cheat_Engine_target_reports_no_target_without_inventing_an_incarnation() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallOpenedProcessId(scope.State, 0); - - var observation = TargetSelection.ObserveCurrent(); - - Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, observation.Status); - Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); - Assert.Null(observation.SelectedProcessId); - Assert.Null(observation.Incarnation); - Assert.False(observation.IsQualified); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ObserveCurrent_when_the_selection_global_is_unavailable_preserves_that_unavailable_fact() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - TargetProcessIncarnation expected = new(4101, 1001); - - var observation = TargetSelection.ObserveCurrent(); - var check = TargetSelection.ValidateCurrent(expected); - - Assert.Equal(TargetSelectionObservationStatus.GlobalUnavailable, observation.Status); - Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); - Assert.Null(observation.SelectedProcessId); - Assert.Null(observation.Incarnation); - Assert.Equal(TargetIdentityCheckKind.GlobalUnavailable, check.Kind); - Assert.False(check.IsCurrent); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ObserveCurrent_when_the_selection_callback_raises_preserves_the_Lua_failure_fact() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, "function getOpenedProcessID() error('fixture target selection failure') end"u8); - - var observation = TargetSelection.ObserveCurrent(); - - Assert.Equal(TargetSelectionObservationStatus.LuaFailure, observation.Status); - Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); - Assert.Null(observation.SelectedProcessId); - Assert.Null(observation.Incarnation); - Assert.False(observation.IsQualified); - Assert.Equal(0, scope.State.Top); - } - - [Theory] - [InlineData("function getOpenedProcessID() return true end")] - [InlineData("function getOpenedProcessID() return -1 end")] - public void ObserveCurrent_when_the_selection_callback_returns_an_invalid_PID_reports_an_invalid_result(string fixture) - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); - - var observation = TargetSelection.ObserveCurrent(); - - Assert.Equal(TargetSelectionObservationStatus.InvalidResult, observation.Status); - Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); - Assert.Null(observation.SelectedProcessId); - Assert.Null(observation.Incarnation); - Assert.False(observation.IsQualified); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ObserveCurrent_when_the_selected_PID_cannot_be_locally_qualified_keeps_the_PID_without_granting_authority() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallOpenedProcessId(scope.State, int.MaxValue); - - var observation = TargetSelection.ObserveCurrent(); - - Assert.Equal(TargetSelectionObservationStatus.CurrentTargetUnqualified, observation.Status); - Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId, observation.Evidence); - Assert.Equal(int.MaxValue, observation.SelectedProcessId); - Assert.Null(observation.Incarnation); - Assert.False(observation.IsQualified); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ValidateCurrent_distinguishes_a_different_PID_from_reuse_of_the_same_PID() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallOpenedProcessId(scope.State, Environment.ProcessId); - TargetProcessIncarnation changedTarget = new(Environment.ProcessId + 1, 1001); - TargetProcessIncarnation reusedPid = new(Environment.ProcessId, 1); - - var changed = TargetSelection.ValidateCurrent(changedTarget); - var reused = TargetSelection.ValidateCurrent(reusedPid); - - Assert.Equal(TargetIdentityCheckKind.TargetChanged, changed.Kind); - Assert.Equal(Environment.ProcessId, changed.Observed.SelectedProcessId); - Assert.Equal(TargetIdentityCheckKind.ProcessReused, reused.Kind); - Assert.Equal(Environment.ProcessId, reused.Observed.SelectedProcessId); - Assert.False(changed.IsCurrent); - Assert.False(reused.IsCurrent); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Default_identity_check_is_not_current() - { - TargetIdentityCheck check = default; - - Assert.Equal(TargetIdentityCheckKind.Unspecified, check.Kind); - Assert.False(check.IsCurrent); - } - - [Fact] - public void Default_selection_observation_is_not_qualified() - { - TargetSelectionObservation observation = default; - - Assert.Equal(TargetSelectionObservationStatus.Unspecified, observation.Status); - Assert.False(observation.IsQualified); - } - - private static void InstallOpenedProcessId(CheatEngine.SDK.Lua.State.LuaState state, int processId) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + processId + " end")); - } + [Fact] + public void ObserveCurrent_with_the_current_process_id_returns_a_qualified_incarnation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, Environment.ProcessId); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetQualified, observation.Status); + Assert.Equal(Environment.ProcessId, observation.SelectedProcessId); + Assert.True(observation.IsQualified); + Assert.True(observation.Incarnation.HasValue); + Assert.Equal(Environment.ProcessId, observation.Incarnation.Value.ProcessId); + Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalProcessStartTime, + observation.Evidence); + Assert.True(TargetSelection.ValidateCurrent(observation.Incarnation.Value).IsCurrent); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ObserveCurrent_with_no_Cheat_Engine_target_reports_no_target_without_inventing_an_incarnation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, 0); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, observation.Status); + Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); + Assert.Null(observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ObserveCurrent_when_the_selection_global_is_unavailable_preserves_that_unavailable_fact() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + TargetProcessIncarnation expected = new(4101, 1001); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + TargetIdentityCheck check = TargetSelection.ValidateCurrent(expected); + + Assert.Equal(TargetSelectionObservationStatus.GlobalUnavailable, observation.Status); + Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); + Assert.Null(observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.Equal(TargetIdentityCheckKind.GlobalUnavailable, check.Kind); + Assert.False(check.IsCurrent); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ObserveCurrent_when_the_selection_callback_raises_preserves_the_Lua_failure_fact() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, "function getOpenedProcessID() error('fixture target selection failure') end"u8); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.LuaFailure, observation.Status); + Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); + Assert.Null(observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("function getOpenedProcessID() return true end")] + [InlineData("function getOpenedProcessID() return -1 end")] + public void ObserveCurrent_when_the_selection_callback_returns_an_invalid_PID_reports_an_invalid_result( + string fixture) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.InvalidResult, observation.Status); + Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); + Assert.Null(observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void + ObserveCurrent_when_the_selected_PID_cannot_be_locally_qualified_keeps_the_PID_without_granting_authority() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, int.MaxValue); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetUnqualified, observation.Status); + Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId, observation.Evidence); + Assert.Equal(int.MaxValue, observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ValidateCurrent_distinguishes_a_different_PID_from_reuse_of_the_same_PID() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, Environment.ProcessId); + TargetProcessIncarnation changedTarget = new(Environment.ProcessId + 1, 1001); + TargetProcessIncarnation reusedPid = new(Environment.ProcessId, 1); + + TargetIdentityCheck changed = TargetSelection.ValidateCurrent(changedTarget); + TargetIdentityCheck reused = TargetSelection.ValidateCurrent(reusedPid); + + Assert.Equal(TargetIdentityCheckKind.TargetChanged, changed.Kind); + Assert.Equal(Environment.ProcessId, changed.Observed.SelectedProcessId); + Assert.Equal(TargetIdentityCheckKind.ProcessReused, reused.Kind); + Assert.Equal(Environment.ProcessId, reused.Observed.SelectedProcessId); + Assert.False(changed.IsCurrent); + Assert.False(reused.IsCurrent); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Default_identity_check_is_not_current() + { + TargetIdentityCheck check = default; + + Assert.Equal(TargetIdentityCheckKind.Unspecified, check.Kind); + Assert.False(check.IsCurrent); + } + + [Fact] + public void Default_selection_observation_is_not_qualified() + { + TargetSelectionObservation observation = default; + + Assert.Equal(TargetSelectionObservationStatus.Unspecified, observation.Status); + Assert.False(observation.IsQualified); + } + + private static void InstallOpenedProcessId(LuaState state, int processId) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + processId + " end")); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Values/AddressLuaTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Values/AddressLuaTests.cs index dc687540..6b7285a9 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Values/AddressLuaTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Values/AddressLuaTests.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Marshalling; @@ -11,148 +12,156 @@ namespace CheatEngine.SDK.Engine.Tests.Values; [Trait("Category", "NativeLua")] public sealed class AddressLuaTests { - [Fact] - public void A_number_is_read_by_bit_reinterpretation() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - - L.PushInteger(0x400000); - L.PushInteger(-1); - L.PushNumber(4198400.0); - L.PushNumber(2.5); - - Assert.True(Address.TryRead(L, 1, out var low)); - Assert.Equal(0x400000UL, low.Value); - Assert.True(Address.TryRead(L, 2, out var high)); - Assert.Equal(ulong.MaxValue, high.Value); - Assert.True(Address.TryRead(L, 3, out var integralFloat)); - Assert.Equal(0x401000UL, integralFloat.Value); - Assert.False(Address.TryRead(L, 4, out var fraction)); - Assert.Equal(Address.Zero, fraction); - Assert.Equal(4, L.Top); - } - - [Fact] - public void A_string_is_read_as_hexadecimal_never_as_decimal() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - - L.PushString("00400000"u8); - L.PushString("0x7FF6A1B2C3D4"u8); - L.PushString("10"u8); - L.PushString("kernel32.dll+10"u8); - L.PushString(""u8); - - Assert.True(Address.TryRead(L, 1, out var padded)); - Assert.Equal(0x400000UL, padded.Value); - Assert.True(Address.TryRead(L, 2, out var prefixed)); - Assert.Equal(0x7FF6A1B2C3D4UL, prefixed.Value); - Assert.True(Address.TryRead(L, 3, out var ten)); - Assert.Equal(0x10UL, ten.Value); - Assert.False(Address.TryRead(L, 4, out _)); - Assert.False(Address.TryRead(L, 5, out _)); - - // The strings were not converted in place. - Assert.Equal(LuaType.String, L.TypeOf(1)); - Assert.Equal(LuaType.String, L.TypeOf(3)); - Assert.Equal(5, L.Top); - } - - [Fact] - public void A_string_that_is_not_hexadecimal_never_falls_back_to_Lua_number_coercion() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - - // Each of these is a number to lua_tointegerx (10, 16 and 10); to this type they are text and not addresses. - // ("1e1" would not do: it is the hexadecimal 0x1E1.) - L.PushString("1e+1"u8); - L.PushString("0x1p4"u8); - L.PushString("10.0"u8); - - for (var index = 1; index <= 3; index++) - { - Assert.True(L.TryReadInteger(index, out _), - "index " + index.ToString(CultureInfo.InvariantCulture) + " should be convertible by Lua"); - Assert.False(Address.TryRead(L, index, out var address), - "index " + index.ToString(CultureInfo.InvariantCulture) + " was read as an address"); - Assert.Equal(Address.Zero, address); - Assert.Equal(LuaType.String, L.TypeOf(index)); - } - } - - [Fact] - public void Other_types_and_absent_values_are_not_addresses() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - - L.PushNil(); - L.PushBoolean(true); - L.CreateTable(); - L.PushLightUserdata(0x400000); - - for (var index = 1; index <= 5; index++) - { - Assert.False(Address.TryRead(L, index, out var address)); - Assert.Equal(Address.Zero, address); - } - } - - [Fact] - public void Push_and_read_round_trip_through_the_marshaller_contract() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - - Address high = 0xFFFF_FFFF_FFFF_FFF0; - Address.Push(L, high); - Assert.True(L.IsInteger(-1)); - Assert.True(L.TryReadInteger(-1, out var bits)); - Assert.Equal(unchecked((long)0xFFFF_FFFF_FFFF_FFF0UL), bits); - Assert.True(RoundTrip(L, out var back)); - Assert.Equal(high, back); - Assert.Equal(1, L.Top); - } - - [Fact] - public void Reading_either_form_allocates_nothing() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); - L.PushInteger(0x400000); - L.PushString("0x7FF6A1B2C3D4"u8); - ulong sink = 0; - - AllocationGate.AssertZero(() => - { - if (!Address.TryRead(L, 1, out var number)) Assert.Fail("number read failed"); - if (!Address.TryRead(L, 2, out var text)) Assert.Fail("text read failed"); - sink += number.Value + text.Value; - Address.Push(L, number); - L.Pop(1); - }); - - Assert.NotEqual(0UL, sink); - } - - private static bool RoundTrip(LuaState L, out T value) - where TMarshaller : struct, ILuaMarshaller - { - return TMarshaller.TryRead(L, -1, out value!); - } + [Fact] + public void A_number_is_read_by_bit_reinterpretation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + + L.PushInteger(0x400000); + L.PushInteger(-1); + L.PushNumber(4198400.0); + L.PushNumber(2.5); + + Assert.True(Address.TryRead(L, 1, out Address low)); + Assert.Equal(0x400000UL, low.Value); + Assert.True(Address.TryRead(L, 2, out Address high)); + Assert.Equal(ulong.MaxValue, high.Value); + Assert.True(Address.TryRead(L, 3, out Address integralFloat)); + Assert.Equal(0x401000UL, integralFloat.Value); + Assert.False(Address.TryRead(L, 4, out Address fraction)); + Assert.Equal(Address.Zero, fraction); + Assert.Equal(4, L.Top); + } + + [Fact] + public void A_string_is_read_as_hexadecimal_never_as_decimal() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + + L.PushString("00400000"u8); + L.PushString("0x7FF6A1B2C3D4"u8); + L.PushString("10"u8); + L.PushString("kernel32.dll+10"u8); + L.PushString(""u8); + + Assert.True(Address.TryRead(L, 1, out Address padded)); + Assert.Equal(0x400000UL, padded.Value); + Assert.True(Address.TryRead(L, 2, out Address prefixed)); + Assert.Equal(0x7FF6A1B2C3D4UL, prefixed.Value); + Assert.True(Address.TryRead(L, 3, out Address ten)); + Assert.Equal(0x10UL, ten.Value); + Assert.False(Address.TryRead(L, 4, out _)); + Assert.False(Address.TryRead(L, 5, out _)); + + // The strings were not converted in place. + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(LuaType.String, L.TypeOf(3)); + Assert.Equal(5, L.Top); + } + + [Fact] + public void A_string_that_is_not_hexadecimal_never_falls_back_to_Lua_number_coercion() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + + // Each of these is a number to lua_tointegerx (10, 16 and 10); to this type they are text and not addresses. + // ("1e1" would not do: it is the hexadecimal 0x1E1.) + L.PushString("1e+1"u8); + L.PushString("0x1p4"u8); + L.PushString("10.0"u8); + + for (int index = 1; index <= 3; index++) + { + Assert.True(L.TryReadInteger(index, out _), + "index " + index.ToString(CultureInfo.InvariantCulture) + " should be convertible by Lua"); + Assert.False(Address.TryRead(L, index, out Address address), + "index " + index.ToString(CultureInfo.InvariantCulture) + " was read as an address"); + Assert.Equal(Address.Zero, address); + Assert.Equal(LuaType.String, L.TypeOf(index)); + } + } + + [Fact] + public void Other_types_and_absent_values_are_not_addresses() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + + L.PushNil(); + L.PushBoolean(true); + L.CreateTable(); + L.PushLightUserdata(0x400000); + + for (int index = 1; index <= 5; index++) + { + Assert.False(Address.TryRead(L, index, out Address address)); + Assert.Equal(Address.Zero, address); + } + } + + [Fact] + public void Push_and_read_round_trip_through_the_marshaller_contract() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + + Address high = 0xFFFF_FFFF_FFFF_FFF0; + Address.Push(L, high); + Assert.True(L.IsInteger(-1)); + Assert.True(L.TryReadInteger(-1, out long bits)); + Assert.Equal(unchecked((long) 0xFFFF_FFFF_FFFF_FFF0UL), bits); + Assert.True(RoundTrip(L, out Address back)); + Assert.Equal(high, back); + Assert.Equal(1, L.Top); + } + + [Fact] + public void Reading_either_form_allocates_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); + L.PushInteger(0x400000); + L.PushString("0x7FF6A1B2C3D4"u8); + ulong sink = 0; + + AllocationGate.AssertZero(() => + { + if (!Address.TryRead(L, 1, out Address number)) + { + Assert.Fail("number read failed"); + } + + if (!Address.TryRead(L, 2, out Address text)) + { + Assert.Fail("text read failed"); + } + + sink += number.Value + text.Value; + Address.Push(L, number); + L.Pop(1); + }); + + Assert.NotEqual(0UL, sink); + } + + private static bool RoundTrip(LuaState L, out T value) + where TMarshaller : struct, ILuaMarshaller + { + return TMarshaller.TryRead(L, -1, out value!); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Values/AddressTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Values/AddressTests.cs index 37ef34aa..8347539a 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Values/AddressTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Values/AddressTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; + using CheatEngine.SDK.Engine.Values; namespace CheatEngine.SDK.Engine.Tests.Values; @@ -7,248 +8,248 @@ namespace CheatEngine.SDK.Engine.Tests.Values; /// The address value type without any Lua: parsing, formatting, culture invariance, conversions and arithmetic. public sealed class AddressTests { - [Theory] - [InlineData("00400000", 0x400000UL)] - [InlineData("400000", 0x400000UL)] - [InlineData("0x400000", 0x400000UL)] - [InlineData("0X400000", 0x400000UL)] - [InlineData("0x0", 0UL)] - [InlineData("0", 0UL)] - [InlineData("000000000000000000000000001", 1UL)] - [InlineData("7FF6A1B2C3D4", 0x7FF6A1B2C3D4UL)] - [InlineData("7ff6a1b2c3d4", 0x7FF6A1B2C3D4UL)] - [InlineData("FFFFFFFFFFFFFFFF", ulong.MaxValue)] - [InlineData("0xFFFFFFFFFFFFFFFF", ulong.MaxValue)] - [InlineData(" 0x10 ", 0x10UL)] - [InlineData("\t10\r\n", 0x10UL)] - [InlineData("10", 0x10UL)] - public void TryParse_accepts_hexadecimal_text_with_an_optional_prefix(string text, ulong expected) - { - Assert.True(Address.TryParse(text, out var fromChars)); - Assert.Equal(expected, fromChars.Value); - - Assert.True(Address.TryParse(Encoding.UTF8.GetBytes(text), out var fromBytes)); - Assert.Equal(expected, fromBytes.Value); - - Assert.Equal(new Address(expected), Address.Parse(text)); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("0x")] - [InlineData("0x ")] - [InlineData("x10")] - [InlineData("-10")] - [InlineData("+10")] - [InlineData("10h")] - [InlineData("$10")] - [InlineData("1 0")] - [InlineData("0x0x10")] - [InlineData("1G")] - [InlineData("10000000000000000")] - [InlineData("0x10000000000000000")] - [InlineData("1.5")] - [InlineData("kernel32.dll+10")] - [InlineData("\u0661\u0660")] - [InlineData("\u00a010")] - [InlineData("10\u2003")] - public void TryParse_rejects_text_that_is_not_a_hexadecimal_address(string text) - { - Assert.False(Address.TryParse(text, out var fromChars)); - Assert.Equal(Address.Zero, fromChars); - - Assert.False(Address.TryParse(Encoding.UTF8.GetBytes(text), out var fromBytes)); - Assert.Equal(Address.Zero, fromBytes); - - Assert.Throws(() => Address.Parse(text)); - } - - [Fact] - public void TryParse_of_a_null_string_is_a_failure_and_Parse_throws() - { - Assert.False(Address.TryParse((string?)null, out var address)); - Assert.Equal(Address.Zero, address); - Assert.Throws(() => Address.Parse(null!)); - } - - [Theory] - [InlineData(0UL, "00000000")] - [InlineData(0x400000UL, "00400000")] - [InlineData(0xFFFFFFFFUL, "FFFFFFFF")] - [InlineData(0x100000000UL, "0000000100000000")] - [InlineData(0x7FF6A1B2C3D4UL, "00007FF6A1B2C3D4")] - [InlineData(ulong.MaxValue, "FFFFFFFFFFFFFFFF")] - public void ToString_uses_Cheat_Engines_padded_uppercase_convention(ulong value, string expected) - { - Address address = value; + [Theory] + [InlineData("00400000", 0x400000UL)] + [InlineData("400000", 0x400000UL)] + [InlineData("0x400000", 0x400000UL)] + [InlineData("0X400000", 0x400000UL)] + [InlineData("0x0", 0UL)] + [InlineData("0", 0UL)] + [InlineData("000000000000000000000000001", 1UL)] + [InlineData("7FF6A1B2C3D4", 0x7FF6A1B2C3D4UL)] + [InlineData("7ff6a1b2c3d4", 0x7FF6A1B2C3D4UL)] + [InlineData("FFFFFFFFFFFFFFFF", ulong.MaxValue)] + [InlineData("0xFFFFFFFFFFFFFFFF", ulong.MaxValue)] + [InlineData(" 0x10 ", 0x10UL)] + [InlineData("\t10\r\n", 0x10UL)] + [InlineData("10", 0x10UL)] + public void TryParse_accepts_hexadecimal_text_with_an_optional_prefix(string text, ulong expected) + { + Assert.True(Address.TryParse(text, out Address fromChars)); + Assert.Equal(expected, fromChars.Value); + + Assert.True(Address.TryParse(Encoding.UTF8.GetBytes(text), out Address fromBytes)); + Assert.Equal(expected, fromBytes.Value); + + Assert.Equal(new Address(expected), Address.Parse(text)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("0x")] + [InlineData("0x ")] + [InlineData("x10")] + [InlineData("-10")] + [InlineData("+10")] + [InlineData("10h")] + [InlineData("$10")] + [InlineData("1 0")] + [InlineData("0x0x10")] + [InlineData("1G")] + [InlineData("10000000000000000")] + [InlineData("0x10000000000000000")] + [InlineData("1.5")] + [InlineData("kernel32.dll+10")] + [InlineData("\u0661\u0660")] + [InlineData("\u00a010")] + [InlineData("10\u2003")] + public void TryParse_rejects_text_that_is_not_a_hexadecimal_address(string text) + { + Assert.False(Address.TryParse(text, out Address fromChars)); + Assert.Equal(Address.Zero, fromChars); + + Assert.False(Address.TryParse(Encoding.UTF8.GetBytes(text), out Address fromBytes)); + Assert.Equal(Address.Zero, fromBytes); + + Assert.Throws(() => Address.Parse(text)); + } + + [Fact] + public void TryParse_of_a_null_string_is_a_failure_and_Parse_throws() + { + Assert.False(Address.TryParse((string?) null, out Address address)); + Assert.Equal(Address.Zero, address); + Assert.Throws(() => Address.Parse(null!)); + } + + [Theory] + [InlineData(0UL, "00000000")] + [InlineData(0x400000UL, "00400000")] + [InlineData(0xFFFFFFFFUL, "FFFFFFFF")] + [InlineData(0x100000000UL, "0000000100000000")] + [InlineData(0x7FF6A1B2C3D4UL, "00007FF6A1B2C3D4")] + [InlineData(ulong.MaxValue, "FFFFFFFFFFFFFFFF")] + public void ToString_uses_Cheat_Engines_padded_uppercase_convention(ulong value, string expected) + { + Address address = value; #pragma warning disable MA0011 // The parameterless ToString() is the member under test; it is culture-invariant by design. - Assert.Equal(expected, address.ToString()); + Assert.Equal(expected, address.ToString()); #pragma warning restore MA0011 - Assert.Equal(expected, address.ToString(null, null)); - Assert.Equal(expected, address.ToString("G", CultureInfo.InvariantCulture)); - Assert.Equal(expected, address.ToString(string.Empty, CultureInfo.InvariantCulture)); - Assert.Equal(expected, string.Create(CultureInfo.InvariantCulture, $"{address}")); - Assert.Equal(expected, string.Format(CultureInfo.InvariantCulture, "{0}", address)); - } - - [Theory] - [InlineData(0x400000UL, "X", "400000")] - [InlineData(0x400000UL, "x", "400000")] - [InlineData(0xABCUL, "x", "abc")] - [InlineData(0xABCUL, "X8", "00000ABC")] - [InlineData(0xABCUL, "x16", "0000000000000abc")] - [InlineData(0xABCUL, "X2", "ABC")] - [InlineData(0UL, "X", "0")] - public void ToString_with_a_hexadecimal_format_behaves_like_ulong(ulong value, string format, string expected) - { - Address address = value; - Assert.Equal(expected, address.ToString(format, CultureInfo.InvariantCulture)); - Assert.Equal(expected, string.Format(CultureInfo.InvariantCulture, "{0:" + format + "}", address)); - Assert.Equal(value.ToString(format, CultureInfo.InvariantCulture), address.ToString(format, null)); - } - - [Theory] - [InlineData("D")] - [InlineData("N")] - [InlineData("XG")] - [InlineData("X8X")] - [InlineData("0x")] - [InlineData("P")] - public void ToString_with_an_unsupported_format_throws(string format) - { - Address address = 0x10; - Assert.Throws(() => address.ToString(format, CultureInfo.InvariantCulture)); - Assert.Throws(() => address.TryFormat(new char[32], out _, format, null)); - } - - [Fact] - public void TryFormat_writes_utf16_and_utf8_and_reports_a_small_buffer() - { - Address address = 0x7FF6A1B2C3D4; - - Span chars = stackalloc char[16]; - Assert.True(address.TryFormat(chars, out var charsWritten, default, null)); - Assert.Equal("00007FF6A1B2C3D4", chars[..charsWritten].ToString()); - Assert.True(address.TryFormat(chars, out charsWritten, "x", null)); - Assert.Equal("7ff6a1b2c3d4", chars[..charsWritten].ToString()); - Assert.False(address.TryFormat(chars[..4], out charsWritten, default, null)); - Assert.Equal(0, charsWritten); - - Span bytes = stackalloc byte[16]; - Assert.True(address.TryFormat(bytes, out var bytesWritten, default, null)); - Assert.True(bytes[..bytesWritten].SequenceEqual("00007FF6A1B2C3D4"u8)); - Assert.True(address.TryFormat(bytes, out bytesWritten, "X", null)); - Assert.True(bytes[..bytesWritten].SequenceEqual("7FF6A1B2C3D4"u8)); - Assert.False(address.TryFormat(bytes[..4], out bytesWritten, default, null)); - Assert.Equal(0, bytesWritten); - - // What TryFormat writes parses back to the same address. - Assert.True(address.TryFormat(bytes, out bytesWritten, default, null)); - Assert.True(Address.TryParse(bytes[..bytesWritten], out var roundTripped)); - Assert.Equal(address, roundTripped); - } - - [Theory] - [InlineData("de-DE")] - [InlineData("tr-TR")] - [InlineData("ar-SA")] - [InlineData("fa-IR")] - public void Parsing_and_formatting_do_not_depend_on_the_current_culture(string cultureName) - { - var culture = CultureInfo.GetCultureInfo(cultureName); - var previousCulture = CultureInfo.CurrentCulture; - var previousUiCulture = CultureInfo.CurrentUICulture; - try - { - CultureInfo.CurrentCulture = culture; - CultureInfo.CurrentUICulture = culture; - - var address = Address.Parse("0x7ff6a1b2c3d4"); - Assert.Equal(0x7FF6A1B2C3D4UL, address.Value); + Assert.Equal(expected, address.ToString(null, null)); + Assert.Equal(expected, address.ToString("G", CultureInfo.InvariantCulture)); + Assert.Equal(expected, address.ToString(string.Empty, CultureInfo.InvariantCulture)); + Assert.Equal(expected, string.Create(CultureInfo.InvariantCulture, $"{address}")); + Assert.Equal(expected, string.Format(CultureInfo.InvariantCulture, "{0}", address)); + } + + [Theory] + [InlineData(0x400000UL, "X", "400000")] + [InlineData(0x400000UL, "x", "400000")] + [InlineData(0xABCUL, "x", "abc")] + [InlineData(0xABCUL, "X8", "00000ABC")] + [InlineData(0xABCUL, "x16", "0000000000000abc")] + [InlineData(0xABCUL, "X2", "ABC")] + [InlineData(0UL, "X", "0")] + public void ToString_with_a_hexadecimal_format_behaves_like_ulong(ulong value, string format, string expected) + { + Address address = value; + Assert.Equal(expected, address.ToString(format, CultureInfo.InvariantCulture)); + Assert.Equal(expected, string.Format(CultureInfo.InvariantCulture, "{0:" + format + "}", address)); + Assert.Equal(value.ToString(format, CultureInfo.InvariantCulture), address.ToString(format, null)); + } + + [Theory] + [InlineData("D")] + [InlineData("N")] + [InlineData("XG")] + [InlineData("X8X")] + [InlineData("0x")] + [InlineData("P")] + public void ToString_with_an_unsupported_format_throws(string format) + { + Address address = 0x10; + Assert.Throws(() => address.ToString(format, CultureInfo.InvariantCulture)); + Assert.Throws(() => address.TryFormat(new char[32], out _, format, null)); + } + + [Fact] + public void TryFormat_writes_utf16_and_utf8_and_reports_a_small_buffer() + { + Address address = 0x7FF6A1B2C3D4; + + Span chars = stackalloc char[16]; + Assert.True(address.TryFormat(chars, out int charsWritten, default, null)); + Assert.Equal("00007FF6A1B2C3D4", chars[..charsWritten].ToString()); + Assert.True(address.TryFormat(chars, out charsWritten, "x", null)); + Assert.Equal("7ff6a1b2c3d4", chars[..charsWritten].ToString()); + Assert.False(address.TryFormat(chars[..4], out charsWritten, default, null)); + Assert.Equal(0, charsWritten); + + Span bytes = stackalloc byte[16]; + Assert.True(address.TryFormat(bytes, out int bytesWritten, default, null)); + Assert.True(bytes[..bytesWritten].SequenceEqual("00007FF6A1B2C3D4"u8)); + Assert.True(address.TryFormat(bytes, out bytesWritten, "X", null)); + Assert.True(bytes[..bytesWritten].SequenceEqual("7FF6A1B2C3D4"u8)); + Assert.False(address.TryFormat(bytes[..4], out bytesWritten, default, null)); + Assert.Equal(0, bytesWritten); + + // What TryFormat writes parses back to the same address. + Assert.True(address.TryFormat(bytes, out bytesWritten, default, null)); + Assert.True(Address.TryParse(bytes[..bytesWritten], out Address roundTripped)); + Assert.Equal(address, roundTripped); + } + + [Theory] + [InlineData("de-DE")] + [InlineData("tr-TR")] + [InlineData("ar-SA")] + [InlineData("fa-IR")] + public void Parsing_and_formatting_do_not_depend_on_the_current_culture(string cultureName) + { + CultureInfo culture = CultureInfo.GetCultureInfo(cultureName); + CultureInfo previousCulture = CultureInfo.CurrentCulture; + CultureInfo previousUiCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + + Address address = Address.Parse("0x7ff6a1b2c3d4"); + Assert.Equal(0x7FF6A1B2C3D4UL, address.Value); #pragma warning disable MA0011 // The parameterless ToString() under a foreign culture is the point of this test. - Assert.Equal("00007FF6A1B2C3D4", address.ToString()); + Assert.Equal("00007FF6A1B2C3D4", address.ToString()); #pragma warning restore MA0011 - Assert.Equal("7ff6a1b2c3d4", address.ToString("x", culture)); - Assert.Equal("00007FF6A1B2C3D4", string.Format(culture, "{0}", address)); - Assert.True(Address.TryParse("00000000000000FF", out var parsed)); - Assert.Equal(255UL, parsed.Value); - - // The Turkish dotless i must not affect the 'x' prefix test, and Arabic-Indic digits are never digits here. - Assert.True(Address.TryParse("0X1F", out parsed)); - Assert.Equal(0x1FUL, parsed.Value); - Assert.False(Address.TryParse("\u0660\u0661", out _)); - } - finally - { - CultureInfo.CurrentCulture = previousCulture; - CultureInfo.CurrentUICulture = previousUiCulture; - } - } - - [Fact] - public void Conversions_keep_the_bits() - { - var high = Address.FromInt64(-1); - Assert.Equal(ulong.MaxValue, high.Value); - Assert.Equal(-1L, high.ToInt64()); - Assert.Equal(ulong.MaxValue, high.ToUInt64()); - Assert.Equal(ulong.MaxValue, (ulong)high); - - var low = Address.FromUInt64(0x400000); - Assert.Equal(0x400000L, low.ToInt64()); - Address implicitlyConverted = 0x400000UL; - Assert.Equal(low, implicitlyConverted); - Assert.Equal(low, Address.FromInt64(0x400000)); - - Assert.True(Address.Zero.IsZero); - Assert.False(low.IsZero); - Assert.Equal(default, Address.Zero); - } - - [Fact] - public void Arithmetic_offsets_and_wraps_like_a_pointer() - { - Address address = 0x1000; - Assert.Equal(0x1010UL, (address + 0x10).Value); - Assert.Equal(0x0FF0UL, (address - 0x10).Value); - Assert.Equal(0x0FF0UL, (address + -0x10).Value); - Assert.Equal(0x1010UL, address.Add(0x10).Value); - Assert.Equal(0x0FF0UL, address.Subtract(0x10).Value); - - Address top = ulong.MaxValue; - Assert.Equal(Address.Zero, top + 1); - Assert.Equal(top, Address.Zero - 1); - } - - [Fact] - public void Equality_ordering_and_hashing_follow_the_unsigned_value() - { - Address a = 0x10; - Address b = 0x10; - Address c = 0x20; - Address high = ulong.MaxValue; - - Assert.True(a == b); - Assert.False(a != b); - Assert.True(a.Equals(b)); - Assert.True(a.Equals((object)b)); - Assert.False(a.Equals(null)); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - - Assert.True(a < c); - Assert.True(c > a); - Assert.True(a <= b); - Assert.True(a >= b); - Assert.True(c < high); - Assert.True(a.CompareTo(c) < 0); - Assert.True(c.CompareTo(a) > 0); - Assert.Equal(0, a.CompareTo(b)); - Assert.Equal(1, a.CompareTo(null)); - Assert.Throws(() => a.CompareTo("not an address")); - - Address[] sorted = [high, c, a]; - Array.Sort(sorted); - Assert.Equal([a, c, high], sorted); - } + Assert.Equal("7ff6a1b2c3d4", address.ToString("x", culture)); + Assert.Equal("00007FF6A1B2C3D4", string.Format(culture, "{0}", address)); + Assert.True(Address.TryParse("00000000000000FF", out Address parsed)); + Assert.Equal(255UL, parsed.Value); + + // The Turkish dotless i must not affect the 'x' prefix test, and Arabic-Indic digits are never digits here. + Assert.True(Address.TryParse("0X1F", out parsed)); + Assert.Equal(0x1FUL, parsed.Value); + Assert.False(Address.TryParse("\u0660\u0661", out _)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + + [Fact] + public void Conversions_keep_the_bits() + { + Address high = Address.FromInt64(-1); + Assert.Equal(ulong.MaxValue, high.Value); + Assert.Equal(-1L, high.ToInt64()); + Assert.Equal(ulong.MaxValue, high.ToUInt64()); + Assert.Equal(ulong.MaxValue, (ulong) high); + + Address low = Address.FromUInt64(0x400000); + Assert.Equal(0x400000L, low.ToInt64()); + Address implicitlyConverted = 0x400000UL; + Assert.Equal(low, implicitlyConverted); + Assert.Equal(low, Address.FromInt64(0x400000)); + + Assert.True(Address.Zero.IsZero); + Assert.False(low.IsZero); + Assert.Equal(default, Address.Zero); + } + + [Fact] + public void Arithmetic_offsets_and_wraps_like_a_pointer() + { + Address address = 0x1000; + Assert.Equal(0x1010UL, (address + 0x10).Value); + Assert.Equal(0x0FF0UL, (address - 0x10).Value); + Assert.Equal(0x0FF0UL, (address + -0x10).Value); + Assert.Equal(0x1010UL, address.Add(0x10).Value); + Assert.Equal(0x0FF0UL, address.Subtract(0x10).Value); + + Address top = ulong.MaxValue; + Assert.Equal(Address.Zero, top + 1); + Assert.Equal(top, Address.Zero - 1); + } + + [Fact] + public void Equality_ordering_and_hashing_follow_the_unsigned_value() + { + Address a = 0x10; + Address b = 0x10; + Address c = 0x20; + Address high = ulong.MaxValue; + + Assert.True(a == b); + Assert.False(a != b); + Assert.True(a.Equals(b)); + Assert.True(a.Equals((object) b)); + Assert.False(a.Equals(null)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + + Assert.True(a < c); + Assert.True(c > a); + Assert.True(a <= b); + Assert.True(a >= b); + Assert.True(c < high); + Assert.True(a.CompareTo(c) < 0); + Assert.True(c.CompareTo(a) > 0); + Assert.Equal(0, a.CompareTo(b)); + Assert.Equal(1, a.CompareTo(null)); + Assert.Throws(() => a.CompareTo("not an address")); + + Address[] sorted = [high, c, a]; + Array.Sort(sorted); + Assert.Equal([a, c, high], sorted); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Values/IndexBaseTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Values/IndexBaseTests.cs index 27974786..9d86691c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Values/IndexBaseTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Values/IndexBaseTests.cs @@ -5,44 +5,44 @@ namespace CheatEngine.SDK.Engine.Tests.Values; /// The index-base conversions without any Lua. public sealed class IndexBaseTests { - [Fact] - public void The_two_bases_are_zero_and_one() - { - Assert.Equal(0, IndexBase.FirstObjectIndex); - Assert.Equal(1L, IndexBase.FirstLuaKey); - } + [Fact] + public void The_two_bases_are_zero_and_one() + { + Assert.Equal(0, IndexBase.FirstObjectIndex); + Assert.Equal(1L, IndexBase.FirstLuaKey); + } - [Theory] - [InlineData(0, 1L)] - [InlineData(1, 2L)] - [InlineData(41, 42L)] - [InlineData(int.MaxValue, int.MaxValue + 1L)] - public void ToLuaKey_adds_one_without_overflowing(int zeroBased, long expectedKey) - { - Assert.Equal(expectedKey, IndexBase.ToLuaKey(zeroBased)); - Assert.Equal(zeroBased, IndexBase.FromLuaKey(expectedKey)); - Assert.True(IndexBase.TryFromLuaKey(expectedKey, out var back)); - Assert.Equal(zeroBased, back); - } + [Theory] + [InlineData(0, 1L)] + [InlineData(1, 2L)] + [InlineData(41, 42L)] + [InlineData(int.MaxValue, int.MaxValue + 1L)] + public void ToLuaKey_adds_one_without_overflowing(int zeroBased, long expectedKey) + { + Assert.Equal(expectedKey, IndexBase.ToLuaKey(zeroBased)); + Assert.Equal(zeroBased, IndexBase.FromLuaKey(expectedKey)); + Assert.True(IndexBase.TryFromLuaKey(expectedKey, out int back)); + Assert.Equal(zeroBased, back); + } - [Theory] - [InlineData(-1)] - [InlineData(int.MinValue)] - public void ToLuaKey_rejects_a_negative_index(int zeroBased) - { - Assert.Throws(() => IndexBase.ToLuaKey(zeroBased)); - } + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void ToLuaKey_rejects_a_negative_index(int zeroBased) + { + Assert.Throws(() => IndexBase.ToLuaKey(zeroBased)); + } - [Theory] - [InlineData(0L)] - [InlineData(-1L)] - [InlineData(long.MinValue)] - [InlineData(int.MaxValue + 2L)] - [InlineData(long.MaxValue)] - public void FromLuaKey_rejects_keys_outside_the_sequence_range(long key) - { - Assert.False(IndexBase.TryFromLuaKey(key, out var zeroBased)); - Assert.Equal(0, zeroBased); - Assert.Throws(() => IndexBase.FromLuaKey(key)); - } + [Theory] + [InlineData(0L)] + [InlineData(-1L)] + [InlineData(long.MinValue)] + [InlineData(int.MaxValue + 2L)] + [InlineData(long.MaxValue)] + public void FromLuaKey_rejects_keys_outside_the_sequence_range(long key) + { + Assert.False(IndexBase.TryFromLuaKey(key, out int zeroBased)); + Assert.Equal(0, zeroBased); + Assert.Throws(() => IndexBase.FromLuaKey(key)); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Values/LuaSequenceTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Values/LuaSequenceTests.cs index 0f7e6766..ec9e3fd5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Values/LuaSequenceTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Values/LuaSequenceTests.cs @@ -10,86 +10,86 @@ namespace CheatEngine.SDK.Engine.Tests.Values; [Trait("Category", "NativeLua")] public sealed class LuaSequenceTests { - [Fact] - public void Protected_access_reads_and_writes_elements_by_zero_based_index() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); + [Fact] + public void Protected_access_reads_and_writes_elements_by_zero_based_index() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); - EngineTest.Run(L, "return { 10, 20, 30 }"u8, 1); - var table = L.Top; + EngineTest.Run(L, "return { 10, 20, 30 }"u8, 1); + int table = L.Top; - Assert.True(L.TryGetSequenceItem(table, 0).IsOk); - Assert.Equal(10, EngineTest.ReadInteger(L, -1)); - Assert.True(L.TryGetSequenceItem(-2, 2).IsOk); - Assert.Equal(30, EngineTest.ReadInteger(L, -1)); - Assert.True(L.TryGetSequenceItem(table, 3).IsOk); - Assert.True(L.IsNil(-1)); - L.SetTop(table); + Assert.True(L.TryGetSequenceItem(table, 0).IsOk); + Assert.Equal(10, EngineTest.ReadInteger(L, -1)); + Assert.True(L.TryGetSequenceItem(-2, 2).IsOk); + Assert.Equal(30, EngineTest.ReadInteger(L, -1)); + Assert.True(L.TryGetSequenceItem(table, 3).IsOk); + Assert.True(L.IsNil(-1)); + L.SetTop(table); - L.PushInteger(25); - Assert.True(L.TrySetSequenceItem(table, 1).IsOk); - Assert.Equal(table, L.Top); - Assert.Equal(LuaType.Number, L.RawGetIndex(table, 2)); - Assert.Equal(25, EngineTest.ReadInteger(L, -1)); - } + L.PushInteger(25); + Assert.True(L.TrySetSequenceItem(table, 1).IsOk); + Assert.Equal(table, L.Top); + Assert.Equal(LuaType.Number, L.RawGetIndex(table, 2)); + Assert.Equal(25, EngineTest.ReadInteger(L, -1)); + } - [Fact] - public void Raw_access_reads_writes_and_counts_by_zero_based_index() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); + [Fact] + public void Raw_access_reads_writes_and_counts_by_zero_based_index() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); - L.CreateTable(3); - var table = L.Top; - L.PushInteger(100); - L.RawSetSequenceItem(table, 0); - L.PushInteger(200); - L.RawSetSequenceItem(table, 1); - L.PushInteger(300); - L.RawSetSequenceItem(table, 2); + L.CreateTable(3); + int table = L.Top; + L.PushInteger(100); + L.RawSetSequenceItem(table, 0); + L.PushInteger(200); + L.RawSetSequenceItem(table, 1); + L.PushInteger(300); + L.RawSetSequenceItem(table, 2); - Assert.Equal(3, L.RawSequenceCount(table)); - Assert.Equal(LuaType.Number, L.RawGetSequenceItem(table, 2)); - Assert.Equal(300, EngineTest.ReadInteger(L, -1)); - Assert.Equal(LuaType.Nil, L.RawGetSequenceItem(table, 3)); - Assert.Equal(LuaType.Number, L.RawGetIndex(table, 1)); - Assert.Equal(100, EngineTest.ReadInteger(L, -1)); - Assert.Equal(LuaType.Nil, L.RawGetIndex(table, 0)); - } + Assert.Equal(3, L.RawSequenceCount(table)); + Assert.Equal(LuaType.Number, L.RawGetSequenceItem(table, 2)); + Assert.Equal(300, EngineTest.ReadInteger(L, -1)); + Assert.Equal(LuaType.Nil, L.RawGetSequenceItem(table, 3)); + Assert.Equal(LuaType.Number, L.RawGetIndex(table, 1)); + Assert.Equal(100, EngineTest.ReadInteger(L, -1)); + Assert.Equal(LuaType.Nil, L.RawGetIndex(table, 0)); + } - [Fact] - public void A_negative_index_is_refused_before_anything_is_pushed() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = EngineTest.View(state); - L.CreateTable(); - var top = L.Top; + [Fact] + public void A_negative_index_is_refused_before_anything_is_pushed() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = EngineTest.View(state); + L.CreateTable(); + int top = L.Top; - Assert.Throws(() => L.TryGetSequenceItem(1, -1)); - Assert.Throws(() => L.RawGetSequenceItem(1, -1)); - L.PushInteger(1); - Assert.Throws(() => L.TrySetSequenceItem(1, -1)); - Assert.Throws(() => L.RawSetSequenceItem(1, -1)); - Assert.Equal(top + 1, L.Top); - } + Assert.Throws(() => L.TryGetSequenceItem(1, -1)); + Assert.Throws(() => L.RawGetSequenceItem(1, -1)); + L.PushInteger(1); + Assert.Throws(() => L.TrySetSequenceItem(1, -1)); + Assert.Throws(() => L.RawSetSequenceItem(1, -1)); + Assert.Equal(top + 1, L.Top); + } - [Fact] - public void A_raising_index_metamethod_is_a_status_not_a_crash() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = EngineTest.View(state); - using LuaFrame frame = new(L); + [Fact] + public void A_raising_index_metamethod_is_a_status_not_a_crash() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = EngineTest.View(state); + using LuaFrame frame = new(L); - EngineTest.Run(L, "return setmetatable({}, { __index = function(t, k) error('no element ' .. k) end })"u8, 1); - var status = L.TryGetSequenceItem(-1, 4); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("no element 5", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); - } + EngineTest.Run(L, "return setmetatable({}, { __index = function(t, k) error('no element ' .. k) end })"u8, 1); + LuaStatus status = L.TryGetSequenceItem(-1, 4); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("no element 5", EngineTest.ErrorMessage(L, status), StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/AnsiNameBufferTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/AnsiNameBufferTests.cs index e408a00b..ac5ab043 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/AnsiNameBufferTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/AnsiNameBufferTests.cs @@ -5,36 +5,36 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; /// The name buffer on its own: ASCII copy, NUL handling, empty input. No Lua needed. public sealed unsafe class AnsiNameBufferTests { - [Fact] - public void ASCII_is_copied_byte_for_byte_with_a_terminating_NUL() - { - var buffer = AnsiNameBuffer.Allocate("My Trainer"u8); - - Assert.True(AnsiNameBuffer.Read(buffer).SequenceEqual("My Trainer"u8)); - Assert.Equal(0, buffer[10]); - } - - [Fact] - public void An_embedded_NUL_ends_the_name() - { - var buffer = AnsiNameBuffer.Allocate("Cut\0Here"u8); - - Assert.True(AnsiNameBuffer.Read(buffer).SequenceEqual("Cut"u8)); - } - - [Fact] - public void An_empty_name_is_one_NUL() - { - var buffer = AnsiNameBuffer.Allocate(default); - - Assert.True(buffer is not null); - Assert.Equal(0, buffer[0]); - Assert.True(AnsiNameBuffer.Read(buffer).IsEmpty); - } - - [Fact] - public void Reading_a_null_buffer_is_empty() - { - Assert.True(AnsiNameBuffer.Read(null).IsEmpty); - } + [Fact] + public void ASCII_is_copied_byte_for_byte_with_a_terminating_NUL() + { + byte* buffer = AnsiNameBuffer.Allocate("My Trainer"u8); + + Assert.True(AnsiNameBuffer.Read(buffer).SequenceEqual("My Trainer"u8)); + Assert.Equal(0, buffer[10]); + } + + [Fact] + public void An_embedded_NUL_ends_the_name() + { + byte* buffer = AnsiNameBuffer.Allocate("Cut\0Here"u8); + + Assert.True(AnsiNameBuffer.Read(buffer).SequenceEqual("Cut"u8)); + } + + [Fact] + public void An_empty_name_is_one_NUL() + { + byte* buffer = AnsiNameBuffer.Allocate(default); + + Assert.True(buffer is not null); + Assert.Equal(0, buffer[0]); + Assert.True(AnsiNameBuffer.Read(buffer).IsEmpty); + } + + [Fact] + public void Reading_a_null_buffer_is_empty() + { + Assert.True(AnsiNameBuffer.Read(null).IsEmpty); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs index a350b1c3..ff76a04b 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs @@ -8,106 +8,106 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; /// The version query, called through the record's function pointer as the host calls it. No Lua needed. public sealed unsafe class GetVersionTests { - [Fact] - public void Fills_version_6_and_the_bootstrap_name_pointer() - { - HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - PluginVersion version = default; - - var result = host.CallGetVersion(&version, sizeof(PluginVersion)); - - Assert.True(result.IsTrue); - Assert.Equal((uint)AbiConstants.SdkVersion, version.Version); - Assert.Equal((nint)host.Record.Name, (nint)version.PluginName); - Assert.Equal(sizeof(PluginVersion), PluginHost.LastVersionRecordSize); - } - - [Fact] - public void A_larger_host_record_is_accepted_and_only_the_known_fields_are_written() - { - HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var buffer = stackalloc byte[64]; - new Span(buffer, 64).Fill(0xEE); - - var result = host.CallGetVersion((PluginVersion*)buffer, 64); - - Assert.True(result.IsTrue); - Assert.Equal((uint)AbiConstants.SdkVersion, ((PluginVersion*)buffer)->Version); - Assert.Equal(0xEE, buffer[sizeof(PluginVersion)]); - Assert.Equal(0xEE, buffer[63]); - } - - [Theory] - [InlineData(1)] - [InlineData(8)] - [InlineData(15)] - public void A_positive_size_smaller_than_16_bytes_is_refused_and_left_untouched(int size) - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var buffer = stackalloc byte[16]; - new Span(buffer, 16).Fill(0xEE); - - var result = host.CallGetVersion((PluginVersion*)buffer, size); - - Assert.False(result.IsTrue); - Assert.True(new ReadOnlySpan(buffer, 16).IndexOfAnyExcept((byte)0xEE) < 0); - Assert.NotEmpty(sink.Errors("reserved")); - Assert.Equal(size, PluginHost.LastVersionRecordSize); - } - - // This version-query record has its own host size contract: a host that claims nothing (zero or negative) is not - // refused, and a host that claims enough is written. It is unrelated to InitializeManaged's opaque second integer. - [Theory] - [InlineData(0)] - [InlineData(-16)] - [InlineData(16)] - [InlineData(64)] - public void An_unknown_or_sufficient_size_writes_the_record(int size) - { - HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - PluginVersion version = default; - - var result = host.CallGetVersion(&version, size); - - Assert.True(result.IsTrue); - Assert.Equal((uint)AbiConstants.SdkVersion, version.Version); - Assert.Equal((nint)host.Record.Name, (nint)version.PluginName); - Assert.Equal(size, PluginHost.LastVersionRecordSize); - } - - [Fact] - public void A_null_record_is_refused() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - - Assert.False(host.CallGetVersion(null, sizeof(PluginVersion)).IsTrue); - Assert.NotEmpty(sink.Errors("address is zero")); - } - - [Fact] - public void Without_a_bootstrap_there_is_no_name_to_report() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var getVersion = host.Record.GetVersion; - PluginHost.ResetForTests(); - PluginVersion version = default; - - var result = getVersion(&version, sizeof(PluginVersion)); - - Assert.False(result.IsTrue); - Assert.True(version.PluginName is null); - Assert.NotEmpty(sink.Errors("bootstrap has not run")); - } + [Fact] + public void Fills_version_6_and_the_bootstrap_name_pointer() + { + HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + PluginVersion version = default; + + Bool32 result = host.CallGetVersion(&version, sizeof(PluginVersion)); + + Assert.True(result.IsTrue); + Assert.Equal((uint) AbiConstants.SdkVersion, version.Version); + Assert.Equal((nint) host.Record.Name, (nint) version.PluginName); + Assert.Equal(sizeof(PluginVersion), PluginHost.LastVersionRecordSize); + } + + [Fact] + public void A_larger_host_record_is_accepted_and_only_the_known_fields_are_written() + { + HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + byte* buffer = stackalloc byte[64]; + new Span(buffer, 64).Fill(0xEE); + + Bool32 result = host.CallGetVersion((PluginVersion*) buffer, 64); + + Assert.True(result.IsTrue); + Assert.Equal((uint) AbiConstants.SdkVersion, ((PluginVersion*) buffer)->Version); + Assert.Equal(0xEE, buffer[sizeof(PluginVersion)]); + Assert.Equal(0xEE, buffer[63]); + } + + [Theory] + [InlineData(1)] + [InlineData(8)] + [InlineData(15)] + public void A_positive_size_smaller_than_16_bytes_is_refused_and_left_untouched(int size) + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + byte* buffer = stackalloc byte[16]; + new Span(buffer, 16).Fill(0xEE); + + Bool32 result = host.CallGetVersion((PluginVersion*) buffer, size); + + Assert.False(result.IsTrue); + Assert.True(new ReadOnlySpan(buffer, 16).IndexOfAnyExcept((byte) 0xEE) < 0); + Assert.NotEmpty(sink.Errors("reserved")); + Assert.Equal(size, PluginHost.LastVersionRecordSize); + } + + // This version-query record has its own host size contract: a host that claims nothing (zero or negative) is not + // refused, and a host that claims enough is written. It is unrelated to InitializeManaged's opaque second integer. + [Theory] + [InlineData(0)] + [InlineData(-16)] + [InlineData(16)] + [InlineData(64)] + public void An_unknown_or_sufficient_size_writes_the_record(int size) + { + HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + PluginVersion version = default; + + Bool32 result = host.CallGetVersion(&version, size); + + Assert.True(result.IsTrue); + Assert.Equal((uint) AbiConstants.SdkVersion, version.Version); + Assert.Equal((nint) host.Record.Name, (nint) version.PluginName); + Assert.Equal(size, PluginHost.LastVersionRecordSize); + } + + [Fact] + public void A_null_record_is_refused() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + + Assert.False(host.CallGetVersion(null, sizeof(PluginVersion)).IsTrue); + Assert.NotEmpty(sink.Errors("address is zero")); + } + + [Fact] + public void Without_a_bootstrap_there_is_no_name_to_report() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + delegate* unmanaged[Stdcall] getVersion = host.Record.GetVersion; + PluginHost.ResetForTests(); + PluginVersion version = default; + + Bool32 result = getVersion(&version, sizeof(PluginVersion)); + + Assert.False(result.IsTrue); + Assert.True(version.PluginName is null); + Assert.NotEmpty(sink.Errors("bootstrap has not run")); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs index 4f5a391d..a1520ebd 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; @@ -13,184 +14,184 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; /// public sealed unsafe class InitializeManagedTests { - [Fact] - public void Writes_exactly_the_36_byte_record_and_nothing_past_it() - { - HostingTest.Reset(); - using HostSimulator host = new(); - - var result = host.Initialize(); - - Assert.Equal(ManagedEntryPoint.Success, result); - Assert.True(host.GuardIntact); - Assert.False(host.RecordUntouched); - Assert.Equal(36, HostSimulator.RecordSize); - Assert.True(PluginHost.IsInitialized); - - ref var record = ref host.Record; - Assert.True(record.Name is not null); - Assert.True(record.GetVersion is not null); - Assert.True(record.EnablePlugin is not null); - Assert.True(record.DisablePlugin is not null); - Assert.NotEqual((nint)record.GetVersion, (nint)record.EnablePlugin); - Assert.NotEqual((nint)record.EnablePlugin, (nint)record.DisablePlugin); - Assert.Equal((uint)AbiConstants.SdkVersion, record.Version); - Assert.Equal(0, PluginHost.LastInitRecordArgument); - } - - [Fact] - public void Writes_the_record_at_an_odd_address_without_touching_the_guard() - { - HostingTest.Reset(); - using HostSimulator host = new(true); - - Assert.Equal(1, host.Initialize()); - - Assert.True(host.GuardIntact); - Assert.Equal((uint)AbiConstants.SdkVersion, host.Record.Version); - Assert.Equal(RecordingPluginFactory.Name, Marshal.PtrToStringAnsi((nint)host.Record.Name)); - } - - [Fact] - public void Second_call_is_idempotent_and_writes_the_same_bytes_including_the_name_pointer() - { - HostingTest.Reset(); - using HostSimulator first = new(); - using HostSimulator second = new(); - - Assert.Equal(1, first.Initialize()); - Assert.Equal(1, second.Initialize()); - - Assert.True(first.RecordBytes.SequenceEqual(second.RecordBytes)); - Assert.Equal((nint)first.Record.Name, (nint)second.Record.Name); - Assert.True(second.GuardIntact); - } - - [Fact] - public void Name_is_the_ASCII_bytes_of_the_factory_name_NUL_terminated() - { - HostingTest.Reset(); - using HostSimulator host = new(); - - Assert.Equal(1, host.Initialize()); - - var written = AnsiNameBuffer.Read(host.Record.Name); - Assert.True(written.SequenceEqual(RecordingPluginFactory.Utf8Name)); - Assert.Equal(0, host.Record.Name[written.Length]); - } - - [Fact] - public void Non_ASCII_name_is_converted_to_the_process_ANSI_code_page() - { - HostingTest.Reset(); - using HostSimulator host = new(); - - Assert.Equal(1, host.Initialize()); - - // The reference conversion is the one the official bootstrap uses; the buffer must match it byte for byte. - // What U+00E9 becomes depends on the machine's ANSI code page (0xE9, a best-fit 'e', '?', or two UTF-8 bytes - // under code page 65001), so nothing here asserts a particular byte or a round trip back to the string: - // only that the ASCII prefix survived unchanged and the character produced at least one byte. - var reference = Marshal.StringToHGlobalAnsi(NonAsciiNamePluginFactory.Name); - try - { - var expected = MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)reference); - var written = AnsiNameBuffer.Read(host.Record.Name); - Assert.True(expected.SequenceEqual(written)); - Assert.True(written.StartsWith("Plugin "u8)); - Assert.True(written.Length > "Plugin "u8.Length); - Assert.Equal(0, host.Record.Name[written.Length]); - } - finally - { - Marshal.FreeHGlobal(reference); - } - } - - [Fact] - public void Null_record_address_fails_and_is_logged() - { - var sink = HostingTest.Reset(); - - var result = PluginHost.InitializeManaged(0, 36); - - Assert.Equal(ManagedEntryPoint.Failure, result); - Assert.False(PluginHost.IsInitialized); - Assert.NotEmpty(sink.Errors("address is zero")); - } - - [Theory] - [InlineData(int.MinValue)] - [InlineData(-1)] - [InlineData(0)] - [InlineData(1)] - [InlineData(35)] - [InlineData(36)] - [InlineData(40)] - [InlineData(4096)] - public void An_opaque_bootstrap_argument_is_recorded_without_changing_the_record_write(int hostArgument) - { - HostingTest.Reset(); - using HostSimulator host = new(); - - Assert.Equal(1, host.Initialize(hostArgument)); - - Assert.False(host.RecordUntouched); - Assert.True(host.GuardIntact); - Assert.Equal(hostArgument, PluginHost.LastInitRecordArgument); - } - - [Fact] - public void A_second_factory_type_is_rejected_deterministically_and_the_first_keeps_working() - { - var sink = HostingTest.Reset(); - using HostSimulator first = new(); - using HostSimulator other = new(); - using HostSimulator again = new(); - - Assert.Equal(1, first.Initialize()); - Assert.Equal(0, other.Initialize()); - Assert.Equal(0, other.Initialize()); - Assert.Equal(1, again.Initialize()); - - Assert.True(other.RecordUntouched); - Assert.True(first.RecordBytes.SequenceEqual(again.RecordBytes)); - Assert.Equal(2, sink.Errors("already registered").Count); - Assert.Contains(nameof(AlternatePluginFactory), sink.Errors("already registered")[0].Message, - StringComparison.Ordinal); - } - - [Fact] - public void A_factory_whose_name_getter_throws_fails_with_0_and_nothing_registered_so_a_good_factory_still_can() - { - var sink = HostingTest.Reset(); - using HostSimulator broken = new(); - using HostSimulator good = new(); - - var result = broken.Initialize(); - - Assert.Equal(ManagedEntryPoint.Failure, result); - Assert.True(broken.RecordUntouched); - Assert.True(broken.GuardIntact); - Assert.False(PluginHost.IsInitialized); - Assert.Equal(0, PluginHost.LastInitRecordArgument); - (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("InitializeManaged failed.")); - var exception = Assert.IsType(entry.Item3); - Assert.Contains("requested by the test", exception.Message, StringComparison.Ordinal); - - Assert.Equal(ManagedEntryPoint.Success, good.Initialize()); - Assert.True(PluginHost.IsInitialized); - Assert.True(AnsiNameBuffer.Read(good.Record.Name).SequenceEqual(RecordingPluginFactory.Utf8Name)); - } - - [Fact] - public void Trace_entries_carry_the_arguments() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - - host.Initialize(40); - - Assert.True(sink.HasEntry(HostLogLevel.Trace, "host argument 40")); - } + [Fact] + public void Writes_exactly_the_36_byte_record_and_nothing_past_it() + { + HostingTest.Reset(); + using HostSimulator host = new(); + + int result = host.Initialize(); + + Assert.Equal(ManagedEntryPoint.Success, result); + Assert.True(host.GuardIntact); + Assert.False(host.RecordUntouched); + Assert.Equal(36, HostSimulator.RecordSize); + Assert.True(PluginHost.IsInitialized); + + ref PluginInitRecord record = ref host.Record; + Assert.True(record.Name is not null); + Assert.True(record.GetVersion is not null); + Assert.True(record.EnablePlugin is not null); + Assert.True(record.DisablePlugin is not null); + Assert.NotEqual((nint) record.GetVersion, (nint) record.EnablePlugin); + Assert.NotEqual((nint) record.EnablePlugin, (nint) record.DisablePlugin); + Assert.Equal((uint) AbiConstants.SdkVersion, record.Version); + Assert.Equal(0, PluginHost.LastInitRecordArgument); + } + + [Fact] + public void Writes_the_record_at_an_odd_address_without_touching_the_guard() + { + HostingTest.Reset(); + using HostSimulator host = new(true); + + Assert.Equal(1, host.Initialize()); + + Assert.True(host.GuardIntact); + Assert.Equal((uint) AbiConstants.SdkVersion, host.Record.Version); + Assert.Equal(RecordingPluginFactory.Name, Marshal.PtrToStringAnsi((nint) host.Record.Name)); + } + + [Fact] + public void Second_call_is_idempotent_and_writes_the_same_bytes_including_the_name_pointer() + { + HostingTest.Reset(); + using HostSimulator first = new(); + using HostSimulator second = new(); + + Assert.Equal(1, first.Initialize()); + Assert.Equal(1, second.Initialize()); + + Assert.True(first.RecordBytes.SequenceEqual(second.RecordBytes)); + Assert.Equal((nint) first.Record.Name, (nint) second.Record.Name); + Assert.True(second.GuardIntact); + } + + [Fact] + public void Name_is_the_ASCII_bytes_of_the_factory_name_NUL_terminated() + { + HostingTest.Reset(); + using HostSimulator host = new(); + + Assert.Equal(1, host.Initialize()); + + ReadOnlySpan written = AnsiNameBuffer.Read(host.Record.Name); + Assert.True(written.SequenceEqual(RecordingPluginFactory.Utf8Name)); + Assert.Equal(0, host.Record.Name[written.Length]); + } + + [Fact] + public void Non_ASCII_name_is_converted_to_the_process_ANSI_code_page() + { + HostingTest.Reset(); + using HostSimulator host = new(); + + Assert.Equal(1, host.Initialize()); + + // The reference conversion is the one the official bootstrap uses; the buffer must match it byte for byte. + // What U+00E9 becomes depends on the machine's ANSI code page (0xE9, a best-fit 'e', '?', or two UTF-8 bytes + // under code page 65001), so nothing here asserts a particular byte or a round trip back to the string: + // only that the ASCII prefix survived unchanged and the character produced at least one byte. + IntPtr reference = Marshal.StringToHGlobalAnsi(NonAsciiNamePluginFactory.Name); + try + { + ReadOnlySpan expected = MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*) reference); + ReadOnlySpan written = AnsiNameBuffer.Read(host.Record.Name); + Assert.True(expected.SequenceEqual(written)); + Assert.True(written.StartsWith("Plugin "u8)); + Assert.True(written.Length > "Plugin "u8.Length); + Assert.Equal(0, host.Record.Name[written.Length]); + } + finally + { + Marshal.FreeHGlobal(reference); + } + } + + [Fact] + public void Null_record_address_fails_and_is_logged() + { + CapturingLogSink sink = HostingTest.Reset(); + + int result = PluginHost.InitializeManaged(0, 36); + + Assert.Equal(ManagedEntryPoint.Failure, result); + Assert.False(PluginHost.IsInitialized); + Assert.NotEmpty(sink.Errors("address is zero")); + } + + [Theory] + [InlineData(int.MinValue)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + [InlineData(35)] + [InlineData(36)] + [InlineData(40)] + [InlineData(4096)] + public void An_opaque_bootstrap_argument_is_recorded_without_changing_the_record_write(int hostArgument) + { + HostingTest.Reset(); + using HostSimulator host = new(); + + Assert.Equal(1, host.Initialize(hostArgument)); + + Assert.False(host.RecordUntouched); + Assert.True(host.GuardIntact); + Assert.Equal(hostArgument, PluginHost.LastInitRecordArgument); + } + + [Fact] + public void A_second_factory_type_is_rejected_deterministically_and_the_first_keeps_working() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator first = new(); + using HostSimulator other = new(); + using HostSimulator again = new(); + + Assert.Equal(1, first.Initialize()); + Assert.Equal(0, other.Initialize()); + Assert.Equal(0, other.Initialize()); + Assert.Equal(1, again.Initialize()); + + Assert.True(other.RecordUntouched); + Assert.True(first.RecordBytes.SequenceEqual(again.RecordBytes)); + Assert.Equal(2, sink.Errors("already registered").Count); + Assert.Contains(nameof(AlternatePluginFactory), sink.Errors("already registered")[0].Message, + StringComparison.Ordinal); + } + + [Fact] + public void A_factory_whose_name_getter_throws_fails_with_0_and_nothing_registered_so_a_good_factory_still_can() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator broken = new(); + using HostSimulator good = new(); + + int result = broken.Initialize(); + + Assert.Equal(ManagedEntryPoint.Failure, result); + Assert.True(broken.RecordUntouched); + Assert.True(broken.GuardIntact); + Assert.False(PluginHost.IsInitialized); + Assert.Equal(0, PluginHost.LastInitRecordArgument); + (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("InitializeManaged failed.")); + NotSupportedException exception = Assert.IsType(entry.Item3); + Assert.Contains("requested by the test", exception.Message, StringComparison.Ordinal); + + Assert.Equal(ManagedEntryPoint.Success, good.Initialize()); + Assert.True(PluginHost.IsInitialized); + Assert.True(AnsiNameBuffer.Read(good.Record.Name).SequenceEqual(RecordingPluginFactory.Utf8Name)); + } + + [Fact] + public void Trace_entries_carry_the_arguments() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + + host.Initialize(40); + + Assert.True(sink.HasEntry(HostLogLevel.Trace, "host argument 40")); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs index 26fbad4a..4bb6484e 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Tests.Support; using CheatEngine.SDK.Lua.Interop.Api; @@ -14,103 +15,114 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; /// public sealed class LuaModuleLocatorTests { - // Small system DLLs that a test process does not map on its own. - private static readonly string[] SystemDlls = ["msftedit.dll", "winhttp.dll", "wintrust.dll", "cabinet.dll"]; - - [Fact] - public void A_refused_bind_releases_the_reference_the_lookup_added() - { - var name = FindUnmappedSystemDll(); - var owner = NativeLibrary.Load(name); - try - { - Assert.True(LuaModule.TryGetLoaded(name, out var handle)); - Assert.Equal(owner, handle); - - Assert.False(LuaModuleLocator.BindLocated(handle, true, out var failure)); - - Assert.Contains("could not be bound", failure, StringComparison.Ordinal); - Assert.True(IsMapped(name)); // the owner's reference is untouched - NativeLibrary.Free(owner); - Assert.False(IsMapped(name)); // and the lookup's reference is gone - } - finally - { - Unload(name); - } - } - - [Fact] - public void A_handle_the_caller_owns_is_never_released() - { - var name = FindUnmappedSystemDll(); - var owner = NativeLibrary.Load(name); - try - { - Assert.False(LuaModuleLocator.BindLocated(owner, false, out _)); - - Assert.True(IsMapped(name)); - NativeLibrary.Free(owner); - Assert.False(IsMapped(name)); - } - finally - { - Unload(name); - } - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Binding_again_the_module_the_table_is_bound_to_succeeds_and_keeps_it_bound() - { - HostingTest.RequireNativeLua(); - Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); - - // The fixture bound the table to this module when it loaded the DLL. - Assert.True(LuaModule.TryGetLoaded(NativeLuaLibrary.LibraryPath!, out var handle)); - Assert.Equal(NativeLuaLibrary.Handle, handle); - - Assert.True(LuaModuleLocator.BindLocated(handle, true, out var failure)); - - Assert.Null(failure); - Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); - } - - private static string FindUnmappedSystemDll() - { - string? found = null; - if (OperatingSystem.IsWindows()) - foreach (var candidate in SystemDlls) - { - if (IsMapped(candidate) || !NativeLibrary.TryLoad(candidate, out var probe)) continue; - - NativeLibrary.Free(probe); - if (IsMapped(candidate)) continue; - - found = candidate; - break; - } - - Assert.SkipUnless(found is not null, "No system DLL that this process leaves unmapped was found."); - return found!; - } - - // A successful lookup adds a reference: it is handed straight back, so the probe never changes the count. - private static bool IsMapped(string name) - { - if (!LuaModule.TryGetLoaded(name, out var handle)) return false; - - NativeLibrary.Free(handle); - return true; - } - - // Teardown for a failed assertion: drains whatever the test left behind, one net reference per turn. - private static void Unload(string name) - { - while (LuaModule.TryGetLoaded(name, out var handle)) - { - NativeLibrary.Free(handle); - NativeLibrary.Free(handle); - } - } + // Small system DLLs that a test process does not map on its own. + private static readonly string[] SystemDlls = ["msftedit.dll", "winhttp.dll", "wintrust.dll", "cabinet.dll"]; + + [Fact] + public void A_refused_bind_releases_the_reference_the_lookup_added() + { + string name = FindUnmappedSystemDll(); + IntPtr owner = NativeLibrary.Load(name); + try + { + Assert.True(LuaModule.TryGetLoaded(name, out IntPtr handle)); + Assert.Equal(owner, handle); + + Assert.False(LuaModuleLocator.BindLocated(handle, true, out string? failure)); + + Assert.Contains("could not be bound", failure, StringComparison.Ordinal); + Assert.True(IsMapped(name)); // the owner's reference is untouched + NativeLibrary.Free(owner); + Assert.False(IsMapped(name)); // and the lookup's reference is gone + } + finally + { + Unload(name); + } + } + + [Fact] + public void A_handle_the_caller_owns_is_never_released() + { + string name = FindUnmappedSystemDll(); + IntPtr owner = NativeLibrary.Load(name); + try + { + Assert.False(LuaModuleLocator.BindLocated(owner, false, out _)); + + Assert.True(IsMapped(name)); + NativeLibrary.Free(owner); + Assert.False(IsMapped(name)); + } + finally + { + Unload(name); + } + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Binding_again_the_module_the_table_is_bound_to_succeeds_and_keeps_it_bound() + { + HostingTest.RequireNativeLua(); + Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); + + // The fixture bound the table to this module when it loaded the DLL. + Assert.True(LuaModule.TryGetLoaded(NativeLuaLibrary.LibraryPath!, out IntPtr handle)); + Assert.Equal(NativeLuaLibrary.Handle, handle); + + Assert.True(LuaModuleLocator.BindLocated(handle, true, out string? failure)); + + Assert.Null(failure); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + } + + private static string FindUnmappedSystemDll() + { + string? found = null; + if (OperatingSystem.IsWindows()) + { + foreach (string candidate in SystemDlls) + { + if (IsMapped(candidate) || !NativeLibrary.TryLoad(candidate, out IntPtr probe)) + { + continue; + } + + NativeLibrary.Free(probe); + if (IsMapped(candidate)) + { + continue; + } + + found = candidate; + break; + } + } + + Assert.SkipUnless(found is not null, "No system DLL that this process leaves unmapped was found."); + return found!; + } + + // A successful lookup adds a reference: it is handed straight back, so the probe never changes the count. + private static bool IsMapped(string name) + { + if (!LuaModule.TryGetLoaded(name, out IntPtr handle)) + { + return false; + } + + NativeLibrary.Free(handle); + return true; + } + + // Teardown for a failed assertion: drains whatever the test left behind, one net reference per turn. + private static void Unload(string name) + { + while (LuaModule.TryGetLoaded(name, out IntPtr handle)) + { + NativeLibrary.Free(handle); + NativeLibrary.Free(handle); + } + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogTests.cs index f3b53df4..649aacb3 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogTests.cs @@ -9,96 +9,96 @@ namespace CheatEngine.SDK.Hosting.Tests.Diagnostics; /// public sealed class HostLogTests : IDisposable { - public HostLogTests() - { - HostLog.ResetForTests(); - } - - public void Dispose() - { - HostLog.ResetForTests(); - } - - [Fact] - public void Defaults_are_the_debug_output_sink_at_Information() - { - Assert.Same(DebugOutputLogSink.Instance, HostLog.Sink); - Assert.Equal(HostLogLevel.Information, HostLog.MinimumLevel); - Assert.False(HostLog.IsEnabled(HostLogLevel.Trace)); - Assert.True(HostLog.IsEnabled(HostLogLevel.Information)); - Assert.True(HostLog.IsEnabled(HostLogLevel.Error)); - } - - [Fact] - public void Setting_a_null_sink_restores_the_default() - { - HostLog.Sink = new CapturingLogSink(); - Assert.IsType(HostLog.Sink); - - HostLog.Sink = null!; - - Assert.Same(DebugOutputLogSink.Instance, HostLog.Sink); - } - - [Fact] - public void Entries_below_the_minimum_level_are_not_delivered() - { - CapturingLogSink sink = new(); - HostLog.Sink = sink; - HostLog.MinimumLevel = HostLogLevel.Warning; - - HostLog.Write(HostLogLevel.Trace, "trace"); - HostLog.Write(HostLogLevel.Information, "info"); - HostLog.Write(HostLogLevel.Warning, "warn"); - HostLog.Write(HostLogLevel.Error, "error", new InvalidOperationException("boom")); - - Assert.Equal(2, sink.Entries.Count); - Assert.Equal((HostLogLevel.Warning, "warn", null), sink.Entries[0]); - Assert.Equal(HostLogLevel.Error, sink.Entries[1].Level); - Assert.Equal("error", sink.Entries[1].Message); - Assert.IsType(sink.Entries[1].Exception); - } - - [Fact] - public void A_null_message_is_delivered_as_empty() - { - CapturingLogSink sink = new(); - HostLog.Sink = sink; - - HostLog.Write(HostLogLevel.Error, null); - - Assert.Equal(string.Empty, Assert.Single(sink.Entries).Message); - } - - [Fact] - public void A_throwing_sink_never_escapes() - { - HostLog.Sink = new ThrowingLogSink(); - - var escaped = Record.Exception(() => HostLog.Write(HostLogLevel.Error, "anything")); - - Assert.Null(escaped); - } - - [Fact] - public void The_debug_output_sink_accepts_every_shape_of_entry() - { - var escaped = Record.Exception(() => - { - DebugOutputLogSink.Instance.Write(HostLogLevel.Trace, "plain", null); - DebugOutputLogSink.Instance.Write(HostLogLevel.Error, "with exception", - new InvalidOperationException("boom")); - DebugOutputLogSink.Instance.Write(HostLogLevel.Warning, string.Empty, null); - }); - - Assert.Null(escaped); - } - - private sealed class ThrowingLogSink : IHostLogSink - { - public void Write(HostLogLevel level, string message, Exception? exception) - { - throw new NotSupportedException("sink failure"); - } - } + public HostLogTests() + { + HostLog.ResetForTests(); + } + + public void Dispose() + { + HostLog.ResetForTests(); + } + + [Fact] + public void Defaults_are_the_debug_output_sink_at_Information() + { + Assert.Same(DebugOutputLogSink.Instance, HostLog.Sink); + Assert.Equal(HostLogLevel.Information, HostLog.MinimumLevel); + Assert.False(HostLog.IsEnabled(HostLogLevel.Trace)); + Assert.True(HostLog.IsEnabled(HostLogLevel.Information)); + Assert.True(HostLog.IsEnabled(HostLogLevel.Error)); + } + + [Fact] + public void Setting_a_null_sink_restores_the_default() + { + HostLog.Sink = new CapturingLogSink(); + Assert.IsType(HostLog.Sink); + + HostLog.Sink = null!; + + Assert.Same(DebugOutputLogSink.Instance, HostLog.Sink); + } + + [Fact] + public void Entries_below_the_minimum_level_are_not_delivered() + { + CapturingLogSink sink = new(); + HostLog.Sink = sink; + HostLog.MinimumLevel = HostLogLevel.Warning; + + HostLog.Write(HostLogLevel.Trace, "trace"); + HostLog.Write(HostLogLevel.Information, "info"); + HostLog.Write(HostLogLevel.Warning, "warn"); + HostLog.Write(HostLogLevel.Error, "error", new InvalidOperationException("boom")); + + Assert.Equal(2, sink.Entries.Count); + Assert.Equal((HostLogLevel.Warning, "warn", null), sink.Entries[0]); + Assert.Equal(HostLogLevel.Error, sink.Entries[1].Level); + Assert.Equal("error", sink.Entries[1].Message); + Assert.IsType(sink.Entries[1].Exception); + } + + [Fact] + public void A_null_message_is_delivered_as_empty() + { + CapturingLogSink sink = new(); + HostLog.Sink = sink; + + HostLog.Write(HostLogLevel.Error, null); + + Assert.Equal(string.Empty, Assert.Single(sink.Entries).Message); + } + + [Fact] + public void A_throwing_sink_never_escapes() + { + HostLog.Sink = new ThrowingLogSink(); + + Exception? escaped = Record.Exception(() => HostLog.Write(HostLogLevel.Error, "anything")); + + Assert.Null(escaped); + } + + [Fact] + public void The_debug_output_sink_accepts_every_shape_of_entry() + { + Exception? escaped = Record.Exception(() => + { + DebugOutputLogSink.Instance.Write(HostLogLevel.Trace, "plain", null); + DebugOutputLogSink.Instance.Write(HostLogLevel.Error, "with exception", + new InvalidOperationException("boom")); + DebugOutputLogSink.Instance.Write(HostLogLevel.Warning, string.Empty, null); + }); + + Assert.Null(escaped); + } + + private sealed class ThrowingLogSink : IHostLogSink + { + public void Write(HostLogLevel level, string message, Exception? exception) + { + throw new NotSupportedException("sink failure"); + } + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs index a34c4b5c..1cec9838 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs @@ -1,16 +1,16 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Tests.Support; using CheatEngine.SDK.Hosting.Threading; using CheatEngine.SDK.Lua.Callbacks; -using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; -using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -28,10 +28,10 @@ public sealed unsafe class AdmissionLifecycleTests public void Disable_nested_in_inline_main_thread_work_is_refused_and_the_enable_stands() { HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); + CapturingLogSink sink = HostingTest.Reset(); using NativeLuaState state = new(); using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); + RecordingPlugin plugin = HostingTest.Enable(host, state); StrongBox nestedResult = new(); MainThread.Invoke(static state => state.Result.Value = state.Host.CallDisable(), @@ -54,20 +54,21 @@ public void A_stale_context_cannot_admit_work_after_reenable() using NativeLuaState state = new(); using HostSimulator host = new(); HostingTest.Enable(host, state, 1); - var stale = PluginHost.Context!; + PluginContext stale = PluginHost.Context!; Assert.True(host.CallDisable().IsTrue); - var exports = FakeExports.Create(); + ManagedExportedFunctions exports = FakeExports.Create(); Assert.True(host.CallEnable(&exports, 2).IsTrue); - var current = PluginHost.Context!; + PluginContext current = PluginHost.Context!; - var failure = Assert.Throws(() => PluginHost.AdmitMainThreadWork(stale)); + InvalidOperationException failure = + Assert.Throws(() => PluginHost.AdmitMainThreadWork(stale)); Assert.Contains("stopping or disabled", failure.Message, StringComparison.Ordinal); Assert.False(stale.IsCurrent); Assert.True(stale.ShutdownToken.IsCancellationRequested); Assert.True(current.IsCurrent); - var currentAdmission = PluginHost.AdmitMainThreadWork(current); + PluginHost.MainThreadWorkAdmission currentAdmission = PluginHost.AdmitMainThreadWork(current); Assert.NotNull(currentAdmission); currentAdmission.Dispose(); } @@ -85,7 +86,7 @@ public void A_worker_cannot_queue_work_after_disable_has_closed_admission() HostingTest.Enable(host, state); StrongBox workerFailure = new(); StrongBox workerCompleted = new(); - var dispatchAttempts = 0; + int dispatchAttempts = 0; MainThreadDispatcher.DispatchOverrideForTests = _ => Interlocked.Increment(ref dispatchAttempts); RecordingPlugin.NestedCallInOnDisable = () => StartAdmissionClosedWorker( workerFailure, @@ -96,7 +97,7 @@ public void A_worker_cannot_queue_work_after_disable_has_closed_admission() Assert.True(host.CallDisable().IsTrue); Assert.True(workerCompleted.Value); - var failure = Assert.IsType(workerFailure.Value); + InvalidOperationException failure = Assert.IsType(workerFailure.Value); Assert.Contains("no longer accepts new main-thread dispatch", failure.Message, StringComparison.Ordinal); Assert.Equal(0, dispatchAttempts); } @@ -111,12 +112,12 @@ public void A_worker_cannot_queue_work_after_disable_has_closed_admission() public void A_throwing_shutdown_registration_is_neutralized_and_cleanup_completes() { HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); + CapturingLogSink sink = HostingTest.Reset(); using NativeLuaState state = new(); using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); - var context = PluginHost.Context!; - using var registration = context.ShutdownToken.Register(static () => + RecordingPlugin plugin = HostingTest.Enable(host, state); + PluginContext context = PluginHost.Context!; + using CancellationTokenRegistration registration = context.ShutdownToken.Register(static () => throw new InvalidOperationException("shutdown registration failure requested by the test")); Assert.True(host.CallDisable().IsTrue); @@ -137,14 +138,15 @@ public void A_callback_from_the_previous_enable_fails_before_invoking_plugin_cod using NativeLuaState state = new(); using HostSimulator host = new(); HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); + LuaState L = LuaRuntime.AcquireState(); CallbackInvocationCounter counter = new(); - Assert.True(LuaCallback.TryCreate(L, new LuaNativeFunction(&CountInvocation), counter, out var callback).IsOk); + Assert.True(LuaCallback.TryCreate(L, new LuaNativeFunction(&CountInvocation), counter, + out LuaCallback? callback).IsOk); Assert.NotNull(callback); Assert.True(callback.TryRegister(L, "staleLifecycleCallback"u8).IsOk); Assert.True(host.CallDisable().IsTrue); - var exports = FakeExports.Create(); + ManagedExportedFunctions exports = FakeExports.Create(); Assert.True(host.CallEnable(&exports, 2).IsTrue); using (LuaFrame frame = new(L)) @@ -165,13 +167,17 @@ private static Bool32 StartAdmissionClosedWorker( { Thread worker = new(() => { - workerFailure.Value = Record.Exception(() => MainThread.Invoke(static _ => { }, 0)); + workerFailure.Value = Record.Exception(() => MainThread.Invoke(static _ => + { + }, 0)); workerCompleted.Value = true; }); worker.Start(); if (!worker.Join(TimeSpan.FromSeconds(5))) + { throw new TimeoutException("The admission-closed worker did not return."); + } return Bool32.True; } @@ -183,7 +189,9 @@ private static int CountInvocation(nint pointer) try { if (!LuaThunk.TryGetState(state, out CallbackInvocationCounter? counter)) + { return LuaThunk.Fail(state, "callback released"u8); + } counter.InvocationCount++; return 0; @@ -196,6 +204,10 @@ private static int CountInvocation(nint pointer) private sealed class CallbackInvocationCounter { - public int InvocationCount { get; set; } + public int InvocationCount + { + get; + set; + } } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs index 380e2027..9208ba07 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs @@ -1,14 +1,19 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi; +using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Tests.Support; using CheatEngine.SDK.Hosting.Threading; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; @@ -16,399 +21,403 @@ namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; /// The disable callback and the enable-disable-enable cycle Cheat Engine's plugin dialog produces. public sealed unsafe class DisablePluginTests { - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_from_a_non_main_thread_is_refused_without_starting_cleanup() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); - var context = PluginHost.Context!; - StrongBox result = new(); - StrongBox workerFailure = new(); - Thread worker = new(() => - { - try - { - result.Value = host.CallDisable(); - } - catch (Exception exception) - { - workerFailure.Value = exception; - } - }); - - worker.Start(); - Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "The non-main lifecycle callback did not return."); - - Assert.Null(workerFailure.Value); - Assert.False(result.Value.IsTrue); - Assert.Equal(0, plugin.DisableCalls); - Assert.True(PluginHost.IsEnabled); - Assert.Same(context, PluginHost.Context); - Assert.True(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("other than the captured plugin main thread")); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_from_an_admitted_Lua_operation_is_refused_without_changing_the_lifecycle() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); - - using (LuaRuntime.AcquireOperation()) - { - Assert.False(host.CallDisable().IsTrue); - } - - Assert.Equal(0, plugin.DisableCalls); - Assert.True(PluginHost.IsEnabled); - Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); - Assert.True(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("admitted Lua operation")); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_from_executing_dispatched_work_is_refused_without_waiting_for_that_work() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); - var cancellationToken = TestContext.Current.CancellationToken; - using ManualResetEventSlim queued = new(initialState: false); - using ManualResetEventSlim workFinished = new(initialState: false); - StrongBox queuedWork = new(); - StrongBox nestedResult = new(); - StrongBox workerFailure = new(); - MainThreadDispatcher.DispatchOverrideForTests = item => - { - queuedWork.Value = item; - queued.Set(); - workFinished.Wait(cancellationToken); - }; - - Thread worker = new(() => - { - try - { - MainThread.Invoke(_ => nestedResult.Value = host.CallDisable(), 0); - } - catch (Exception exception) - { - workerFailure.Value = exception; - } - }); - - worker.Start(); - Assert.True(queued.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The worker did not queue main-thread work."); - MainThreadDispatcher.ExecuteQueuedWorkForTests(queuedWork.Value!); - workFinished.Set(); - Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "The dispatched worker did not return."); - - Assert.Null(workerFailure.Value); - Assert.False(nestedResult.Value.IsTrue); - Assert.Equal(0, plugin.DisableCalls); - Assert.True(PluginHost.IsEnabled); - Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); - Assert.True(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("dispatched main-thread work")); - } - - [Fact] - [Trait("Category", "NativeLua")] - [SuppressMessage("Meziantou.Analyzer", "MA0051", - Justification = - "This test deliberately covers the complete close-drain-detach sequence in one deterministic scenario.")] - [SuppressMessage("xUnit.Analyzers", "xUnit1051", - Justification = - "The bounded host-thread barrier is a deterministic synchronization point independent of test cancellation.")] - public void Disable_on_the_GUI_thread_pumps_admitted_worker_work_before_detaching() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var context = PluginHost.Context!; - var cancellationToken = TestContext.Current.CancellationToken; - using ManualResetEventSlim queued = new(initialState: false); - using ManualResetEventSlim workExecuted = new(initialState: false); - StrongBox queuedWork = new(); - Exception? workerFailure = null; - var observedShutdown = false; - var executedThreadId = new int[1]; - var workerResult = 0; - MainThreadDispatcher.DispatchOverrideForTests = item => - { - queuedWork.Value = item; - queued.Set(); - workExecuted.Wait(cancellationToken); - }; - FakeExports.CheckSynchronizeHandlerForTests = () => - { - var item = queuedWork.Value; - if (item is null) return; - - observedShutdown = context.ShutdownToken.IsCancellationRequested; - MainThreadDispatcher.ExecuteQueuedWorkForTests(item); - workExecuted.Set(); - }; - Thread worker = new(() => - { - try - { - workerResult = MainThread.Invoke( - static threadId => - { - threadId[0] = Environment.CurrentManagedThreadId; - return 6 * 7; - }, - executedThreadId); - } - catch (Exception exception) - { - workerFailure = exception; - } - }); - - worker.Start(); - Assert.True(queued.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The worker did not queue MainThread.Invoke work."); - - // The worker is inside MainThread.Invoke and waits for the queue-capable host fake. Disable must call - // CheckSynchronize instead of blindly waiting on the GUI thread, then wait for that real Invoke to return - // before Lua callbacks are neutralized. - Assert.True(host.CallDisable().IsTrue); - Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "The queued worker did not terminate."); - Assert.Null(workerFailure); - Assert.Equal(42, workerResult); - Assert.True(observedShutdown); - Assert.Equal(Environment.CurrentManagedThreadId, executedThreadId[0]); - Assert.True(FakeExports.CheckSynchronizeCalls > 0); - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); - } - - [Fact] - public void Disabling_while_disabled_is_a_no_op_reported_as_TRUE_with_a_warning() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - - var result = host.CallDisable(); - - Assert.True(result.IsTrue); - Assert.False(PluginHost.IsEnabled); - Assert.True(sink.HasEntry(HostLogLevel.Warning, "not enabled")); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_runs_OnDisable_while_attached_then_detaches_and_withdraws_the_context() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state, 5); - var context = PluginHost.Context!; - - var result = host.CallDisable(); - - Assert.True(result.IsTrue); - Assert.Equal(1, plugin.DisableCalls); - Assert.True(plugin.RuntimeAttachedInOnDisable); - Assert.False(plugin.HostEnabledInOnDisable); - Assert.False(PluginHost.IsEnabled); - Assert.Null(PluginHost.Context); - Assert.False(LuaRuntime.IsAttached); - Assert.False(context.IsCurrent); - Assert.True(context.ShutdownToken.IsCancellationRequested); - Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); - Assert.False(MainThread.IsMainThread); - Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 5 disabled")); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_neutralizes_the_callbacks_the_plugin_forgot() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); - LuaCallback? callback = null; - try - { - Assert.True(LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), out callback).IsOk); - Assert.NotNull(callback); - Assert.True(callback.IsCurrent); - - Assert.True(host.CallDisable().IsTrue); - - Assert.True(callback.IsReleased); - } - finally - { - callback?.Dispose(); - } - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_closes_host_subscription_callback_admission_before_OnDisable_then_unregisters_once() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); - Action? hostCallback = null; - var callbackCalls = 0; - var unregisterCalls = 0; - - Assert.True(LuaHostSubscription.TryRegister(L, () => callbackCalls++, - (registrationState, callback) => - { - hostCallback = callback; - return releaseState => unregisterCalls++; - }, out var subscription)); - Assert.NotNull(subscription); - RecordingPlugin.NestedCallInOnDisable = () => - { - hostCallback!(); - return Bool32.True; - }; - - Assert.True(host.CallDisable().IsTrue); - - Assert.Equal(0, callbackCalls); - Assert.Equal(1, unregisterCalls); - Assert.True(subscription.IsDisposed); - Assert.Equal(0, LuaHostSubscriptionRegistry.Count); - } - - // The test-infrastructure counterpart of the previous test: a test that ends with the plugin still enabled and a - // callback still alive must not turn into a use-after-free in whichever test runs next. The simulator's disposal - // is the teardown, and it runs before the state (declared first) is closed. - [Fact] - [Trait("Category", "NativeLua")] - public void A_callback_forgotten_by_a_test_is_released_when_the_simulator_is_disposed_while_the_state_is_open() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); - Assert.True(LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), out var forgotten).IsOk); - Assert.NotNull(forgotten); - - host.Dispose(); // what the using statement does at the end of a test, with the state still open - - Assert.True(forgotten.IsReleased); - Assert.Null(forgotten.StateObject); - Assert.True(FakeExports.Create().GetLuaState() is null); // the provider hands out nothing any more - Assert.False(PluginHost.IsEnabled); - Assert.False(PluginHost.IsInitialized); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); // the state is still open and balanced - host.Dispose(); // idempotent: the using statement disposes it again - } - - [Fact] - [Trait("Category", "NativeLua")] - public void OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_disabled() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); - RecordingPlugin.ThrowInOnDisable = true; - - var result = host.CallDisable(); - - Assert.True(result.IsTrue); - Assert.Equal(1, plugin.DisableCalls); - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("OnDisable threw")); - Assert.IsType(entry.Item3); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Enable_disable_enable_reuses_the_instance_and_attaches_with_a_new_epoch() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state, 1); - var first = PluginHost.Context!; - - Assert.True(host.CallDisable().IsTrue); - var exports = FakeExports.Create(); - Assert.True(host.CallEnable(&exports, 2).IsTrue); - var second = PluginHost.Context!; - - Assert.Same(plugin, PluginHost.PluginForTests); - Assert.Equal(1, RecordingPlugin.ConstructorCalls); - Assert.Equal(2, plugin.EnableCalls); - Assert.Equal(1, plugin.DisableCalls); - Assert.NotSame(first, second); - Assert.Equal(first.Epoch + 1, second.Epoch); - Assert.Equal(LuaRuntime.Epoch, second.Epoch); - Assert.Equal(2u, second.PluginId); - Assert.False(first.IsCurrent); - Assert.True(second.IsCurrent); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(42, plugin.LuaResultInOnEnable); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_reference_cached_in_one_enable_is_stale_in_the_next() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); - L.PushInteger(1); - var reference = L.CreateRef(); - Assert.True(reference.IsCurrent); - - Assert.True(host.CallDisable().IsTrue); - var exports = FakeExports.Create(); - Assert.True(host.CallEnable(&exports, 1).IsTrue); - - Assert.False(reference.IsCurrent); - Assert.False(LuaRuntime.AcquireState().TryPushRef(reference)); - reference.Dispose(); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int NoOpThunk(nint handle) - { - return 0; - } + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_from_a_non_main_thread_is_refused_without_starting_cleanup() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state); + PluginContext context = PluginHost.Context!; + StrongBox result = new(); + StrongBox workerFailure = new(); + Thread worker = new(() => + { + try + { + result.Value = host.CallDisable(); + } + catch (Exception exception) + { + workerFailure.Value = exception; + } + }); + + worker.Start(); + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "The non-main lifecycle callback did not return."); + + Assert.Null(workerFailure.Value); + Assert.False(result.Value.IsTrue); + Assert.Equal(0, plugin.DisableCalls); + Assert.True(PluginHost.IsEnabled); + Assert.Same(context, PluginHost.Context); + Assert.True(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("other than the captured plugin main thread")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_from_an_admitted_Lua_operation_is_refused_without_changing_the_lifecycle() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state); + + using (LuaRuntime.AcquireOperation()) + { + Assert.False(host.CallDisable().IsTrue); + } + + Assert.Equal(0, plugin.DisableCalls); + Assert.True(PluginHost.IsEnabled); + Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); + Assert.True(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("admitted Lua operation")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_from_executing_dispatched_work_is_refused_without_waiting_for_that_work() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim queued = new(false); + using ManualResetEventSlim workFinished = new(false); + StrongBox queuedWork = new(); + StrongBox nestedResult = new(); + StrongBox workerFailure = new(); + MainThreadDispatcher.DispatchOverrideForTests = item => + { + queuedWork.Value = item; + queued.Set(); + workFinished.Wait(cancellationToken); + }; + + Thread worker = new(() => + { + try + { + MainThread.Invoke(_ => nestedResult.Value = host.CallDisable(), 0); + } + catch (Exception exception) + { + workerFailure.Value = exception; + } + }); + + worker.Start(); + Assert.True(queued.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The worker did not queue main-thread work."); + MainThreadDispatcher.ExecuteQueuedWorkForTests(queuedWork.Value!); + workFinished.Set(); + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "The dispatched worker did not return."); + + Assert.Null(workerFailure.Value); + Assert.False(nestedResult.Value.IsTrue); + Assert.Equal(0, plugin.DisableCalls); + Assert.True(PluginHost.IsEnabled); + Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); + Assert.True(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("dispatched main-thread work")); + } + + [Fact] + [Trait("Category", "NativeLua")] + [SuppressMessage("Meziantou.Analyzer", "MA0051", + Justification = + "This test deliberately covers the complete close-drain-detach sequence in one deterministic scenario.")] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = + "The bounded host-thread barrier is a deterministic synchronization point independent of test cancellation.")] + public void Disable_on_the_GUI_thread_pumps_admitted_worker_work_before_detaching() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + PluginContext context = PluginHost.Context!; + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim queued = new(false); + using ManualResetEventSlim workExecuted = new(false); + StrongBox queuedWork = new(); + Exception? workerFailure = null; + bool observedShutdown = false; + int[] executedThreadId = new int[1]; + int workerResult = 0; + MainThreadDispatcher.DispatchOverrideForTests = item => + { + queuedWork.Value = item; + queued.Set(); + workExecuted.Wait(cancellationToken); + }; + FakeExports.CheckSynchronizeHandlerForTests = () => + { + MainThreadWorkItem? item = queuedWork.Value; + if (item is null) + { + return; + } + + observedShutdown = context.ShutdownToken.IsCancellationRequested; + MainThreadDispatcher.ExecuteQueuedWorkForTests(item); + workExecuted.Set(); + }; + Thread worker = new(() => + { + try + { + workerResult = MainThread.Invoke( + static threadId => + { + threadId[0] = Environment.CurrentManagedThreadId; + return 6 * 7; + }, + executedThreadId); + } + catch (Exception exception) + { + workerFailure = exception; + } + }); + + worker.Start(); + Assert.True(queued.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The worker did not queue MainThread.Invoke work."); + + // The worker is inside MainThread.Invoke and waits for the queue-capable host fake. Disable must call + // CheckSynchronize instead of blindly waiting on the GUI thread, then wait for that real Invoke to return + // before Lua callbacks are neutralized. + Assert.True(host.CallDisable().IsTrue); + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "The queued worker did not terminate."); + Assert.Null(workerFailure); + Assert.Equal(42, workerResult); + Assert.True(observedShutdown); + Assert.Equal(Environment.CurrentManagedThreadId, executedThreadId[0]); + Assert.True(FakeExports.CheckSynchronizeCalls > 0); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + } + + [Fact] + public void Disabling_while_disabled_is_a_no_op_reported_as_TRUE_with_a_warning() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + + Bool32 result = host.CallDisable(); + + Assert.True(result.IsTrue); + Assert.False(PluginHost.IsEnabled); + Assert.True(sink.HasEntry(HostLogLevel.Warning, "not enabled")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_runs_OnDisable_while_attached_then_detaches_and_withdraws_the_context() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state, 5); + PluginContext context = PluginHost.Context!; + + Bool32 result = host.CallDisable(); + + Assert.True(result.IsTrue); + Assert.Equal(1, plugin.DisableCalls); + Assert.True(plugin.RuntimeAttachedInOnDisable); + Assert.False(plugin.HostEnabledInOnDisable); + Assert.False(PluginHost.IsEnabled); + Assert.Null(PluginHost.Context); + Assert.False(LuaRuntime.IsAttached); + Assert.False(context.IsCurrent); + Assert.True(context.ShutdownToken.IsCancellationRequested); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + Assert.False(MainThread.IsMainThread); + Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 5 disabled")); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_neutralizes_the_callbacks_the_plugin_forgot() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState L = LuaRuntime.AcquireState(); + LuaCallback? callback = null; + try + { + Assert.True(LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), out callback).IsOk); + Assert.NotNull(callback); + Assert.True(callback.IsCurrent); + + Assert.True(host.CallDisable().IsTrue); + + Assert.True(callback.IsReleased); + } + finally + { + callback?.Dispose(); + } + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_closes_host_subscription_callback_admission_before_OnDisable_then_unregisters_once() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState L = LuaRuntime.AcquireState(); + Action? hostCallback = null; + int callbackCalls = 0; + int unregisterCalls = 0; + + Assert.True(LuaHostSubscription.TryRegister(L, () => callbackCalls++, + (registrationState, callback) => + { + hostCallback = callback; + return releaseState => unregisterCalls++; + }, out LuaHostSubscription? subscription)); + Assert.NotNull(subscription); + RecordingPlugin.NestedCallInOnDisable = () => + { + hostCallback!(); + return Bool32.True; + }; + + Assert.True(host.CallDisable().IsTrue); + + Assert.Equal(0, callbackCalls); + Assert.Equal(1, unregisterCalls); + Assert.True(subscription.IsDisposed); + Assert.Equal(0, LuaHostSubscriptionRegistry.Count); + } + + // The test-infrastructure counterpart of the previous test: a test that ends with the plugin still enabled and a + // callback still alive must not turn into a use-after-free in whichever test runs next. The simulator's disposal + // is the teardown, and it runs before the state (declared first) is closed. + [Fact] + [Trait("Category", "NativeLua")] + public void A_callback_forgotten_by_a_test_is_released_when_the_simulator_is_disposed_while_the_state_is_open() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState L = LuaRuntime.AcquireState(); + Assert.True(LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), + out LuaCallback? forgotten).IsOk); + Assert.NotNull(forgotten); + + host.Dispose(); // what the using statement does at the end of a test, with the state still open + + Assert.True(forgotten.IsReleased); + Assert.Null(forgotten.StateObject); + Assert.True(FakeExports.Create().GetLuaState() is null); // the provider hands out nothing any more + Assert.False(PluginHost.IsEnabled); + Assert.False(PluginHost.IsInitialized); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); // the state is still open and balanced + host.Dispose(); // idempotent: the using statement disposes it again + } + + [Fact] + [Trait("Category", "NativeLua")] + public void OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_disabled() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state); + RecordingPlugin.ThrowInOnDisable = true; + + Bool32 result = host.CallDisable(); + + Assert.True(result.IsTrue); + Assert.Equal(1, plugin.DisableCalls); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("OnDisable threw")); + Assert.IsType(entry.Item3); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Enable_disable_enable_reuses_the_instance_and_attaches_with_a_new_epoch() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state, 1); + PluginContext first = PluginHost.Context!; + + Assert.True(host.CallDisable().IsTrue); + ManagedExportedFunctions exports = FakeExports.Create(); + Assert.True(host.CallEnable(&exports, 2).IsTrue); + PluginContext second = PluginHost.Context!; + + Assert.Same(plugin, PluginHost.PluginForTests); + Assert.Equal(1, RecordingPlugin.ConstructorCalls); + Assert.Equal(2, plugin.EnableCalls); + Assert.Equal(1, plugin.DisableCalls); + Assert.NotSame(first, second); + Assert.Equal(first.Epoch + 1, second.Epoch); + Assert.Equal(LuaRuntime.Epoch, second.Epoch); + Assert.Equal(2u, second.PluginId); + Assert.False(first.IsCurrent); + Assert.True(second.IsCurrent); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(42, plugin.LuaResultInOnEnable); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_reference_cached_in_one_enable_is_stale_in_the_next() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState L = LuaRuntime.AcquireState(); + L.PushInteger(1); + LuaRef reference = L.CreateRef(); + Assert.True(reference.IsCurrent); + + Assert.True(host.CallDisable().IsTrue); + ManagedExportedFunctions exports = FakeExports.Create(); + Assert.True(host.CallEnable(&exports, 1).IsTrue); + + Assert.False(reference.IsCurrent); + Assert.False(LuaRuntime.AcquireState().TryPushRef(reference)); + reference.Dispose(); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int NoOpThunk(nint handle) + { + return 0; + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs index 9fd15c68..58779d5c 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; + +using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Context; @@ -20,418 +22,424 @@ namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; /// public sealed unsafe class EnablePluginTests { - [Fact] - public void A_null_exports_record_fails_without_touching_Lua() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - - var result = host.CallEnable(null, 1); - - Assert.False(result.IsTrue); - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("address is zero")); - } - - [Theory] - [InlineData(0)] - [InlineData(24)] - [InlineData(40)] - [InlineData(47)] - [InlineData(-48)] - public void An_undersized_exports_record_fails_cleanly(int reportedSize) - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - exports.SizeOfExportedFunctions = reportedSize; - - var result = host.CallEnable(&exports, 1); - - Assert.False(result.IsTrue); - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(0, RecordingPlugin.ConstructorCalls); - Assert.NotEmpty(sink.Errors(reportedSize.ToString(CultureInfo.InvariantCulture) + "-byte exports record")); - } - - [Fact] - public void A_record_without_GetLuaState_fails() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - exports.GetLuaState = null; - - Assert.False(host.CallEnable(&exports, 1).IsTrue); - Assert.NotEmpty(sink.Errors("no GetLuaState")); - } - - [Fact] - public void Enable_before_the_bootstrap_fails() - { - var sink = HostingTest.Reset(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var enable = host.Record.EnablePlugin; - PluginHost.ResetForTests(); - var exports = FakeExports.Create(); - - Assert.False(enable(&exports, 1).IsTrue); - Assert.NotEmpty(sink.Errors("bootstrap has not run")); - } - - [Fact] - public void Without_a_Lua_module_in_the_process_the_enable_fails_before_any_plugin_code() - { - var sink = HostingTest.Reset(); - HostingTest.UseNoModule(); - using HostSimulator host = new(); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - - var result = host.CallEnable(&exports, 1); - - Assert.False(result.IsTrue); - Assert.False(PluginHost.IsEnabled); - Assert.Equal(0, RecordingPlugin.ConstructorCalls); - Assert.Equal(0, FakeExports.GetLuaStateCalls); - Assert.NotEmpty(sink.Errors("resolver returned no Lua module")); - } - - [Fact] - [Trait("Category", "NativeLua")] - [SuppressMessage("Meziantou.Analyzer", "MA0051", - Justification = - "This test verifies every lifecycle invariant after a successful enable and intentionally keeps the assertions together.")] - public void Enables_the_plugin_binds_Lua_attaches_the_runtime_and_runs_OnEnable_on_this_thread() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - - var result = host.CallEnable(&exports, 42); - - Assert.True(result.IsTrue); - Assert.True(PluginHost.IsEnabled); - Assert.True(LuaApi.IsInitialized); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(FakeExports.GetLuaStateAddress, LuaRuntime.CurrentBinding.StateProvider); - Assert.Equal(FakeExports.PusherAddress, LuaRuntime.CurrentBinding.HostObjectPusher); - Assert.Equal(Environment.CurrentManagedThreadId, LuaRuntime.CurrentBinding.MainThreadId); - - var plugin = Assert.IsType(PluginHost.PluginForTests); - Assert.Equal(1, RecordingPlugin.ConstructorCalls); - Assert.False(RecordingPlugin.RuntimeAttachedInConstructor); - Assert.False(RecordingPlugin.HostEnabledInConstructor); - Assert.Equal(1, plugin.EnableCalls); - Assert.Equal(Environment.CurrentManagedThreadId, plugin.EnableThreadId); - Assert.True(plugin.RuntimeAttachedInOnEnable); - Assert.False(plugin.HostEnabledInOnEnable); - Assert.True(plugin.MainThreadInOnEnable); - Assert.Equal(42, plugin.LuaResultInOnEnable); - - var context = Assert.IsType(PluginHost.Context); - Assert.Same(context, plugin.ContextInOnEnable); - Assert.Equal(42u, context.PluginId); - Assert.Equal(LuaRuntime.Epoch, context.Epoch); - Assert.Equal(Environment.CurrentManagedThreadId, context.MainThreadId); - Assert.True(context.IsCurrent); - Assert.True(context.IsMainThread); - Assert.Equal(48, context.ReportedExportsSize); - Assert.True(context.HasProcessMessages); - Assert.True(context.HasCheckSynchronize); - Assert.Equal(LuaRuntime.CurrentBinding, context.HostBinding); - Assert.False(context.ShutdownToken.IsCancellationRequested); - Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); - - Assert.Equal(0, FakeExports.LuaRegisterCalls); - Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 42 enabled")); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void The_exports_record_is_copied_during_the_call_not_referenced() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - - Assert.True(host.CallEnable(&exports, 1).IsTrue); - exports = default; // the host's stack local dies after the call - - MainThread.ProcessMessages(); - Assert.Equal(1, FakeExports.ProcessMessagesCalls); - Assert.Equal(FakeExports.GetLuaStateAddress, LuaRuntime.CurrentBinding.StateProvider); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_longer_exports_record_is_accepted_and_its_tail_ignored() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - var buffer = stackalloc byte[64]; - new Span(buffer, 64).Fill(0xFF); - *(ManagedExportedFunctions*)buffer = FakeExports.Create(64); - - Assert.True(host.CallEnable((ManagedExportedFunctions*)buffer, 3).IsTrue); - - Assert.Equal(64, PluginHost.Context!.ReportedExportsSize); - Assert.Equal(0xFF, buffer[48]); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_state_provider_that_returns_null_fails_the_self_check() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - FakeExports.UseState(null); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - - Assert.False(host.CallEnable(&exports, 1).IsTrue); - - Assert.False(PluginHost.IsEnabled); - Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(0, RecordingPlugin.ConstructorCalls); - Assert.NotEmpty(sink.Errors("returned no state")); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.ThrowInOnEnable = true; - var exports = FakeExports.Create(); - - var result = host.CallEnable(&exports, 1); - - Assert.False(result.IsTrue); - Assert.False(PluginHost.IsEnabled); - Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); - Assert.Null(PluginHost.Context); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(1, RecordingPlugin.LastConstructed!.EnableCalls); - Assert.True(RecordingPlugin.LastConstructed.ContextInOnEnable!.ShutdownToken.IsCancellationRequested); - (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("OnEnable threw")); - var exception = Assert.IsType(entry.Item3); - Assert.Contains("requested by the test", exception.Message, StringComparison.Ordinal); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retryable() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.ThrowInOnEnable = true; - RecordingPlugin.CreateCallbacksInOnEnable = true; - var releases = 0; - LuaCallbackRegistry.AfterReleaseForTesting = () => - { - if (++releases == 1) throw new InvalidOperationException("deterministic failed-enable cleanup failure"); - }; - var exports = FakeExports.Create(); - - try - { - Assert.False(host.CallEnable(&exports, 1).IsTrue); - - var plugin = RecordingPlugin.LastConstructed!; - Assert.NotNull(plugin); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(PluginHostLifecyclePhase.Disabling, PluginHost.Phase); - Assert.NotNull(PluginHost.Context); - Assert.True(plugin.CallbackTwo!.IsReleased); - Assert.False(plugin.CallbackOne!.IsReleased); - Assert.NotEmpty(sink.Errors("shutdown remains incomplete")); - - LuaCallbackRegistry.AfterReleaseForTesting = null; - Assert.True(host.CallDisable().IsTrue); - Assert.False(LuaRuntime.IsAttached); - Assert.Null(PluginHost.Context); - Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); - Assert.True(plugin.CallbackOne.IsReleased); - } - finally - { - LuaCallbackRegistry.AfterReleaseForTesting = null; - } - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwinds() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.ThrowInOnEnable = true; - RecordingPlugin.CreateCallbacksInOnEnable = true; - var releases = 0; - LuaCallbackRegistry.AfterReleaseForTesting = () => - { - if (++releases == 1) throw new InvalidOperationException("deterministic failed-enable cleanup failure"); - }; - var nestedRequested = false; - var nestedResult = true; - var exports = FakeExports.Create(); - - try - { - Assert.False(host.CallEnable(&exports, 1).IsTrue); - - releases = 0; - sink.OnMessage = message => - { - if (!nestedRequested && message.Contains("shutdown remains incomplete", StringComparison.Ordinal)) - { - nestedRequested = true; - nestedResult = host.CallDisable().IsTrue; - } - }; - Assert.False(host.CallDisable().IsTrue); - Assert.True(nestedRequested); - Assert.False(nestedResult); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(PluginHostLifecyclePhase.Disabling, PluginHost.Phase); - Assert.True(sink.HasEntry(HostLogLevel.Error, "a disable transition is already completing")); - - LuaCallbackRegistry.AfterReleaseForTesting = null; - sink.OnMessage = null; - Assert.True(host.CallDisable().IsTrue); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); - } - finally - { - LuaCallbackRegistry.AfterReleaseForTesting = null; - sink.OnMessage = null; - } - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_throwing_constructor_fails_the_enable_and_is_retried_on_the_next_enable() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.ThrowInConstructor = true; - var exports = FakeExports.Create(); - - Assert.False(host.CallEnable(&exports, 1).IsTrue); - - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.Null(PluginHost.PluginForTests); - Assert.Equal(1, RecordingPlugin.ConstructorCalls); - (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("constructor threw")); - Assert.IsType(entry.Item3); - - RecordingPlugin.ThrowInConstructor = false; - Assert.True(host.CallEnable(&exports, 1).IsTrue); - Assert.Equal(2, RecordingPlugin.ConstructorCalls); - Assert.True(PluginHost.IsEnabled); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_factory_that_returns_null_fails_the_enable() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - Assert.Equal(1, host.Initialize()); - var exports = FakeExports.Create(); - - Assert.False(host.CallEnable(&exports, 1).IsTrue); - - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("returned null")); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Enabling_twice_without_a_disable_is_ignored_with_a_warning() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state); - var context = PluginHost.Context!; - var exports = FakeExports.Create(); - - Assert.True(host.CallEnable(&exports, 99).IsTrue); - - Assert.Equal(1, plugin.EnableCalls); - Assert.Same(context, PluginHost.Context); - Assert.Equal(7u, PluginHost.Context!.PluginId); - Assert.True(sink.HasEntry(HostLogLevel.Warning, "already enabled")); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void The_production_module_lookup_finds_the_fixture_when_it_is_Cheat_Engines_DLL() - { - HostingTest.RequireNativeLua(); - Assert.SkipUnless( - string.Equals(Path.GetFileName(NativeLuaLibrary.LibraryPath), LuaModule.CheatEngine64ModuleName, - StringComparison.OrdinalIgnoreCase), - "The fixture is not named " + LuaModule.CheatEngine64ModuleName + - ", so the loaded-module lookup cannot find it."); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - FakeExports.UseState(state.L); - LuaModuleLocator.Resolver = null; // production lookup: GetModuleHandleExW("lua53-64.dll") - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(); - - Assert.True(host.CallEnable(&exports, 1).IsTrue); - Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); - } + [Fact] + public void A_null_exports_record_fails_without_touching_Lua() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + + Bool32 result = host.CallEnable(null, 1); + + Assert.False(result.IsTrue); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("address is zero")); + } + + [Theory] + [InlineData(0)] + [InlineData(24)] + [InlineData(40)] + [InlineData(47)] + [InlineData(-48)] + public void An_undersized_exports_record_fails_cleanly(int reportedSize) + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + exports.SizeOfExportedFunctions = reportedSize; + + Bool32 result = host.CallEnable(&exports, 1); + + Assert.False(result.IsTrue); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(0, RecordingPlugin.ConstructorCalls); + Assert.NotEmpty(sink.Errors(reportedSize.ToString(CultureInfo.InvariantCulture) + "-byte exports record")); + } + + [Fact] + public void A_record_without_GetLuaState_fails() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + exports.GetLuaState = null; + + Assert.False(host.CallEnable(&exports, 1).IsTrue); + Assert.NotEmpty(sink.Errors("no GetLuaState")); + } + + [Fact] + public void Enable_before_the_bootstrap_fails() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + delegate* unmanaged[Stdcall] enable = host.Record.EnablePlugin; + PluginHost.ResetForTests(); + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.False(enable(&exports, 1).IsTrue); + Assert.NotEmpty(sink.Errors("bootstrap has not run")); + } + + [Fact] + public void Without_a_Lua_module_in_the_process_the_enable_fails_before_any_plugin_code() + { + CapturingLogSink sink = HostingTest.Reset(); + HostingTest.UseNoModule(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 result = host.CallEnable(&exports, 1); + + Assert.False(result.IsTrue); + Assert.False(PluginHost.IsEnabled); + Assert.Equal(0, RecordingPlugin.ConstructorCalls); + Assert.Equal(0, FakeExports.GetLuaStateCalls); + Assert.NotEmpty(sink.Errors("resolver returned no Lua module")); + } + + [Fact] + [Trait("Category", "NativeLua")] + [SuppressMessage("Meziantou.Analyzer", "MA0051", + Justification = + "This test verifies every lifecycle invariant after a successful enable and intentionally keeps the assertions together.")] + public void Enables_the_plugin_binds_Lua_attaches_the_runtime_and_runs_OnEnable_on_this_thread() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 result = host.CallEnable(&exports, 42); + + Assert.True(result.IsTrue); + Assert.True(PluginHost.IsEnabled); + Assert.True(LuaApi.IsInitialized); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(FakeExports.GetLuaStateAddress, LuaRuntime.CurrentBinding.StateProvider); + Assert.Equal(FakeExports.PusherAddress, LuaRuntime.CurrentBinding.HostObjectPusher); + Assert.Equal(Environment.CurrentManagedThreadId, LuaRuntime.CurrentBinding.MainThreadId); + + RecordingPlugin plugin = Assert.IsType(PluginHost.PluginForTests); + Assert.Equal(1, RecordingPlugin.ConstructorCalls); + Assert.False(RecordingPlugin.RuntimeAttachedInConstructor); + Assert.False(RecordingPlugin.HostEnabledInConstructor); + Assert.Equal(1, plugin.EnableCalls); + Assert.Equal(Environment.CurrentManagedThreadId, plugin.EnableThreadId); + Assert.True(plugin.RuntimeAttachedInOnEnable); + Assert.False(plugin.HostEnabledInOnEnable); + Assert.True(plugin.MainThreadInOnEnable); + Assert.Equal(42, plugin.LuaResultInOnEnable); + + PluginContext context = Assert.IsType(PluginHost.Context); + Assert.Same(context, plugin.ContextInOnEnable); + Assert.Equal(42u, context.PluginId); + Assert.Equal(LuaRuntime.Epoch, context.Epoch); + Assert.Equal(Environment.CurrentManagedThreadId, context.MainThreadId); + Assert.True(context.IsCurrent); + Assert.True(context.IsMainThread); + Assert.Equal(48, context.ReportedExportsSize); + Assert.True(context.HasProcessMessages); + Assert.True(context.HasCheckSynchronize); + Assert.Equal(LuaRuntime.CurrentBinding, context.HostBinding); + Assert.False(context.ShutdownToken.IsCancellationRequested); + Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); + + Assert.Equal(0, FakeExports.LuaRegisterCalls); + Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 42 enabled")); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void The_exports_record_is_copied_during_the_call_not_referenced() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.True(host.CallEnable(&exports, 1).IsTrue); + exports = default; // the host's stack local dies after the call + + MainThread.ProcessMessages(); + Assert.Equal(1, FakeExports.ProcessMessagesCalls); + Assert.Equal(FakeExports.GetLuaStateAddress, LuaRuntime.CurrentBinding.StateProvider); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_longer_exports_record_is_accepted_and_its_tail_ignored() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + byte* buffer = stackalloc byte[64]; + new Span(buffer, 64).Fill(0xFF); + *(ManagedExportedFunctions*) buffer = FakeExports.Create(64); + + Assert.True(host.CallEnable((ManagedExportedFunctions*) buffer, 3).IsTrue); + + Assert.Equal(64, PluginHost.Context!.ReportedExportsSize); + Assert.Equal(0xFF, buffer[48]); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_state_provider_that_returns_null_fails_the_self_check() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + FakeExports.UseState(null); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.False(host.CallEnable(&exports, 1).IsTrue); + + Assert.False(PluginHost.IsEnabled); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(0, RecordingPlugin.ConstructorCalls); + Assert.NotEmpty(sink.Errors("returned no state")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.ThrowInOnEnable = true; + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 result = host.CallEnable(&exports, 1); + + Assert.False(result.IsTrue); + Assert.False(PluginHost.IsEnabled); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + Assert.Null(PluginHost.Context); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(1, RecordingPlugin.LastConstructed!.EnableCalls); + Assert.True(RecordingPlugin.LastConstructed.ContextInOnEnable!.ShutdownToken.IsCancellationRequested); + (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("OnEnable threw")); + InvalidOperationException exception = Assert.IsType(entry.Item3); + Assert.Contains("requested by the test", exception.Message, StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retryable() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.ThrowInOnEnable = true; + RecordingPlugin.CreateCallbacksInOnEnable = true; + int releases = 0; + LuaCallbackRegistry.AfterReleaseForTesting = () => + { + if (++releases == 1) + { + throw new InvalidOperationException("deterministic failed-enable cleanup failure"); + } + }; + ManagedExportedFunctions exports = FakeExports.Create(); + + try + { + Assert.False(host.CallEnable(&exports, 1).IsTrue); + + RecordingPlugin plugin = RecordingPlugin.LastConstructed!; + Assert.NotNull(plugin); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(PluginHostLifecyclePhase.Disabling, PluginHost.Phase); + Assert.NotNull(PluginHost.Context); + Assert.True(plugin.CallbackTwo!.IsReleased); + Assert.False(plugin.CallbackOne!.IsReleased); + Assert.NotEmpty(sink.Errors("shutdown remains incomplete")); + + LuaCallbackRegistry.AfterReleaseForTesting = null; + Assert.True(host.CallDisable().IsTrue); + Assert.False(LuaRuntime.IsAttached); + Assert.Null(PluginHost.Context); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + Assert.True(plugin.CallbackOne.IsReleased); + } + finally + { + LuaCallbackRegistry.AfterReleaseForTesting = null; + } + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwinds() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.ThrowInOnEnable = true; + RecordingPlugin.CreateCallbacksInOnEnable = true; + int releases = 0; + LuaCallbackRegistry.AfterReleaseForTesting = () => + { + if (++releases == 1) + { + throw new InvalidOperationException("deterministic failed-enable cleanup failure"); + } + }; + bool nestedRequested = false; + bool nestedResult = true; + ManagedExportedFunctions exports = FakeExports.Create(); + + try + { + Assert.False(host.CallEnable(&exports, 1).IsTrue); + + releases = 0; + sink.OnMessage = message => + { + if (!nestedRequested && message.Contains("shutdown remains incomplete", StringComparison.Ordinal)) + { + nestedRequested = true; + nestedResult = host.CallDisable().IsTrue; + } + }; + Assert.False(host.CallDisable().IsTrue); + Assert.True(nestedRequested); + Assert.False(nestedResult); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(PluginHostLifecyclePhase.Disabling, PluginHost.Phase); + Assert.True(sink.HasEntry(HostLogLevel.Error, "a disable transition is already completing")); + + LuaCallbackRegistry.AfterReleaseForTesting = null; + sink.OnMessage = null; + Assert.True(host.CallDisable().IsTrue); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + } + finally + { + LuaCallbackRegistry.AfterReleaseForTesting = null; + sink.OnMessage = null; + } + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_throwing_constructor_fails_the_enable_and_is_retried_on_the_next_enable() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.ThrowInConstructor = true; + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.False(host.CallEnable(&exports, 1).IsTrue); + + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.Null(PluginHost.PluginForTests); + Assert.Equal(1, RecordingPlugin.ConstructorCalls); + (HostLogLevel, string, Exception?) entry = Assert.Single(sink.Errors("constructor threw")); + Assert.IsType(entry.Item3); + + RecordingPlugin.ThrowInConstructor = false; + Assert.True(host.CallEnable(&exports, 1).IsTrue); + Assert.Equal(2, RecordingPlugin.ConstructorCalls); + Assert.True(PluginHost.IsEnabled); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_factory_that_returns_null_fails_the_enable() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + Assert.Equal(1, host.Initialize()); + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.False(host.CallEnable(&exports, 1).IsTrue); + + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("returned null")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Enabling_twice_without_a_disable_is_ignored_with_a_warning() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state); + PluginContext context = PluginHost.Context!; + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.True(host.CallEnable(&exports, 99).IsTrue); + + Assert.Equal(1, plugin.EnableCalls); + Assert.Same(context, PluginHost.Context); + Assert.Equal(7u, PluginHost.Context!.PluginId); + Assert.True(sink.HasEntry(HostLogLevel.Warning, "already enabled")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void The_production_module_lookup_finds_the_fixture_when_it_is_Cheat_Engines_DLL() + { + HostingTest.RequireNativeLua(); + Assert.SkipUnless( + string.Equals(Path.GetFileName(NativeLuaLibrary.LibraryPath), LuaModule.CheatEngine64ModuleName, + StringComparison.OrdinalIgnoreCase), + "The fixture is not named " + LuaModule.CheatEngine64ModuleName + + ", so the loaded-module lookup cannot find it."); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + FakeExports.UseState(state.L); + LuaModuleLocator.Resolver = null; // production lookup: GetModuleHandleExW("lua53-64.dll") + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Assert.True(host.CallEnable(&exports, 1).IsTrue); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs index 0402c6ed..1be9ec3f 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Abi; +using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Tests.Support; @@ -19,199 +21,205 @@ namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; /// public sealed unsafe class ReentrancyTests { - [Fact] - [Trait("Category", "NativeLua")] - [SuppressMessage("xUnit.Analyzers", "xUnit1051", - Justification = "The bounded lifecycle barrier is a deterministic host-thread synchronization point.")] - public void A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer_enable_decides_the_state() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var cancellationToken = TestContext.Current.CancellationToken; - using ManualResetEventSlim entered = new(initialState: false); - using ManualResetEventSlim continueEnable = new(initialState: false); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.OnEnableEntered = entered; - RecordingPlugin.ContinueOnEnable = continueEnable; - Bool32 outer = default; - Exception? workerFailure = null; - Thread enabling = new(() => - { - try - { - var exports = FakeExports.Create(); - outer = host.CallEnable(&exports, 1); - } - catch (Exception exception) - { - workerFailure = exception; - } - }); - - enabling.Start(); - Assert.True(entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "OnEnable did not reach its deterministic wait point."); - Assert.Equal(PluginHostLifecyclePhase.Enabling, PluginHost.Phase); - Assert.False(PluginHost.IsEnabled); - var earlyDispatch = Record.Exception(() => MainThread.Invoke(static _ => { }, 0)); - var earlyDispatchFailure = Assert.IsType(earlyDispatch); - Assert.Contains("no longer accepts new main-thread dispatch", earlyDispatchFailure.Message, - StringComparison.Ordinal); - - // This call returns while the outer OnEnable is still blocked. It therefore proves that the lifecycle gate - // does not wait behind plugin code, instead of relying on a timing threshold. - Assert.False(host.CallDisable().IsTrue); - Assert.Equal(PluginHostLifecyclePhase.Enabling, PluginHost.Phase); - Assert.NotEmpty(sink.Errors("lifecycle is in Enabling")); - - continueEnable.Set(); - Assert.True(enabling.Join(TimeSpan.FromSeconds(5)), "The outer enable did not complete."); - Assert.Null(workerFailure); - Assert.True(outer.IsTrue); - Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); - Assert.True(PluginHost.IsEnabled); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_nested_in_OnEnable_is_refused_and_the_enable_stands() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.NestedCallInOnEnable = () => host.CallDisable(); - var exports = FakeExports.Create(); - - var outer = host.CallEnable(&exports, 1); - - var plugin = RecordingPlugin.LastConstructed!; - Assert.True(outer.IsTrue); - Assert.False(plugin.NestedResultInOnEnable!.Value.IsTrue); - Assert.Equal(0, plugin.DisableCalls); - Assert.False(plugin.HostEnabledAfterNestedCall); - Assert.True(plugin.RuntimeAttachedAfterNestedCall); - Assert.True(PluginHost.IsEnabled); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(1u, PluginHost.Context!.PluginId); - Assert.NotEmpty(sink.Errors("lifecycle is in Enabling")); - Assert.False(sink.HasEntry(HostLogLevel.Information, "disabled")); - Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 1 enabled")); - - // The host's next real disable is the one that runs OnDisable. - Assert.True(host.CallDisable().IsTrue); - Assert.Equal(1, plugin.DisableCalls); - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Enable_nested_in_OnEnable_is_refused_and_does_not_replace_the_context() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - RecordingPlugin.NestedCallInOnEnable = () => NestedEnable(host, 99); - var exports = FakeExports.Create(); - - var outer = host.CallEnable(&exports, 1); - - var plugin = RecordingPlugin.LastConstructed!; - Assert.True(outer.IsTrue); - Assert.False(plugin.NestedResultInOnEnable!.Value.IsTrue); - Assert.Equal(1, plugin.EnableCalls); - Assert.Equal(1, RecordingPlugin.ConstructorCalls); - Assert.True(PluginHost.IsEnabled); - Assert.Equal(1u, PluginHost.Context!.PluginId); - Assert.Same(plugin.ContextInOnEnable, PluginHost.Context); - Assert.NotEmpty(sink.Errors("lifecycle is in Enabling")); - Assert.False(sink.HasEntry(HostLogLevel.Warning, "already enabled")); - Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 1 enabled")); - Assert.False(sink.HasEntry(HostLogLevel.Information, "Plugin 99 enabled")); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Enable_nested_in_OnDisable_is_refused_and_the_disable_stands() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state, 1); - RecordingPlugin.NestedCallInOnDisable = () => NestedEnable(host, 99); - - var outer = host.CallDisable(); - - Assert.True(outer.IsTrue); - Assert.False(plugin.NestedResultInOnDisable!.Value.IsTrue); - Assert.Equal(1, plugin.EnableCalls); - Assert.Equal(1, plugin.DisableCalls); - Assert.False(plugin - .HostEnabledAfterNestedCall); // Disabling keeps the context but IsEnabled is stable-state only. - Assert.True(plugin.RuntimeAttachedAfterNestedCall); - Assert.False(PluginHost.IsEnabled); - Assert.Null(PluginHost.Context); - Assert.False(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("lifecycle is in Disabling")); - Assert.False(sink.HasEntry(HostLogLevel.Warning, "already enabled")); - Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 1 disabled")); - - // The host's next real enable works as usual. - var exports = FakeExports.Create(); - Assert.True(host.CallEnable(&exports, 2).IsTrue); - Assert.Equal(2, plugin.EnableCalls); - Assert.Equal(2u, PluginHost.Context!.PluginId); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Disable_nested_in_OnDisable_is_refused_and_OnDisable_runs_once() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - var plugin = HostingTest.Enable(host, state, 1); - RecordingPlugin.NestedCallInOnDisable = () => host.CallDisable(); - - var outer = host.CallDisable(); - - Assert.True(outer.IsTrue); - Assert.False(plugin.NestedResultInOnDisable!.Value.IsTrue); - Assert.Equal(1, plugin.DisableCalls); - Assert.False(PluginHost.IsEnabled); - Assert.False(LuaRuntime.IsAttached); - Assert.NotEmpty(sink.Errors("lifecycle is in Disabling")); - Assert.Equal(1, CountEntries(sink, HostLogLevel.Information, "Plugin 1 disabled")); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - private static Bool32 NestedEnable(HostSimulator host, uint pluginId) - { - var exports = FakeExports.Create(); - return host.CallEnable(&exports, pluginId); - } - - private static int CountEntries(CapturingLogSink sink, HostLogLevel level, string fragment) - { - var count = 0; - foreach (var (entryLevel, message, _) in sink.Entries) - if (entryLevel == level && message.Contains(fragment, StringComparison.Ordinal)) - count++; - - return count; - } + [Fact] + [Trait("Category", "NativeLua")] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = "The bounded lifecycle barrier is a deterministic host-thread synchronization point.")] + public void A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer_enable_decides_the_state() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim entered = new(false); + using ManualResetEventSlim continueEnable = new(false); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.OnEnableEntered = entered; + RecordingPlugin.ContinueOnEnable = continueEnable; + Bool32 outer = default; + Exception? workerFailure = null; + Thread enabling = new(() => + { + try + { + ManagedExportedFunctions exports = FakeExports.Create(); + outer = host.CallEnable(&exports, 1); + } + catch (Exception exception) + { + workerFailure = exception; + } + }); + + enabling.Start(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "OnEnable did not reach its deterministic wait point."); + Assert.Equal(PluginHostLifecyclePhase.Enabling, PluginHost.Phase); + Assert.False(PluginHost.IsEnabled); + Exception? earlyDispatch = Record.Exception(() => MainThread.Invoke(static _ => + { + }, 0)); + InvalidOperationException earlyDispatchFailure = Assert.IsType(earlyDispatch); + Assert.Contains("no longer accepts new main-thread dispatch", earlyDispatchFailure.Message, + StringComparison.Ordinal); + + // This call returns while the outer OnEnable is still blocked. It therefore proves that the lifecycle gate + // does not wait behind plugin code, instead of relying on a timing threshold. + Assert.False(host.CallDisable().IsTrue); + Assert.Equal(PluginHostLifecyclePhase.Enabling, PluginHost.Phase); + Assert.NotEmpty(sink.Errors("lifecycle is in Enabling")); + + continueEnable.Set(); + Assert.True(enabling.Join(TimeSpan.FromSeconds(5)), "The outer enable did not complete."); + Assert.Null(workerFailure); + Assert.True(outer.IsTrue); + Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase); + Assert.True(PluginHost.IsEnabled); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_nested_in_OnEnable_is_refused_and_the_enable_stands() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.NestedCallInOnEnable = () => host.CallDisable(); + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 outer = host.CallEnable(&exports, 1); + + RecordingPlugin plugin = RecordingPlugin.LastConstructed!; + Assert.True(outer.IsTrue); + Assert.False(plugin.NestedResultInOnEnable!.Value.IsTrue); + Assert.Equal(0, plugin.DisableCalls); + Assert.False(plugin.HostEnabledAfterNestedCall); + Assert.True(plugin.RuntimeAttachedAfterNestedCall); + Assert.True(PluginHost.IsEnabled); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(1u, PluginHost.Context!.PluginId); + Assert.NotEmpty(sink.Errors("lifecycle is in Enabling")); + Assert.False(sink.HasEntry(HostLogLevel.Information, "disabled")); + Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 1 enabled")); + + // The host's next real disable is the one that runs OnDisable. + Assert.True(host.CallDisable().IsTrue); + Assert.Equal(1, plugin.DisableCalls); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Enable_nested_in_OnEnable_is_refused_and_does_not_replace_the_context() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + RecordingPlugin.NestedCallInOnEnable = () => NestedEnable(host, 99); + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 outer = host.CallEnable(&exports, 1); + + RecordingPlugin plugin = RecordingPlugin.LastConstructed!; + Assert.True(outer.IsTrue); + Assert.False(plugin.NestedResultInOnEnable!.Value.IsTrue); + Assert.Equal(1, plugin.EnableCalls); + Assert.Equal(1, RecordingPlugin.ConstructorCalls); + Assert.True(PluginHost.IsEnabled); + Assert.Equal(1u, PluginHost.Context!.PluginId); + Assert.Same(plugin.ContextInOnEnable, PluginHost.Context); + Assert.NotEmpty(sink.Errors("lifecycle is in Enabling")); + Assert.False(sink.HasEntry(HostLogLevel.Warning, "already enabled")); + Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 1 enabled")); + Assert.False(sink.HasEntry(HostLogLevel.Information, "Plugin 99 enabled")); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Enable_nested_in_OnDisable_is_refused_and_the_disable_stands() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state, 1); + RecordingPlugin.NestedCallInOnDisable = () => NestedEnable(host, 99); + + Bool32 outer = host.CallDisable(); + + Assert.True(outer.IsTrue); + Assert.False(plugin.NestedResultInOnDisable!.Value.IsTrue); + Assert.Equal(1, plugin.EnableCalls); + Assert.Equal(1, plugin.DisableCalls); + Assert.False(plugin + .HostEnabledAfterNestedCall); // Disabling keeps the context but IsEnabled is stable-state only. + Assert.True(plugin.RuntimeAttachedAfterNestedCall); + Assert.False(PluginHost.IsEnabled); + Assert.Null(PluginHost.Context); + Assert.False(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("lifecycle is in Disabling")); + Assert.False(sink.HasEntry(HostLogLevel.Warning, "already enabled")); + Assert.True(sink.HasEntry(HostLogLevel.Information, "Plugin 1 disabled")); + + // The host's next real enable works as usual. + ManagedExportedFunctions exports = FakeExports.Create(); + Assert.True(host.CallEnable(&exports, 2).IsTrue); + Assert.Equal(2, plugin.EnableCalls); + Assert.Equal(2u, PluginHost.Context!.PluginId); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Disable_nested_in_OnDisable_is_refused_and_OnDisable_runs_once() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + RecordingPlugin plugin = HostingTest.Enable(host, state, 1); + RecordingPlugin.NestedCallInOnDisable = () => host.CallDisable(); + + Bool32 outer = host.CallDisable(); + + Assert.True(outer.IsTrue); + Assert.False(plugin.NestedResultInOnDisable!.Value.IsTrue); + Assert.Equal(1, plugin.DisableCalls); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.NotEmpty(sink.Errors("lifecycle is in Disabling")); + Assert.Equal(1, CountEntries(sink, HostLogLevel.Information, "Plugin 1 disabled")); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + private static Bool32 NestedEnable(HostSimulator host, uint pluginId) + { + ManagedExportedFunctions exports = FakeExports.Create(); + return host.CallEnable(&exports, pluginId); + } + + private static int CountEntries(CapturingLogSink sink, HostLogLevel level, string fragment) + { + int count = 0; + foreach ((HostLogLevel entryLevel, string message, Exception? _) in sink.Entries) + { + if (entryLevel == level && message.Contains(fragment, StringComparison.Ordinal)) + { + count++; + } + } + + return count; + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/AlternatePluginFactory.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/AlternatePluginFactory.cs index 5015f60d..4afe5034 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/AlternatePluginFactory.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/AlternatePluginFactory.cs @@ -8,10 +8,10 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal sealed class AlternatePluginFactory : IPluginFactory { - public static ReadOnlySpan Utf8Name => "Alternate Plugin"u8; + public static ReadOnlySpan Utf8Name => "Alternate Plugin"u8; - public static CheatEnginePlugin Create() - { - return new RecordingPlugin(); - } + public static CheatEnginePlugin Create() + { + return new RecordingPlugin(); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/CapturingLogSink.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/CapturingLogSink.cs index b9e797ae..a96452bb 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/CapturingLogSink.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/CapturingLogSink.cs @@ -5,48 +5,60 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// Collects entries so that tests can assert that a failure was reported, and how. internal sealed class CapturingLogSink : IHostLogSink { - private readonly List<(HostLogLevel Level, string Message, Exception? Exception)> _entries = []; - - public Action? OnMessage { get; set; } - - public IReadOnlyList<(HostLogLevel Level, string Message, Exception? Exception)> Entries - { - get - { - lock (_entries) - { - return [.. _entries]; - } - } - } - - public void Write(HostLogLevel level, string message, Exception? exception) - { - lock (_entries) - { - _entries.Add((level, message, exception)); - } - - OnMessage?.Invoke(message); - } - - /// The error entries whose message contains (ordinal). - public IReadOnlyList<(HostLogLevel Level, string Message, Exception? Exception)> Errors(string fragment) - { - List<(HostLogLevel, string, Exception?)> matches = []; - foreach (var (level, message, exception) in Entries) - if (level == HostLogLevel.Error && message.Contains(fragment, StringComparison.Ordinal)) - matches.Add((level, message, exception)); - - return matches; - } - - public bool HasEntry(HostLogLevel level, string fragment) - { - foreach (var (entryLevel, message, _) in Entries) - if (entryLevel == level && message.Contains(fragment, StringComparison.Ordinal)) - return true; - - return false; - } + private readonly List<(HostLogLevel Level, string Message, Exception? Exception)> _entries = []; + + public Action? OnMessage + { + get; + set; + } + + public IReadOnlyList<(HostLogLevel Level, string Message, Exception? Exception)> Entries + { + get + { + lock (_entries) + { + return [.. _entries]; + } + } + } + + public void Write(HostLogLevel level, string message, Exception? exception) + { + lock (_entries) + { + _entries.Add((level, message, exception)); + } + + OnMessage?.Invoke(message); + } + + /// The error entries whose message contains (ordinal). + public IReadOnlyList<(HostLogLevel Level, string Message, Exception? Exception)> Errors(string fragment) + { + List<(HostLogLevel, string, Exception?)> matches = []; + foreach ((HostLogLevel level, string message, Exception? exception) in Entries) + { + if (level == HostLogLevel.Error && message.Contains(fragment, StringComparison.Ordinal)) + { + matches.Add((level, message, exception)); + } + } + + return matches; + } + + public bool HasEntry(HostLogLevel level, string fragment) + { + foreach ((HostLogLevel entryLevel, string message, Exception? _) in Entries) + { + if (entryLevel == level && message.Contains(fragment, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs index 3094d9ab..17527a21 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/FakeExports.cs @@ -1,8 +1,10 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Lua.Interop.Types; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Hosting.Tests.Support; @@ -15,124 +17,124 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal static unsafe class FakeExports { - private static lua_State* s_state; - private static int s_getLuaStateCalls; - private static int s_processMessagesCalls; - private static int s_checkSynchronizeCalls; - private static int s_lastTimeout; - private static int s_luaRegisterCalls; - private static int s_pusherCalls; - private static nint s_lastPushedObject; - private static byte s_checkSynchronizeRawResult = 1; - private static Action? s_checkSynchronizeHandlerForTests; - - public static int GetLuaStateCalls => Volatile.Read(ref s_getLuaStateCalls); - - public static int ProcessMessagesCalls => Volatile.Read(ref s_processMessagesCalls); - - public static int CheckSynchronizeCalls => Volatile.Read(ref s_checkSynchronizeCalls); - - public static int LastTimeout => Volatile.Read(ref s_lastTimeout); - - public static int LuaRegisterCalls => Volatile.Read(ref s_luaRegisterCalls); - - public static int PusherCalls => Volatile.Read(ref s_pusherCalls); - - public static nint LastPushedObject => Volatile.Read(ref s_lastPushedObject); - - public static nint GetLuaStateAddress => (nint)(delegate* unmanaged[Stdcall])&GetLuaState; - - public static nint PusherAddress => (nint)(delegate* unmanaged[Stdcall])&PushClassInstance; - - /// - /// The raw byte the CheckSynchronize double returns; 1 by default, set to 0 for false or 0xFF to prove - /// truthiness. - /// - public static byte CheckSynchronizeRawResult - { - get => Volatile.Read(ref s_checkSynchronizeRawResult); - set => Volatile.Write(ref s_checkSynchronizeRawResult, value); - } - - /// Runs from the simulated GUI pump after it counts a CheckSynchronize call; tests only. - public static Action? CheckSynchronizeHandlerForTests - { - get => Volatile.Read(ref s_checkSynchronizeHandlerForTests); - set => Volatile.Write(ref s_checkSynchronizeHandlerForTests, value); - } - - /// - /// Points GetLuaState at (null makes it return no state) and clears the - /// counters. - /// - public static void UseState(lua_State* state) - { - s_state = state; - Reset(); - } - - public static void Reset() - { - s_getLuaStateCalls = 0; - s_processMessagesCalls = 0; - s_checkSynchronizeCalls = 0; - s_lastTimeout = -1; - s_luaRegisterCalls = 0; - s_pusherCalls = 0; - s_lastPushedObject = 0; - s_checkSynchronizeRawResult = 1; - s_checkSynchronizeHandlerForTests = null; - } - - /// Builds the record the host would pass, with every slot pointing at a double. - /// The value of sizeofExportedFunctions; defaults to the real size (48). - /// False leaves the LuaPushClassInstance slot null. - /// False leaves the ProcessMessages and CheckSynchronize slots null. - public static ManagedExportedFunctions Create(int reportedSize = 0, bool withPusher = true, bool withPump = true) - { - ManagedExportedFunctions exports = default; - exports.SizeOfExportedFunctions = reportedSize == 0 ? sizeof(ManagedExportedFunctions) : reportedSize; - exports.GetLuaState = &GetLuaState; - exports.LuaRegister = (delegate* unmanaged[Stdcall])&LuaRegisterNeverCalled; - exports.LuaPushClassInstance = withPusher ? &PushClassInstance : null; - exports.ProcessMessages = withPump ? &ProcessMessages : null; - exports.CheckSynchronize = withPump ? &CheckSynchronize : null; - return exports; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* GetLuaState() - { - Interlocked.Increment(ref s_getLuaStateCalls); - return s_state; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void PushClassInstance(void* L, void* nativeObject) - { - Interlocked.Increment(ref s_pusherCalls); - Volatile.Write(ref s_lastPushedObject, (nint)nativeObject); - lua_pushlightuserdata((lua_State*)L, nativeObject); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void ProcessMessages() - { - Interlocked.Increment(ref s_processMessagesCalls); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static Bool8 CheckSynchronize(int timeout) - { - Interlocked.Increment(ref s_checkSynchronizeCalls); - Volatile.Write(ref s_lastTimeout, timeout); - Volatile.Read(ref s_checkSynchronizeHandlerForTests)?.Invoke(); - return new Bool8(s_checkSynchronizeRawResult); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void LuaRegisterNeverCalled() - { - Interlocked.Increment(ref s_luaRegisterCalls); - } + private static lua_State* s_state; + private static int s_getLuaStateCalls; + private static int s_processMessagesCalls; + private static int s_checkSynchronizeCalls; + private static int s_lastTimeout; + private static int s_luaRegisterCalls; + private static int s_pusherCalls; + private static nint s_lastPushedObject; + private static byte s_checkSynchronizeRawResult = 1; + private static Action? s_checkSynchronizeHandlerForTests; + + public static int GetLuaStateCalls => Volatile.Read(ref s_getLuaStateCalls); + + public static int ProcessMessagesCalls => Volatile.Read(ref s_processMessagesCalls); + + public static int CheckSynchronizeCalls => Volatile.Read(ref s_checkSynchronizeCalls); + + public static int LastTimeout => Volatile.Read(ref s_lastTimeout); + + public static int LuaRegisterCalls => Volatile.Read(ref s_luaRegisterCalls); + + public static int PusherCalls => Volatile.Read(ref s_pusherCalls); + + public static nint LastPushedObject => Volatile.Read(ref s_lastPushedObject); + + public static nint GetLuaStateAddress => (nint) (delegate* unmanaged[Stdcall]) &GetLuaState; + + public static nint PusherAddress => (nint) (delegate* unmanaged[Stdcall]) &PushClassInstance; + + /// + /// The raw byte the CheckSynchronize double returns; 1 by default, set to 0 for false or 0xFF to prove + /// truthiness. + /// + public static byte CheckSynchronizeRawResult + { + get => Volatile.Read(ref s_checkSynchronizeRawResult); + set => Volatile.Write(ref s_checkSynchronizeRawResult, value); + } + + /// Runs from the simulated GUI pump after it counts a CheckSynchronize call; tests only. + public static Action? CheckSynchronizeHandlerForTests + { + get => Volatile.Read(ref s_checkSynchronizeHandlerForTests); + set => Volatile.Write(ref s_checkSynchronizeHandlerForTests, value); + } + + /// + /// Points GetLuaState at (null makes it return no state) and clears the + /// counters. + /// + public static void UseState(lua_State* state) + { + s_state = state; + Reset(); + } + + public static void Reset() + { + s_getLuaStateCalls = 0; + s_processMessagesCalls = 0; + s_checkSynchronizeCalls = 0; + s_lastTimeout = -1; + s_luaRegisterCalls = 0; + s_pusherCalls = 0; + s_lastPushedObject = 0; + s_checkSynchronizeRawResult = 1; + s_checkSynchronizeHandlerForTests = null; + } + + /// Builds the record the host would pass, with every slot pointing at a double. + /// The value of sizeofExportedFunctions; defaults to the real size (48). + /// False leaves the LuaPushClassInstance slot null. + /// False leaves the ProcessMessages and CheckSynchronize slots null. + public static ManagedExportedFunctions Create(int reportedSize = 0, bool withPusher = true, bool withPump = true) + { + ManagedExportedFunctions exports = default; + exports.SizeOfExportedFunctions = reportedSize == 0 ? sizeof(ManagedExportedFunctions) : reportedSize; + exports.GetLuaState = &GetLuaState; + exports.LuaRegister = (delegate* unmanaged[Stdcall]) &LuaRegisterNeverCalled; + exports.LuaPushClassInstance = withPusher ? &PushClassInstance : null; + exports.ProcessMessages = withPump ? &ProcessMessages : null; + exports.CheckSynchronize = withPump ? &CheckSynchronize : null; + return exports; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* GetLuaState() + { + Interlocked.Increment(ref s_getLuaStateCalls); + return s_state; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void PushClassInstance(void* L, void* nativeObject) + { + Interlocked.Increment(ref s_pusherCalls); + Volatile.Write(ref s_lastPushedObject, (nint) nativeObject); + lua_pushlightuserdata((lua_State*) L, nativeObject); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void ProcessMessages() + { + Interlocked.Increment(ref s_processMessagesCalls); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool8 CheckSynchronize(int timeout) + { + Interlocked.Increment(ref s_checkSynchronizeCalls); + Volatile.Write(ref s_lastTimeout, timeout); + Volatile.Read(ref s_checkSynchronizeHandlerForTests)?.Invoke(); + return new Bool8(s_checkSynchronizeRawResult); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void LuaRegisterNeverCalled() + { + Interlocked.Increment(ref s_luaRegisterCalls); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/HostSimulator.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/HostSimulator.cs index 57af25f7..ad8f5981 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/HostSimulator.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/HostSimulator.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Abi.Native; @@ -23,74 +24,77 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal sealed unsafe class HostSimulator : IDisposable { - public const byte GuardByte = 0xCD; - public const int GuardLength = 64; - - private readonly int _offset; - private byte* _block; - - public HostSimulator(bool oddAddress = false) - { - _offset = oddAddress ? 1 : 0; - var length = _offset + RecordSize + GuardLength; - _block = (byte*)NativeMemory.Alloc((nuint)length); - new Span(_block, length).Fill(GuardByte); - } - - public static int RecordSize => sizeof(PluginInitRecord); - - /// The address the host passes as args. - public nint RecordAddress => (nint)(_block + _offset); - - /// The 36 bytes of the record as the host sees them. - public ReadOnlySpan RecordBytes => new(_block + _offset, RecordSize); - - /// True while no byte after the record has been touched. - public bool GuardIntact => - new ReadOnlySpan(_block + _offset + RecordSize, GuardLength).IndexOfAnyExcept(GuardByte) < 0; - - /// True while the record still holds the fill pattern, i.e. nothing was written. - public bool RecordUntouched => RecordBytes.IndexOfAnyExcept(GuardByte) < 0; - - public ref PluginInitRecord Record => ref *(PluginInitRecord*)RecordAddress; - - /// - /// Ends the simulated host: resets the host state while the fixture state is still open (see the type remarks), - /// clears the provider, frees the buffer. - /// - public void Dispose() - { - if (_block is null) return; - - PluginHost.ResetForTests(); - LuaRuntime.Detach(); - FakeExports.UseState(null); - NativeMemory.Free(_block); - _block = null; - } - - /// The bootstrap call, as the generated entry point makes it. - public int Initialize(int hostArgument = 0) - where TFactory : IPluginFactory - { - return PluginHost.InitializeManaged(RecordAddress, hostArgument); - } - - /// The host's version query through the record's pointer. - public Bool32 CallGetVersion(PluginVersion* version, int size) - { - return Record.GetVersion(version, size); - } - - /// The host's enable call through the record's pointer. - public Bool32 CallEnable(ManagedExportedFunctions* exports, uint pluginId) - { - return Record.EnablePlugin(exports, pluginId); - } - - /// The host's disable call through the record's pointer. - public Bool32 CallDisable() - { - return Record.DisablePlugin(); - } + public const byte GuardByte = 0xCD; + public const int GuardLength = 64; + + private readonly int _offset; + private byte* _block; + + public HostSimulator(bool oddAddress = false) + { + _offset = oddAddress ? 1 : 0; + int length = _offset + RecordSize + GuardLength; + _block = (byte*) NativeMemory.Alloc((nuint) length); + new Span(_block, length).Fill(GuardByte); + } + + public static int RecordSize => sizeof(PluginInitRecord); + + /// The address the host passes as args. + public nint RecordAddress => (nint) (_block + _offset); + + /// The 36 bytes of the record as the host sees them. + public ReadOnlySpan RecordBytes => new(_block + _offset, RecordSize); + + /// True while no byte after the record has been touched. + public bool GuardIntact => + new ReadOnlySpan(_block + _offset + RecordSize, GuardLength).IndexOfAnyExcept(GuardByte) < 0; + + /// True while the record still holds the fill pattern, i.e. nothing was written. + public bool RecordUntouched => RecordBytes.IndexOfAnyExcept(GuardByte) < 0; + + public ref PluginInitRecord Record => ref *(PluginInitRecord*) RecordAddress; + + /// + /// Ends the simulated host: resets the host state while the fixture state is still open (see the type remarks), + /// clears the provider, frees the buffer. + /// + public void Dispose() + { + if (_block is null) + { + return; + } + + PluginHost.ResetForTests(); + LuaRuntime.Detach(); + FakeExports.UseState(null); + NativeMemory.Free(_block); + _block = null; + } + + /// The bootstrap call, as the generated entry point makes it. + public int Initialize(int hostArgument = 0) + where TFactory : IPluginFactory + { + return PluginHost.InitializeManaged(RecordAddress, hostArgument); + } + + /// The host's version query through the record's pointer. + public Bool32 CallGetVersion(PluginVersion* version, int size) + { + return Record.GetVersion(version, size); + } + + /// The host's enable call through the record's pointer. + public Bool32 CallEnable(ManagedExportedFunctions* exports, uint pluginId) + { + return Record.EnablePlugin(exports, pluginId); + } + + /// The host's disable call through the record's pointer. + public Bool32 CallDisable() + { + return Record.DisablePlugin(); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/HostingTest.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/HostingTest.cs index 41828fee..92704f0d 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/HostingTest.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/HostingTest.cs @@ -1,3 +1,4 @@ +using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Threading; @@ -13,69 +14,69 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal static unsafe class HostingTest { - /// Returns the host, the runtime and the doubles to their initial state and installs a fresh capturing sink. - public static CapturingLogSink Reset() - { - // Whatever state a previous test handed out is closed by now: the provider is cleared first, so that a host - // still enabled here (a test that did not dispose its simulator) abandons its callbacks instead of releasing - // them on a dangling state. The regular teardown is the simulator's disposal, which releases them properly. - FakeExports.UseState(null); - PluginHost.ResetForTests(); - LuaRuntime.Detach(); - MainThreadDispatcher.ResetForTests(); - RecordingPlugin.Reset(); - LuaModuleLocator.Resolver = null; - HostLog.ResetForTests(); - CapturingLogSink sink = new(); - HostLog.Sink = sink; - HostLog.MinimumLevel = HostLogLevel.Trace; - return sink; - } + /// Returns the host, the runtime and the doubles to their initial state and installs a fresh capturing sink. + public static CapturingLogSink Reset() + { + // Whatever state a previous test handed out is closed by now: the provider is cleared first, so that a host + // still enabled here (a test that did not dispose its simulator) abandons its callbacks instead of releasing + // them on a dangling state. The regular teardown is the simulator's disposal, which releases them properly. + FakeExports.UseState(null); + PluginHost.ResetForTests(); + LuaRuntime.Detach(); + MainThreadDispatcher.ResetForTests(); + RecordingPlugin.Reset(); + LuaModuleLocator.Resolver = null; + HostLog.ResetForTests(); + CapturingLogSink sink = new(); + HostLog.Sink = sink; + HostLog.MinimumLevel = HostLogLevel.Trace; + return sink; + } - /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. - public static void RequireNativeLua() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - } + /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. + public static void RequireNativeLua() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + } - /// Makes the host bind to the fixture's module (whatever its file name) and hand out . - public static void UseFixture(NativeLuaState state) - { - LuaModuleLocator.Resolver = &ResolveFixtureModule; - FakeExports.UseState(state.L); - } + /// Makes the host bind to the fixture's module (whatever its file name) and hand out . + public static void UseFixture(NativeLuaState state) + { + LuaModuleLocator.Resolver = &ResolveFixtureModule; + FakeExports.UseState(state.L); + } - /// Makes the module lookup fail, as in a process that has no Lua library at all. - public static void UseNoModule() - { - LuaModuleLocator.Resolver = &ResolveNoModule; - } + /// Makes the module lookup fail, as in a process that has no Lua library at all. + public static void UseNoModule() + { + LuaModuleLocator.Resolver = &ResolveNoModule; + } - /// Bootstraps into and asserts success. - public static void Bootstrap(HostSimulator host) - { - Assert.Equal(1, host.Initialize()); - } + /// Bootstraps into and asserts success. + public static void Bootstrap(HostSimulator host) + { + Assert.Equal(1, host.Initialize()); + } - /// Bootstraps and enables with the fixture; returns the plugin the host constructed. - public static RecordingPlugin Enable(HostSimulator host, NativeLuaState state, uint pluginId = 7) - { - UseFixture(state); - Bootstrap(host); - var exports = FakeExports.Create(); - Assert.True(host.CallEnable(&exports, pluginId).IsTrue); - var plugin = RecordingPlugin.LastConstructed; - Assert.NotNull(plugin); - return plugin; - } + /// Bootstraps and enables with the fixture; returns the plugin the host constructed. + public static RecordingPlugin Enable(HostSimulator host, NativeLuaState state, uint pluginId = 7) + { + UseFixture(state); + Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + Assert.True(host.CallEnable(&exports, pluginId).IsTrue); + RecordingPlugin? plugin = RecordingPlugin.LastConstructed; + Assert.NotNull(plugin); + return plugin; + } - private static nint ResolveFixtureModule() - { - return NativeLuaLibrary.Handle; - } + private static nint ResolveFixtureModule() + { + return NativeLuaLibrary.Handle; + } - private static nint ResolveNoModule() - { - return 0; - } + private static nint ResolveNoModule() + { + return 0; + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/NonAsciiNamePluginFactory.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/NonAsciiNamePluginFactory.cs index 1073bcbe..7c7276e6 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/NonAsciiNamePluginFactory.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/NonAsciiNamePluginFactory.cs @@ -8,12 +8,12 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal sealed class NonAsciiNamePluginFactory : IPluginFactory { - public const string Name = "Plugin \u00E9"; + public const string Name = "Plugin \u00E9"; - public static ReadOnlySpan Utf8Name => "Plugin \u00E9"u8; + public static ReadOnlySpan Utf8Name => "Plugin \u00E9"u8; - public static CheatEnginePlugin Create() - { - return new RecordingPlugin(); - } + public static CheatEnginePlugin Create() + { + return new RecordingPlugin(); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/NullReturningPluginFactory.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/NullReturningPluginFactory.cs index fe1288e0..7aaafa23 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/NullReturningPluginFactory.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/NullReturningPluginFactory.cs @@ -8,10 +8,10 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal sealed class NullReturningPluginFactory : IPluginFactory { - public static ReadOnlySpan Utf8Name => "Null Plugin"u8; + public static ReadOnlySpan Utf8Name => "Null Plugin"u8; - public static CheatEnginePlugin Create() - { - return null!; - } + public static CheatEnginePlugin Create() + { + return null!; + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs index fd9a8261..e3548c17 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Abi; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Context; @@ -18,164 +19,293 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal sealed unsafe class RecordingPlugin : CheatEnginePlugin { - public RecordingPlugin() - { - ConstructorCalls++; - RuntimeAttachedInConstructor = LuaRuntime.IsAttached; - HostEnabledInConstructor = PluginHost.IsEnabled; - LastConstructed = this; - if (ThrowInConstructor) throw new InvalidOperationException("constructor failure requested by the test"); - } - - public static bool ThrowInConstructor { get; set; } - - public static bool ThrowInOnEnable { get; set; } - - public static bool CreateCallbacksInOnEnable { get; set; } - - public static bool ThrowInOnDisable { get; set; } - - /// Signals after has attached and observed the lifecycle state; null for none. - public static ManualResetEventSlim? OnEnableEntered { get; set; } - - /// Blocks after was signalled; null for none. - public static ManualResetEventSlim? ContinueOnEnable { get; set; } - - /// - /// A lifecycle call to make from inside , as a host re-entering through the message pump - /// would; null for none. Runs once, then clears itself. - /// - public static Func? NestedCallInOnEnable { get; set; } - - /// A lifecycle call to make from inside ; null for none. Runs once, then clears itself. - public static Func? NestedCallInOnDisable { get; set; } - - public static int ConstructorCalls { get; private set; } - - public static bool RuntimeAttachedInConstructor { get; private set; } - - public static bool HostEnabledInConstructor { get; private set; } - - public static RecordingPlugin? LastConstructed { get; private set; } - - public int EnableCalls { get; private set; } - - public int DisableCalls { get; private set; } - - public int EnableThreadId { get; private set; } - - public bool RuntimeAttachedInOnEnable { get; private set; } - - public bool HostEnabledInOnEnable { get; private set; } - - public bool MainThreadInOnEnable { get; private set; } - - public PluginContext? ContextInOnEnable { get; private set; } - - public long LuaResultInOnEnable { get; private set; } - - public bool RuntimeAttachedInOnDisable { get; private set; } - - public bool HostEnabledInOnDisable { get; private set; } - - public LuaCallback? CallbackOne { get; private set; } - - public LuaCallback? CallbackTwo { get; private set; } - - /// What the nested call installed in returned; null when none ran. - public Bool32? NestedResultInOnEnable { get; private set; } - - /// What the nested call installed in returned; null when none ran. - public Bool32? NestedResultInOnDisable { get; private set; } - - /// right after the nested call returned, inside the outer callback. - public bool HostEnabledAfterNestedCall { get; private set; } - - /// right after the nested call returned, inside the outer callback. - public bool RuntimeAttachedAfterNestedCall { get; private set; } - - public static void Reset() - { - ThrowInConstructor = false; - ThrowInOnEnable = false; - CreateCallbacksInOnEnable = false; - ThrowInOnDisable = false; - OnEnableEntered = null; - ContinueOnEnable = null; - NestedCallInOnEnable = null; - NestedCallInOnDisable = null; - ConstructorCalls = 0; - RuntimeAttachedInConstructor = false; - HostEnabledInConstructor = false; - LastConstructed = null; - } - - protected internal override void OnEnable() - { - EnableCalls++; - EnableThreadId = Environment.CurrentManagedThreadId; - RuntimeAttachedInOnEnable = LuaRuntime.IsAttached; - HostEnabledInOnEnable = PluginHost.IsEnabled; - MainThreadInOnEnable = MainThread.IsMainThread; - ContextInOnEnable = Context; - OnEnableEntered?.Set(); - ContinueOnEnable?.Wait(Context.ShutdownToken); - - // The Lua layer must be usable here: run a chunk on the state the host hands out. - var L = LuaRuntime.AcquireState(); - using (LuaFrame frame = new(L)) - { - if (L.TryExecute("return 40 + 2"u8, 1).IsOk && L.TryReadInteger(-1, out var value)) - LuaResultInOnEnable = value; - } - - if (CreateCallbacksInOnEnable) - { - if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), out var first).IsOk - || first is null) - throw new InvalidOperationException("first callback creation failed"); - - if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), out var second).IsOk - || second is null) - throw new InvalidOperationException("second callback creation failed"); - - CallbackOne = first; - CallbackTwo = second; - } - - var nested = NestedCallInOnEnable; - if (nested is not null) - { - NestedCallInOnEnable = null; - NestedResultInOnEnable = nested(); - HostEnabledAfterNestedCall = PluginHost.IsEnabled; - RuntimeAttachedAfterNestedCall = LuaRuntime.IsAttached; - } - - if (ThrowInOnEnable) throw new InvalidOperationException("OnEnable failure requested by the test"); - } - - protected internal override void OnDisable() - { - DisableCalls++; - RuntimeAttachedInOnDisable = LuaRuntime.IsAttached; - HostEnabledInOnDisable = PluginHost.IsEnabled; - - var nested = NestedCallInOnDisable; - if (nested is not null) - { - NestedCallInOnDisable = null; - NestedResultInOnDisable = nested(); - HostEnabledAfterNestedCall = PluginHost.IsEnabled; - RuntimeAttachedAfterNestedCall = LuaRuntime.IsAttached; - } - - if (ThrowInOnDisable) throw new InvalidOperationException("OnDisable failure requested by the test"); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int NoOpThunk(nint handle) - { - return 0; - } + public RecordingPlugin() + { + ConstructorCalls++; + RuntimeAttachedInConstructor = LuaRuntime.IsAttached; + HostEnabledInConstructor = PluginHost.IsEnabled; + LastConstructed = this; + if (ThrowInConstructor) + { + throw new InvalidOperationException("constructor failure requested by the test"); + } + } + + public static bool ThrowInConstructor + { + get; + set; + } + + public static bool ThrowInOnEnable + { + get; + set; + } + + public static bool CreateCallbacksInOnEnable + { + get; + set; + } + + public static bool ThrowInOnDisable + { + get; + set; + } + + /// Signals after has attached and observed the lifecycle state; null for none. + public static ManualResetEventSlim? OnEnableEntered + { + get; + set; + } + + /// Blocks after was signalled; null for none. + public static ManualResetEventSlim? ContinueOnEnable + { + get; + set; + } + + /// + /// A lifecycle call to make from inside , as a host re-entering through the message pump + /// would; null for none. Runs once, then clears itself. + /// + public static Func? NestedCallInOnEnable + { + get; + set; + } + + /// A lifecycle call to make from inside ; null for none. Runs once, then clears itself. + public static Func? NestedCallInOnDisable + { + get; + set; + } + + public static int ConstructorCalls + { + get; + private set; + } + + public static bool RuntimeAttachedInConstructor + { + get; + private set; + } + + public static bool HostEnabledInConstructor + { + get; + private set; + } + + public static RecordingPlugin? LastConstructed + { + get; + private set; + } + + public int EnableCalls + { + get; + private set; + } + + public int DisableCalls + { + get; + private set; + } + + public int EnableThreadId + { + get; + private set; + } + + public bool RuntimeAttachedInOnEnable + { + get; + private set; + } + + public bool HostEnabledInOnEnable + { + get; + private set; + } + + public bool MainThreadInOnEnable + { + get; + private set; + } + + public PluginContext? ContextInOnEnable + { + get; + private set; + } + + public long LuaResultInOnEnable + { + get; + private set; + } + + public bool RuntimeAttachedInOnDisable + { + get; + private set; + } + + public bool HostEnabledInOnDisable + { + get; + private set; + } + + public LuaCallback? CallbackOne + { + get; + private set; + } + + public LuaCallback? CallbackTwo + { + get; + private set; + } + + /// What the nested call installed in returned; null when none ran. + public Bool32? NestedResultInOnEnable + { + get; + private set; + } + + /// What the nested call installed in returned; null when none ran. + public Bool32? NestedResultInOnDisable + { + get; + private set; + } + + /// right after the nested call returned, inside the outer callback. + public bool HostEnabledAfterNestedCall + { + get; + private set; + } + + /// right after the nested call returned, inside the outer callback. + public bool RuntimeAttachedAfterNestedCall + { + get; + private set; + } + + public static void Reset() + { + ThrowInConstructor = false; + ThrowInOnEnable = false; + CreateCallbacksInOnEnable = false; + ThrowInOnDisable = false; + OnEnableEntered = null; + ContinueOnEnable = null; + NestedCallInOnEnable = null; + NestedCallInOnDisable = null; + ConstructorCalls = 0; + RuntimeAttachedInConstructor = false; + HostEnabledInConstructor = false; + LastConstructed = null; + } + + protected internal override void OnEnable() + { + EnableCalls++; + EnableThreadId = Environment.CurrentManagedThreadId; + RuntimeAttachedInOnEnable = LuaRuntime.IsAttached; + HostEnabledInOnEnable = PluginHost.IsEnabled; + MainThreadInOnEnable = MainThread.IsMainThread; + ContextInOnEnable = Context; + OnEnableEntered?.Set(); + ContinueOnEnable?.Wait(Context.ShutdownToken); + + // The Lua layer must be usable here: run a chunk on the state the host hands out. + LuaState L = LuaRuntime.AcquireState(); + using (LuaFrame frame = new(L)) + { + if (L.TryExecute("return 40 + 2"u8, 1).IsOk && L.TryReadInteger(-1, out long value)) + { + LuaResultInOnEnable = value; + } + } + + if (CreateCallbacksInOnEnable) + { + if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), + out LuaCallback? first).IsOk + || first is null) + { + throw new InvalidOperationException("first callback creation failed"); + } + + if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), + out LuaCallback? second).IsOk + || second is null) + { + throw new InvalidOperationException("second callback creation failed"); + } + + CallbackOne = first; + CallbackTwo = second; + } + + Func? nested = NestedCallInOnEnable; + if (nested is not null) + { + NestedCallInOnEnable = null; + NestedResultInOnEnable = nested(); + HostEnabledAfterNestedCall = PluginHost.IsEnabled; + RuntimeAttachedAfterNestedCall = LuaRuntime.IsAttached; + } + + if (ThrowInOnEnable) + { + throw new InvalidOperationException("OnEnable failure requested by the test"); + } + } + + protected internal override void OnDisable() + { + DisableCalls++; + RuntimeAttachedInOnDisable = LuaRuntime.IsAttached; + HostEnabledInOnDisable = PluginHost.IsEnabled; + + Func? nested = NestedCallInOnDisable; + if (nested is not null) + { + NestedCallInOnDisable = null; + NestedResultInOnDisable = nested(); + HostEnabledAfterNestedCall = PluginHost.IsEnabled; + RuntimeAttachedAfterNestedCall = LuaRuntime.IsAttached; + } + + if (ThrowInOnDisable) + { + throw new InvalidOperationException("OnDisable failure requested by the test"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int NoOpThunk(nint handle) + { + return 0; + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPluginFactory.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPluginFactory.cs index 7015f3b8..e059ad79 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPluginFactory.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPluginFactory.cs @@ -5,12 +5,12 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// What the entry-point generator emits for , written by hand. internal sealed class RecordingPluginFactory : IPluginFactory { - public const string Name = "Hosting Test Plugin"; + public const string Name = "Hosting Test Plugin"; - public static ReadOnlySpan Utf8Name => "Hosting Test Plugin"u8; + public static ReadOnlySpan Utf8Name => "Hosting Test Plugin"u8; - public static CheatEnginePlugin Create() - { - return new RecordingPlugin(); - } + public static CheatEnginePlugin Create() + { + return new RecordingPlugin(); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/ThrowingNamePluginFactory.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/ThrowingNamePluginFactory.cs index 43e4fb2b..3eb5c623 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/ThrowingNamePluginFactory.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/ThrowingNamePluginFactory.cs @@ -9,10 +9,10 @@ namespace CheatEngine.SDK.Hosting.Tests.Support; /// internal sealed class ThrowingNamePluginFactory : IPluginFactory { - public static ReadOnlySpan Utf8Name => throw new NotSupportedException("name failure requested by the test"); + public static ReadOnlySpan Utf8Name => throw new NotSupportedException("name failure requested by the test"); - public static CheatEnginePlugin Create() - { - return new RecordingPlugin(); - } + public static CheatEnginePlugin Create() + { + return new RecordingPlugin(); + } } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs index 705af4ac..59fcbb04 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs @@ -1,4 +1,6 @@ using System.Runtime.CompilerServices; + +using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Tests.Support; using CheatEngine.SDK.Hosting.Threading; @@ -17,255 +19,267 @@ namespace CheatEngine.SDK.Hosting.Tests.Threading; /// public sealed unsafe class MainThreadTests { - [Fact] - public void Everything_needs_an_enabled_plugin() - { - HostingTest.Reset(); - - Assert.False(MainThread.IsMainThread); - Assert.Throws(MainThread.ProcessMessages); - Assert.Throws(() => MainThread.CheckSynchronize(0)); - Assert.Throws(() => MainThread.Invoke(static _ => { }, 0)); - Assert.Throws(() => MainThread.Invoke(static x => x, 0)); - Assert.Throws(() => MainThread.Invoke(null!, 0)); - Assert.Throws(() => MainThread.Invoke(null!, 0)); - Assert.Throws(() => MainThread.CheckSynchronize(-1)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void ProcessMessages_and_CheckSynchronize_call_the_host_slots_on_the_main_thread() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - - MainThread.ProcessMessages(); - MainThread.ProcessMessages(); - FakeExports.CheckSynchronizeRawResult = 0xFF; - var ran = MainThread.CheckSynchronize(250); - FakeExports.CheckSynchronizeRawResult = 0; - var idle = MainThread.CheckSynchronize(0); - - Assert.True(MainThread.IsMainThread); - Assert.Equal(2, FakeExports.ProcessMessagesCalls); - Assert.Equal(2, FakeExports.CheckSynchronizeCalls); - Assert.Equal(0, FakeExports.LastTimeout); - Assert.True(ran); - Assert.False(idle); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void The_pump_operations_refuse_a_worker_thread_instead_of_running_there() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - - var (isMain, pump, check) = RunOnWorker(static () => - { - var isMain = MainThread.IsMainThread; - var pump = Record.Exception(MainThread.ProcessMessages); - var check = Record.Exception(() => MainThread.CheckSynchronize(0)); - return (isMain, pump, check); - }); - - Assert.False(isMain); - Assert.IsType(pump); - Assert.IsType(check); - Assert.Equal(0, FakeExports.ProcessMessagesCalls); - Assert.Equal(0, FakeExports.CheckSynchronizeCalls); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Slots_the_host_left_empty_are_reported_not_jumped_to() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.UseFixture(state); - HostingTest.Bootstrap(host); - var exports = FakeExports.Create(withPusher: false, withPump: false); - Assert.True(host.CallEnable(&exports, 1).IsTrue); - - Assert.False(PluginHost.Context!.HasProcessMessages); - Assert.False(PluginHost.Context.HasCheckSynchronize); - Assert.Equal(0, PluginHost.Context.HostBinding.HostObjectPusher); - var pump = Assert.Throws(MainThread.ProcessMessages); - Assert.Contains("ProcessMessages", pump.Message, StringComparison.Ordinal); - Assert.Throws(() => MainThread.CheckSynchronize(0)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Invoke_on_the_main_thread_runs_inline_without_Lua() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var providerCallsBefore = FakeExports.GetLuaStateCalls; - var box = new int[1]; - - MainThread.Invoke(static b => b[0] = Environment.CurrentManagedThreadId, box); - var doubled = MainThread.Invoke(static x => x * 2, 21); - - Assert.Equal(42, doubled); - Assert.Equal(Environment.CurrentManagedThreadId, box[0]); - Assert.Equal(providerCallsBefore, FakeExports.GetLuaStateCalls); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Invoke_from_a_worker_without_a_synchronize_global_fails_with_a_clear_message() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - - var failure = RunOnWorker(static () => Record.Exception(() => MainThread.Invoke(static _ => { }, 0))); - - var exception = Assert.IsType(failure); - Assert.Contains("synchronize", exception.Message, StringComparison.Ordinal); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Invoke_from_a_worker_rejects_a_synchronize_stand_in_that_does_not_hop_to_the_main_thread() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - InstallSynchronizeStandIn(state); - - var executed = new int[1]; - var failure = RunOnWorker(() => Record.Exception(() => MainThread.Invoke(static box => box[0]++, executed))); - - var exception = Assert.IsType(failure); - Assert.Contains("other than the enabled plugin main thread", exception.Message, StringComparison.Ordinal); - Assert.Equal(0, executed[0]); - Assert.True(PluginHost.IsEnabled); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - Assert.Equal(1, ReadGlobalInteger(state, "synchronize_calls"u8)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void An_exception_thrown_by_inline_main_thread_work_is_rethrown_on_the_caller() - { - HostingTest.RequireNativeLua(); - var sink = HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var failure = Record.Exception(() => MainThread.Invoke( - static message => throw new NotSupportedException(message), - "from the main thread")); - - var exception = Assert.IsType(failure); - Assert.Equal("from the main thread", exception.Message); - Assert.Empty(sink.Errors("dispatch thunk")); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_synchronize_that_does_not_run_the_function_is_reported() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); - using (LuaFrame frame = new(L)) - { - Assert.True(L.TryExecute("function synchronize(f, ...) end"u8, 0).IsOk); - } - - var failure = RunOnWorker(static () => Record.Exception(() => MainThread.Invoke(static _ => { }, 0))); - - var exception = Assert.IsType(failure); - Assert.Contains("without running", exception.Message, StringComparison.Ordinal); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void A_synchronize_that_raises_is_reported_with_the_Lua_message() - { - HostingTest.RequireNativeLua(); - HostingTest.Reset(); - using NativeLuaState state = new(); - using HostSimulator host = new(); - HostingTest.Enable(host, state); - var L = LuaRuntime.AcquireState(); - using (LuaFrame frame = new(L)) - { - Assert.True(L.TryExecute("function synchronize(f, ...) error('host refused') end"u8, 0).IsOk); - } - - var failure = RunOnWorker(static () => Record.Exception(() => MainThread.Invoke(static _ => { }, 0))); - - var exception = Assert.IsType(failure); - Assert.Contains("host refused", exception.Message, StringComparison.Ordinal); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - private static void InstallSynchronizeStandIn(NativeLuaState state) - { - var L = LuaRuntime.AcquireState(); - using LuaFrame frame = new(L); - var status = L.TryExecute( - "synchronize_calls = 0; function synchronize(f, ...) synchronize_calls = synchronize_calls + 1; return f(...) end"u8, - 0); - Assert.True(status.IsOk, "stand-in install failed: " + LuaError.FromStack(L, status).Message); - Assert.Equal(0, LuaApi.lua_gettop(state.L)); - } - - private static long ReadGlobalInteger(NativeLuaState state, ReadOnlySpan name) - { - LuaState L = new(state.Pointer); - using LuaFrame frame = new(L); - Assert.True(L.TryGetGlobal(name).IsOk); - Assert.True(L.TryReadInteger(-1, out var value)); - return value; - } - - private static T RunOnWorker(Func work) - { - // Boxes, not captured locals: static analysis (S2583) does not see a lambda's write to a captured local. - // Join publishes the worker's writes to this thread. - StrongBox result = new(); - StrongBox crash = new(); - Thread worker = new(() => - { - try - { - result.Value = work(); - } - catch (Exception exception) - { - crash.Value = exception; - } - }); - worker.Start(); - worker.Join(); - if (crash.Value is { } failure) throw new InvalidOperationException("The worker crashed.", failure); - - return result.Value!; - } + [Fact] + public void Everything_needs_an_enabled_plugin() + { + HostingTest.Reset(); + + Assert.False(MainThread.IsMainThread); + Assert.Throws(MainThread.ProcessMessages); + Assert.Throws(() => MainThread.CheckSynchronize(0)); + Assert.Throws(() => MainThread.Invoke(static _ => + { + }, 0)); + Assert.Throws(() => MainThread.Invoke(static x => x, 0)); + Assert.Throws(() => MainThread.Invoke(null!, 0)); + Assert.Throws(() => MainThread.Invoke(null!, 0)); + Assert.Throws(() => MainThread.CheckSynchronize(-1)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void ProcessMessages_and_CheckSynchronize_call_the_host_slots_on_the_main_thread() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + + MainThread.ProcessMessages(); + MainThread.ProcessMessages(); + FakeExports.CheckSynchronizeRawResult = 0xFF; + bool ran = MainThread.CheckSynchronize(250); + FakeExports.CheckSynchronizeRawResult = 0; + bool idle = MainThread.CheckSynchronize(0); + + Assert.True(MainThread.IsMainThread); + Assert.Equal(2, FakeExports.ProcessMessagesCalls); + Assert.Equal(2, FakeExports.CheckSynchronizeCalls); + Assert.Equal(0, FakeExports.LastTimeout); + Assert.True(ran); + Assert.False(idle); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void The_pump_operations_refuse_a_worker_thread_instead_of_running_there() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + + (bool isMain, Exception? pump, Exception? check) = RunOnWorker(static () => + { + bool isMain = MainThread.IsMainThread; + Exception? pump = Record.Exception(MainThread.ProcessMessages); + Exception? check = Record.Exception(() => MainThread.CheckSynchronize(0)); + return (isMain, pump, check); + }); + + Assert.False(isMain); + Assert.IsType(pump); + Assert.IsType(check); + Assert.Equal(0, FakeExports.ProcessMessagesCalls); + Assert.Equal(0, FakeExports.CheckSynchronizeCalls); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Slots_the_host_left_empty_are_reported_not_jumped_to() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.UseFixture(state); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(withPusher: false, withPump: false); + Assert.True(host.CallEnable(&exports, 1).IsTrue); + + Assert.False(PluginHost.Context!.HasProcessMessages); + Assert.False(PluginHost.Context.HasCheckSynchronize); + Assert.Equal(0, PluginHost.Context.HostBinding.HostObjectPusher); + InvalidOperationException pump = Assert.Throws(MainThread.ProcessMessages); + Assert.Contains("ProcessMessages", pump.Message, StringComparison.Ordinal); + Assert.Throws(() => MainThread.CheckSynchronize(0)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Invoke_on_the_main_thread_runs_inline_without_Lua() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + int providerCallsBefore = FakeExports.GetLuaStateCalls; + int[] box = new int[1]; + + MainThread.Invoke(static b => b[0] = Environment.CurrentManagedThreadId, box); + int doubled = MainThread.Invoke(static x => x * 2, 21); + + Assert.Equal(42, doubled); + Assert.Equal(Environment.CurrentManagedThreadId, box[0]); + Assert.Equal(providerCallsBefore, FakeExports.GetLuaStateCalls); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Invoke_from_a_worker_without_a_synchronize_global_fails_with_a_clear_message() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + + Exception? failure = RunOnWorker(static () => Record.Exception(() => MainThread.Invoke(static _ => + { + }, 0))); + + InvalidOperationException exception = Assert.IsType(failure); + Assert.Contains("synchronize", exception.Message, StringComparison.Ordinal); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Invoke_from_a_worker_rejects_a_synchronize_stand_in_that_does_not_hop_to_the_main_thread() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + InstallSynchronizeStandIn(state); + + int[] executed = new int[1]; + Exception? failure = + RunOnWorker(() => Record.Exception(() => MainThread.Invoke(static box => box[0]++, executed))); + + InvalidOperationException exception = Assert.IsType(failure); + Assert.Contains("other than the enabled plugin main thread", exception.Message, StringComparison.Ordinal); + Assert.Equal(0, executed[0]); + Assert.True(PluginHost.IsEnabled); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + Assert.Equal(1, ReadGlobalInteger(state, "synchronize_calls"u8)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void An_exception_thrown_by_inline_main_thread_work_is_rethrown_on_the_caller() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + Exception? failure = Record.Exception(() => MainThread.Invoke( + static message => throw new NotSupportedException(message), + "from the main thread")); + + NotSupportedException exception = Assert.IsType(failure); + Assert.Equal("from the main thread", exception.Message); + Assert.Empty(sink.Errors("dispatch thunk")); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_synchronize_that_does_not_run_the_function_is_reported() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState L = LuaRuntime.AcquireState(); + using (LuaFrame frame = new(L)) + { + Assert.True(L.TryExecute("function synchronize(f, ...) end"u8, 0).IsOk); + } + + Exception? failure = RunOnWorker(static () => Record.Exception(() => MainThread.Invoke(static _ => + { + }, 0))); + + InvalidOperationException exception = Assert.IsType(failure); + Assert.Contains("without running", exception.Message, StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_synchronize_that_raises_is_reported_with_the_Lua_message() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState L = LuaRuntime.AcquireState(); + using (LuaFrame frame = new(L)) + { + Assert.True(L.TryExecute("function synchronize(f, ...) error('host refused') end"u8, 0).IsOk); + } + + Exception? failure = RunOnWorker(static () => Record.Exception(() => MainThread.Invoke(static _ => + { + }, 0))); + + InvalidOperationException exception = Assert.IsType(failure); + Assert.Contains("host refused", exception.Message, StringComparison.Ordinal); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + private static void InstallSynchronizeStandIn(NativeLuaState state) + { + LuaState L = LuaRuntime.AcquireState(); + using LuaFrame frame = new(L); + LuaStatus status = L.TryExecute( + "synchronize_calls = 0; function synchronize(f, ...) synchronize_calls = synchronize_calls + 1; return f(...) end"u8, + 0); + Assert.True(status.IsOk, "stand-in install failed: " + LuaError.FromStack(L, status).Message); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + private static long ReadGlobalInteger(NativeLuaState state, ReadOnlySpan name) + { + LuaState L = new(state.Pointer); + using LuaFrame frame = new(L); + Assert.True(L.TryGetGlobal(name).IsOk); + Assert.True(L.TryReadInteger(-1, out long value)); + return value; + } + + private static T RunOnWorker(Func work) + { + // Boxes, not captured locals: static analysis (S2583) does not see a lambda's write to a captured local. + // Join publishes the worker's writes to this thread. + StrongBox result = new(); + StrongBox crash = new(); + Thread worker = new(() => + { + try + { + result.Value = work(); + } + catch (Exception exception) + { + crash.Value = exception; + } + }); + worker.Start(); + worker.Join(); + if (crash.Value is { } failure) + { + throw new InvalidOperationException("The worker crashed.", failure); + } + + return result.Value!; + } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs index 24ac5951..e022711c 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Reflection; using System.Runtime.Loader; + +using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Context; using CheatEngine.SDK.Hosting.Diagnostics; @@ -12,33 +14,36 @@ namespace LivePlugin.Coexistence; /// internal static class CoexistenceDiagnostics { - internal static void LogEnabled(string pluginLabel, Assembly pluginAssembly, PluginContext context) - { - HostLog.Write(HostLogLevel.Information, string.Create( - CultureInfo.InvariantCulture, - $"CheatEngine.SDK coexistence {pluginLabel}: {GetIdentity(pluginLabel, pluginAssembly)}; PluginId={context.PluginId}; Epoch={context.Epoch}.")); - } + internal static void LogEnabled(string pluginLabel, Assembly pluginAssembly, PluginContext context) + { + HostLog.Write(HostLogLevel.Information, string.Create( + CultureInfo.InvariantCulture, + $"CheatEngine.SDK coexistence {pluginLabel}: {GetIdentity(pluginLabel, pluginAssembly)}; PluginId={context.PluginId}; Epoch={context.Epoch}.")); + } - internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly) - { - var hostingAssembly = typeof(CheatEngine.SDK.Hosting.Bootstrap.PluginHost).Assembly; - var pluginLoadContext = AssemblyLoadContext.GetLoadContext(pluginAssembly); - var hostingLoadContext = AssemblyLoadContext.GetLoadContext(hostingAssembly); + internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly) + { + Assembly hostingAssembly = typeof(PluginHost).Assembly; + AssemblyLoadContext? pluginLoadContext = AssemblyLoadContext.GetLoadContext(pluginAssembly); + AssemblyLoadContext? hostingLoadContext = AssemblyLoadContext.GetLoadContext(hostingAssembly); - return string.Create( - CultureInfo.InvariantCulture, - $"Plugin={pluginLabel}; PluginAssembly={pluginAssembly.FullName}; PluginMvid={pluginAssembly.ManifestModule.ModuleVersionId}; " + - $"HostingAssembly={hostingAssembly.FullName}; HostingMvid={hostingAssembly.ManifestModule.ModuleVersionId}; " + - $"PluginALC={Describe(pluginLoadContext)}; HostingALC={Describe(hostingLoadContext)}; " + - $"SameALC={ReferenceEquals(pluginLoadContext, hostingLoadContext)}"); - } + return string.Create( + CultureInfo.InvariantCulture, + $"Plugin={pluginLabel}; PluginAssembly={pluginAssembly.FullName}; PluginMvid={pluginAssembly.ManifestModule.ModuleVersionId}; " + + $"HostingAssembly={hostingAssembly.FullName}; HostingMvid={hostingAssembly.ManifestModule.ModuleVersionId}; " + + $"PluginALC={Describe(pluginLoadContext)}; HostingALC={Describe(hostingLoadContext)}; " + + $"SameALC={ReferenceEquals(pluginLoadContext, hostingLoadContext)}"); + } - private static string Describe(AssemblyLoadContext? loadContext) - { - if (loadContext is null) return ""; + private static string Describe(AssemblyLoadContext? loadContext) + { + if (loadContext is null) + { + return ""; + } - return string.Create( - CultureInfo.InvariantCulture, - $"Name={loadContext.Name ?? ""}, Collectible={loadContext.IsCollectible}"); - } + return string.Create( + CultureInfo.InvariantCulture, + $"Name={loadContext.Name ?? ""}, Collectible={loadContext.IsCollectible}"); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props index 7e85bd14..66d1463d 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props @@ -13,21 +13,21 @@ - - - - - - + + + + + + - - - - - - + + + + + + diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CheatEngine.SDK.LivePlugin.Coexistence.PluginA.csproj b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CheatEngine.SDK.LivePlugin.Coexistence.PluginA.csproj index 974d794e..bc096b85 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CheatEngine.SDK.LivePlugin.Coexistence.PluginA.csproj +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CheatEngine.SDK.LivePlugin.Coexistence.PluginA.csproj @@ -1,6 +1,6 @@ - + diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs index dc33de81..e4147526 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs @@ -1,4 +1,3 @@ -using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; @@ -12,23 +11,25 @@ namespace LivePlugin.Coexistence.PluginA; [CheatEnginePlugin("CheatEngine.SDK Coexistence Plugin A")] public sealed class CoexistencePluginA : CheatEnginePlugin { - /// - protected override void OnEnable() - { - CoexistenceDiagnostics.LogEnabled("A", typeof(CoexistencePluginA).Assembly, Context); + /// + protected override void OnEnable() + { + CoexistenceDiagnostics.LogEnabled("A", typeof(CoexistencePluginA).Assembly, Context); - var result = CoexistencePluginAFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); - HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - "CheatEngine.SDK coexistence A: RegisterLuaFunctions -> " + result + "."); - if (!result.IsOk) - throw new InvalidOperationException("Plugin A could not register its distinct coexistence Lua functions."); - } + var result = CoexistencePluginAFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); + HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + "CheatEngine.SDK coexistence A: RegisterLuaFunctions -> " + result + "."); + if (!result.IsOk) + { + throw new InvalidOperationException("Plugin A could not register its distinct coexistence Lua functions."); + } + } - /// - protected override void OnDisable() - { - var result = CoexistencePluginAFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); - HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - "CheatEngine.SDK coexistence A: UnregisterLuaFunctions -> " + result + "."); - } + /// + protected override void OnDisable() + { + var result = CoexistencePluginAFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); + HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + "CheatEngine.SDK coexistence A: UnregisterLuaFunctions -> " + result + "."); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs index 86ff2f12..df5cb67a 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs @@ -4,11 +4,17 @@ namespace LivePlugin.Coexistence.PluginA; internal static partial class CoexistencePluginAFunctions { - private static long s_pingCount; + private static long s_pingCount; - [LuaFunction("cheatengine_sdk_coexistence_a_ping")] - public static long Ping() => Interlocked.Increment(ref s_pingCount); + [LuaFunction("cheatengine_sdk_coexistence_a_ping")] + public static long Ping() + { + return Interlocked.Increment(ref s_pingCount); + } - [LuaFunction("cheatengine_sdk_coexistence_a_identity")] - public static string Identity() => CoexistenceDiagnostics.GetIdentity("A", typeof(CoexistencePluginA).Assembly); + [LuaFunction("cheatengine_sdk_coexistence_a_identity")] + public static string Identity() + { + return CoexistenceDiagnostics.GetIdentity("A", typeof(CoexistencePluginA).Assembly); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CheatEngine.SDK.LivePlugin.Coexistence.PluginB.csproj b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CheatEngine.SDK.LivePlugin.Coexistence.PluginB.csproj index a8b4d18c..ffb44640 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CheatEngine.SDK.LivePlugin.Coexistence.PluginB.csproj +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CheatEngine.SDK.LivePlugin.Coexistence.PluginB.csproj @@ -1,6 +1,6 @@ - + diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs index b19e9899..0ef3af2f 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs @@ -1,4 +1,3 @@ -using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; @@ -12,23 +11,25 @@ namespace LivePlugin.Coexistence.PluginB; [CheatEnginePlugin("CheatEngine.SDK Coexistence Plugin B")] public sealed class CoexistencePluginB : CheatEnginePlugin { - /// - protected override void OnEnable() - { - CoexistenceDiagnostics.LogEnabled("B", typeof(CoexistencePluginB).Assembly, Context); + /// + protected override void OnEnable() + { + CoexistenceDiagnostics.LogEnabled("B", typeof(CoexistencePluginB).Assembly, Context); - var result = CoexistencePluginBFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); - HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - "CheatEngine.SDK coexistence B: RegisterLuaFunctions -> " + result + "."); - if (!result.IsOk) - throw new InvalidOperationException("Plugin B could not register its distinct coexistence Lua functions."); - } + var result = CoexistencePluginBFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); + HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + "CheatEngine.SDK coexistence B: RegisterLuaFunctions -> " + result + "."); + if (!result.IsOk) + { + throw new InvalidOperationException("Plugin B could not register its distinct coexistence Lua functions."); + } + } - /// - protected override void OnDisable() - { - var result = CoexistencePluginBFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); - HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - "CheatEngine.SDK coexistence B: UnregisterLuaFunctions -> " + result + "."); - } + /// + protected override void OnDisable() + { + var result = CoexistencePluginBFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); + HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + "CheatEngine.SDK coexistence B: UnregisterLuaFunctions -> " + result + "."); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs index eb4a7a33..ee848e91 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs @@ -4,11 +4,17 @@ namespace LivePlugin.Coexistence.PluginB; internal static partial class CoexistencePluginBFunctions { - private static long s_pingCount; + private static long s_pingCount; - [LuaFunction("cheatengine_sdk_coexistence_b_ping")] - public static long Ping() => Interlocked.Increment(ref s_pingCount); + [LuaFunction("cheatengine_sdk_coexistence_b_ping")] + public static long Ping() + { + return Interlocked.Increment(ref s_pingCount); + } - [LuaFunction("cheatengine_sdk_coexistence_b_identity")] - public static string Identity() => CoexistenceDiagnostics.GetIdentity("B", typeof(CoexistencePluginB).Assembly); + [LuaFunction("cheatengine_sdk_coexistence_b_identity")] + public static string Identity() + { + return CoexistenceDiagnostics.GetIdentity("B", typeof(CoexistencePluginB).Assembly); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md index 94968ee9..90acf273 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md @@ -1,6 +1,7 @@ # CheatEngine.SDK.LivePlugin.Coexistence -An opt-in, manual fixture for the exact-host portion of [SDK-005](../../documentations/engineering/work-items/SDK-005.md). +An opt-in, manual fixture for the exact-host portion +of [SDK-005](../../documentations/engineering/work-items/SDK-005.md). It builds two distinct plugin assemblies, `PluginA` and `PluginB`, and records the identities that Cheat Engine actually loads. It is not a unit test, it is not part of ordinary CI, and this repository contains **no executed result** for it. @@ -18,11 +19,11 @@ process, mutate memory, install hooks, or create a loader isolation model. ## What it can and cannot establish -| Observation | Establishes | Does not establish | -|---|---|---| -| A and B enable and answer separate Lua globals | The recorded host accepted both exact output directories for that run | A general CE version/loader guarantee | -| The identity lines | The actual plugin/Hosting assembly and runtime load-context relationship for that run | That all static state is safely isolated; Lua and CE globals can still be shared | -| Disable A while B remains callable | The narrow A/B global-registration and lifecycle observation | Callback, worker-dispatch, target-provenance, or retained-owner safety | +| Observation | Establishes | Does not establish | +|------------------------------------------------|---------------------------------------------------------------------------------------|----------------------------------------------------------------------------------| +| A and B enable and answer separate Lua globals | The recorded host accepted both exact output directories for that run | A general CE version/loader guarantee | +| The identity lines | The actual plugin/Hosting assembly and runtime load-context relationship for that run | That all static state is safely isolated; Lua and CE globals can still be shared | +| Disable A while B remains callable | The narrow A/B global-registration and lifecycle observation | Callback, worker-dispatch, target-provenance, or retained-owner safety | The fixture intentionally uses the current source graph for both plugins. A side-by-side SDK-version run is separate: obtain two qualified package/output tuples, keep each complete dependency set in its own directory, record their @@ -87,7 +88,8 @@ behaviour the fixture is meant to observe. This fixture observes a managed plugin route only. .NET permits a collectible `AssemblyLoadContext` to be unloaded cooperatively only after relevant threads and strong references are gone; it does not say what load context Cheat -Engine uses for a plugin. See [AssemblyLoadContext unloadability](https://learn.microsoft.com/dotnet/standard/assembly/unloadability). +Engine uses for a plugin. +See [AssemblyLoadContext unloadability](https://learn.microsoft.com/dotnet/standard/assembly/unloadability). Native AOT is a separate deployment profile. A standalone AOT publish/probe is publication evidence, not proof that Cheat Engine can load, disable and remove a native plugin. Microsoft documents that unloading Native AOT libraries with diff --git a/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs b/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs index 7c21c259..9fe9f6e1 100644 --- a/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs +++ b/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs @@ -1,10 +1,13 @@ using System.Globalization; + using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; namespace LivePlugin; @@ -18,64 +21,64 @@ namespace LivePlugin; [CheatEnginePlugin("CheatEngine.SDK Live Plugin")] public sealed class CheatEngineSdkLivePlugin : CheatEnginePlugin { - // PluginHost exposes no call counter for CEPluginInitialize. This counts how many times this plugin's OnEnable - // has run across enable, disable and re-enable cycles, and is logged next to the two record sizes that - // PluginHost does expose. - private static int s_enableCount; + // PluginHost exposes no call counter for CEPluginInitialize. This counts how many times this plugin's OnEnable + // has run across enable, disable and re-enable cycles, and is logged next to the two record sizes that + // PluginHost does expose. + private static int s_enableCount; - /// - protected override void OnEnable() - { - var enableCount = Interlocked.Increment(ref s_enableCount); - LogEnableDiagnostics(enableCount); + /// + protected override void OnEnable() + { + int enableCount = Interlocked.Increment(ref s_enableCount); + LogEnableDiagnostics(enableCount); - var state = LuaRuntime.AcquireState(); - var registered = LiveFunctions.RegisterLuaFunctions(state); - HostLog.Write( - registered.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - string.Create(CultureInfo.InvariantCulture, - $"CheatEngine.SDK Live Plugin: RegisterLuaFunctions -> {registered}.")); + LuaState state = LuaRuntime.AcquireState(); + var registered = LiveFunctions.RegisterLuaFunctions(state); + HostLog.Write( + registered.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + string.Create(CultureInfo.InvariantCulture, + $"CheatEngine.SDK Live Plugin: RegisterLuaFunctions -> {registered}.")); - ReadMemoryAdjacentPrimitive(); - } + ReadMemoryAdjacentPrimitive(); + } - /// - protected override void OnDisable() - { - var state = LuaRuntime.AcquireState(); - var unregistered = LiveFunctions.UnregisterLuaFunctions(state); - HostLog.Write( - unregistered.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - string.Create(CultureInfo.InvariantCulture, - $"CheatEngine.SDK Live Plugin: UnregisterLuaFunctions -> {unregistered}.")); - } + /// + protected override void OnDisable() + { + LuaState state = LuaRuntime.AcquireState(); + var unregistered = LiveFunctions.UnregisterLuaFunctions(state); + HostLog.Write( + unregistered.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + string.Create(CultureInfo.InvariantCulture, + $"CheatEngine.SDK Live Plugin: UnregisterLuaFunctions -> {unregistered}.")); + } - // Logs, as one entry a person reads in DebugView, what Cheat Engine reported to the host: the two record sizes - // PluginHost observed, plus the exports record size and the slot presence that PluginContext reports. - private static void LogEnableDiagnostics(int enableCount) - { - var context = Context; - var message = string.Create( - CultureInfo.InvariantCulture, - $""" - CheatEngine.SDK Live Plugin: OnEnable #{enableCount} (this plugin instance's own enable count; PluginHost does not expose a count of CEPluginInitialize calls). - PluginContext: PluginId={context.PluginId}, Epoch={context.Epoch}, MainThreadId={context.MainThreadId}, ReportedExportsSize={context.ReportedExportsSize} (expect 48 on x64), HasProcessMessages={context.HasProcessMessages}, HasCheckSynchronize={context.HasCheckSynchronize}. - PluginHost: LastInitRecordArgument={PluginHost.LastInitRecordArgument} (the raw CEPluginInitialize second integer; opaque until a CE 7.7 live probe establishes its meaning), LastVersionRecordSize={PluginHost.LastVersionRecordSize} (the GetVersion host-reported record size; expect >= 16), IsInitialized={PluginHost.IsInitialized}. - """); - HostLog.Write(HostLogLevel.Information, message); - } + // Logs, as one entry a person reads in DebugView, what Cheat Engine reported to the host: the two record sizes + // PluginHost observed, plus the exports record size and the slot presence that PluginContext reports. + private static void LogEnableDiagnostics(int enableCount) + { + PluginContext context = Context; + string message = string.Create( + CultureInfo.InvariantCulture, + $""" + CheatEngine.SDK Live Plugin: OnEnable #{enableCount} (this plugin instance's own enable count; PluginHost does not expose a count of CEPluginInitialize calls). + PluginContext: PluginId={context.PluginId}, Epoch={context.Epoch}, MainThreadId={context.MainThreadId}, ReportedExportsSize={context.ReportedExportsSize} (expect 48 on x64), HasProcessMessages={context.HasProcessMessages}, HasCheckSynchronize={context.HasCheckSynchronize}. + PluginHost: LastInitRecordArgument={PluginHost.LastInitRecordArgument} (the raw CEPluginInitialize second integer; opaque until a CE 7.7 live probe establishes its meaning), LastVersionRecordSize={PluginHost.LastVersionRecordSize} (the GetVersion host-reported record size; expect >= 16), IsInitialized={PluginHost.IsInitialized}. + """); + HostLog.Write(HostLogLevel.Information, message); + } - private static void ReadMemoryAdjacentPrimitive() - { - // A placeholder address: point it at a readable location in an attached target (for example a module base - // from getAddress()) to log a real value. Address supplies the number-or-hex-text convention and the - // culture-invariant formatting. The read goes through this plugin's own readInteger binding (MemoryBindings); - // CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt32 offers the same call as a ready-made wrapper. - var probe = Address.FromUInt64(0x00400000UL); - var ok = MemoryBindings.TryReadInt32((nuint)probe.Value, out var value); - HostLog.Write( - HostLogLevel.Information, - string.Create(CultureInfo.InvariantCulture, - $"CheatEngine.SDK Live Plugin: readInteger({probe}) -> ok={ok}, value={value}.")); - } + private static void ReadMemoryAdjacentPrimitive() + { + // A placeholder address: point it at a readable location in an attached target (for example a module base + // from getAddress()) to log a real value. Address supplies the number-or-hex-text convention and the + // culture-invariant formatting. The read goes through this plugin's own readInteger binding (MemoryBindings); + // CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt32 offers the same call as a ready-made wrapper. + Address probe = Address.FromUInt64(0x00400000UL); + bool ok = MemoryBindings.TryReadInt32((nuint) probe.Value, out int value); + HostLog.Write( + HostLogLevel.Information, + string.Create(CultureInfo.InvariantCulture, + $"CheatEngine.SDK Live Plugin: readInteger({probe}) -> ok={ok}, value={value}.")); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin/LiveFunctions.cs b/tests/CheatEngine.SDK.LivePlugin/LiveFunctions.cs index ea067506..b929bf00 100644 --- a/tests/CheatEngine.SDK.LivePlugin/LiveFunctions.cs +++ b/tests/CheatEngine.SDK.LivePlugin/LiveFunctions.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Annotations.Lua; namespace LivePlugin; @@ -12,23 +13,23 @@ namespace LivePlugin; /// internal static partial class LiveFunctions { - private static long s_pingCount; + private static long s_pingCount; - /// Lua: cheatengine_sdk_live_ping(). Increments and returns a process-lifetime counter. - /// The counter, after incrementing. - [LuaFunction("cheatengine_sdk_live_ping")] - public static long Ping() - { - return Interlocked.Increment(ref s_pingCount); - } + /// Lua: cheatengine_sdk_live_ping(). Increments and returns a process-lifetime counter. + /// The counter, after incrementing. + [LuaFunction("cheatengine_sdk_live_ping")] + public static long Ping() + { + return Interlocked.Increment(ref s_pingCount); + } - /// Lua: cheatengine_sdk_live_status(). A one-line status string for a human at the Lua console. - /// The status text. - [LuaFunction("cheatengine_sdk_live_status")] - public static string Status() - { - return string.Create( - CultureInfo.InvariantCulture, - $"CheatEngine.SDK Live Plugin enabled; cheatengine_sdk_live_ping has been called {Volatile.Read(ref s_pingCount)} time(s)."); - } + /// Lua: cheatengine_sdk_live_status(). A one-line status string for a human at the Lua console. + /// The status text. + [LuaFunction("cheatengine_sdk_live_status")] + public static string Status() + { + return string.Create( + CultureInfo.InvariantCulture, + $"CheatEngine.SDK Live Plugin enabled; cheatengine_sdk_live_ping has been called {Volatile.Read(ref s_pingCount)} time(s)."); + } } diff --git a/tests/CheatEngine.SDK.LivePlugin/MemoryBindings.cs b/tests/CheatEngine.SDK.LivePlugin/MemoryBindings.cs index a4473710..f84128d0 100644 --- a/tests/CheatEngine.SDK.LivePlugin/MemoryBindings.cs +++ b/tests/CheatEngine.SDK.LivePlugin/MemoryBindings.cs @@ -12,20 +12,20 @@ namespace LivePlugin; /// internal static partial class MemoryBindings { - /// Reads a signed 32-bit integer at through Cheat Engine's readInteger global. - /// The address to read. - /// The value read, or 0 when the address is not readable. - /// when the address is not readable (Cheat Engine returned nil). - /// - /// Cheat Engine returns an unsigned value unless readInteger's optional second argument is - /// . Keep that host-specific flag private so callers cannot accidentally request an - /// unsigned value that the result marshaller rejects for negative values. - /// - internal static bool TryReadInt32(nuint address, out int value) - { - return TryReadInt32Raw(address, true, out value); - } + /// Reads a signed 32-bit integer at through Cheat Engine's readInteger global. + /// The address to read. + /// The value read, or 0 when the address is not readable. + /// when the address is not readable (Cheat Engine returned nil). + /// + /// Cheat Engine returns an unsigned value unless readInteger's optional second argument is + /// . Keep that host-specific flag private so callers cannot accidentally request an + /// unsigned value that the result marshaller rejects for negative values. + /// + internal static bool TryReadInt32(nuint address, out int value) + { + return TryReadInt32Raw(address, true, out value); + } - [LuaGlobal("readInteger")] - private static partial bool TryReadInt32Raw(nuint address, bool signed, out int value); + [LuaGlobal("readInteger")] + private static partial bool TryReadInt32Raw(nuint address, bool signed, out int value); } diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStateTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStateTests.cs index f99020ff..7a132ecc 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStateTests.cs +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStateTests.cs @@ -105,7 +105,8 @@ public void CaptureHostProfile_when_fresh_authorization_and_pid_match_passes_fre Assert.Equal("C:\\disposable-target.exe", result); Assert.Equal(2, evaluationCount); - Assert.Contains("Runtime gate: denied; The manifest was not present during enable.", LiveProbeState.GetStatus()); + Assert.Contains("Runtime gate: denied; The manifest was not present during enable.", + LiveProbeState.GetStatus()); } [Fact] @@ -122,7 +123,8 @@ public void TryRequireRuntimeAuthorization_when_manifest_expires_after_enable_re LiveProbeState.ValidateAfterEnable(evaluateAuthorization, static () => 401); bool isAllowed = LiveProbeState.TryRequireRuntimeAuthorization(evaluateAuthorization, - static () => throw new InvalidOperationException("A denied authorization must not read CE's PID."), out string denial); + static () => throw new InvalidOperationException("A denied authorization must not read CE's PID."), + out string denial); Assert.False(isAllowed); Assert.Equal("Live probe denied: The authorization manifest has expired.", denial); diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs index 67d87f12..f5f1c2c0 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs @@ -1,4 +1,4 @@ -namespace LiveProbe; +namespace CheatEngine.SDK.LiveProbe.Tests; // The source-linked LiveProbeState tests inject the PID reader and never call this generated Lua global. Keeping the // stub local avoids loading a plugin generator or a Cheat Engine host during unit tests. diff --git a/tests/CheatEngine.SDK.LiveProbe/AuthorizationDecision.cs b/tests/CheatEngine.SDK.LiveProbe/AuthorizationDecision.cs index 7165fae6..92f24a1d 100644 --- a/tests/CheatEngine.SDK.LiveProbe/AuthorizationDecision.cs +++ b/tests/CheatEngine.SDK.LiveProbe/AuthorizationDecision.cs @@ -1,17 +1,27 @@ namespace LiveProbe; -internal readonly record struct AuthorizationDecision(bool IsAllowed, string Reason, string HostPath, string HostSha256, - int TargetProcessId, string TargetPath, string TargetSha256, DateTimeOffset ExpiresUtc) +internal readonly record struct AuthorizationDecision( + bool IsAllowed, + string Reason, + string HostPath, + string HostSha256, + int TargetProcessId, + string TargetPath, + string TargetSha256, + DateTimeOffset ExpiresUtc) { - internal static AuthorizationDecision Denied(string reason) - { - return new AuthorizationDecision(false, reason, string.Empty, string.Empty, 0, string.Empty, string.Empty, default); - } + internal static AuthorizationDecision Denied(string reason) + { + return new AuthorizationDecision(false, reason, string.Empty, string.Empty, 0, string.Empty, string.Empty, + default); + } - internal static AuthorizationDecision Allowed(string hostPath, string hostSha256, int targetProcessId, string targetPath, - string targetSha256, DateTimeOffset expiresUtc) - { - return new AuthorizationDecision(true, "Host and disposable target manifest are verified.", hostPath, hostSha256, - targetProcessId, targetPath, targetSha256, expiresUtc); - } + internal static AuthorizationDecision Allowed(string hostPath, string hostSha256, int targetProcessId, + string targetPath, + string targetSha256, DateTimeOffset expiresUtc) + { + return new AuthorizationDecision(true, "Host and disposable target manifest are verified.", hostPath, + hostSha256, + targetProcessId, targetPath, targetSha256, expiresUtc); + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs index 0ff9a010..f1d18bdc 100644 --- a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs +++ b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs @@ -1,8 +1,11 @@ +using System.ComponentModel; using System.Diagnostics; +using System.Globalization; using System.Reflection.PortableExecutable; using System.Security.Cryptography; using System.Text; using System.Text.Json; + using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Lua.Interop.Loading; @@ -12,153 +15,160 @@ namespace LiveProbe; // emits a self-contained JSON record for the operator to retain outside the repository. internal static class HostProfileObservation { - private const string BridgeFileName = "cheatengine-sdk-lua-bridge.dll"; - private const string ObservedOutcome = "observed"; - - internal static string Capture(AuthorizationDecision authorization) - { - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) - { - writer.WriteStartObject(); - writer.WriteString("schema", "ce77-live-host-profile-v1"); - writer.WriteString("capturedAtUtc", DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture)); - writer.WriteString("catalogRevision", "ce-7.7.0.10621-x64-source-index"); - - writer.WriteStartObject("authorization"); - writer.WriteBoolean("allowed", authorization.IsAllowed); - writer.WriteString("outcome", authorization.Reason); - writer.WriteString("expiresUtc", authorization.ExpiresUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture)); - writer.WriteEndObject(); - - WriteFileIdentity(writer, "host", authorization.HostPath, authorization.HostSha256); - WriteFileIdentity(writer, "lua", FindLoadedModulePath(LuaModule.CheatEngine64ModuleName), null); - WriteFileIdentity(writer, "bridge", Path.Combine(AppContext.BaseDirectory, BridgeFileName), null); - WriteFileIdentity(writer, "plugin", typeof(HostProfileObservation).Assembly.Location, null); - - writer.WriteStartObject("target"); - writer.WriteNumber("processId", authorization.TargetProcessId); - WriteFileIdentityFields(writer, authorization.TargetPath, authorization.TargetSha256); - writer.WriteEndObject(); - writer.WriteEndObject(); - } - - return Encoding.UTF8.GetString(stream.ToArray()); - } - - private static void WriteFileIdentity(Utf8JsonWriter writer, string name, string? path, string? knownHash) - { - writer.WriteStartObject(name); - WriteFileIdentityFields(writer, path, knownHash); - writer.WriteEndObject(); - } - - private static void WriteFileIdentityFields(Utf8JsonWriter writer, string? path, string? knownHash) - { - if (string.IsNullOrWhiteSpace(path)) - { - writer.WriteString("outcome", "not-observed"); - return; - } - - writer.WriteString("path", path); - string outcome = InspectFileForIdentity(path, static filePath => File.OpenRead(filePath)); - writer.WriteString("outcome", outcome); - if (!string.Equals(outcome, ObservedOutcome, StringComparison.Ordinal)) - { - return; - } - - writer.WriteString("sha256", knownHash ?? HashFile(path)); - writer.WriteString("fileVersion", ObserveFileVersion(path, - static filePath => FileVersionInfo.GetVersionInfo(filePath).FileVersion)); - - try - { - using var file = File.OpenRead(path); - using var reader = new PEReader(file); - writer.WriteString("machine", reader.PEHeaders.CoffHeader.Machine.ToString()); - } - catch (Exception exception) when (exception is BadImageFormatException or IOException or UnauthorizedAccessException) - { - writer.WriteString("machine", "unavailable: " + exception.GetType().Name); - } - } - - // The opener is an internal test seam only. Production passes File.OpenRead, which already underpins the later hash - // and PE reads; this preliminary open avoids turning an access or sharing failure into a missing-file claim. - internal static string InspectFileForIdentity(string? path, Func openRead) - { - if (string.IsNullOrWhiteSpace(path)) return "not-observed"; - - try - { - using Stream file = openRead(path); - return ObservedOutcome; - } - catch (FileNotFoundException) - { - return "file-not-found"; - } - catch (DirectoryNotFoundException) - { - return "file-not-found"; - } - catch (UnauthorizedAccessException) - { - return "unavailable: UnauthorizedAccessException"; - } - catch (IOException exception) - { - return "unavailable: " + exception.GetType().Name; - } - } - - // File metadata is read after the preliminary open, so it needs its own failure classification when the file is - // deleted, locked, or access is revoked in between. The reader is an internal test seam only. - internal static string ObserveFileVersion(string path, Func getFileVersion) - { - try - { - return getFileVersion(path) ?? "not-present"; - } - catch (Exception exception) when (exception is ArgumentException or System.ComponentModel.Win32Exception - or IOException or UnauthorizedAccessException) - { - return "unavailable: " + exception.GetType().Name; - } - } - - private static string? FindLoadedModulePath(string moduleName) - { - try - { - using var process = Process.GetCurrentProcess(); - foreach (ProcessModule module in process.Modules) - { - if (string.Equals(Path.GetFileName(module.FileName), moduleName, StringComparison.OrdinalIgnoreCase)) - return module.FileName; - } - } - catch (Exception exception) when (exception is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) - { - HostLog.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Warning, - "CE 7.7 host-profile probe could not enumerate loaded modules.", exception); - } - - return null; - } - - private static string HashFile(string path) - { - try - { - using var file = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(file)); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - return "unavailable: " + exception.GetType().Name; - } - } + private const string BridgeFileName = "cheatengine-sdk-lua-bridge.dll"; + private const string ObservedOutcome = "observed"; + + internal static string Capture(AuthorizationDecision authorization) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + writer.WriteString("schema", "ce77-live-host-profile-v1"); + writer.WriteString("capturedAtUtc", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + writer.WriteString("catalogRevision", "ce-7.7.0.10621-x64-source-index"); + + writer.WriteStartObject("authorization"); + writer.WriteBoolean("allowed", authorization.IsAllowed); + writer.WriteString("outcome", authorization.Reason); + writer.WriteString("expiresUtc", authorization.ExpiresUtc.ToString("O", CultureInfo.InvariantCulture)); + writer.WriteEndObject(); + + WriteFileIdentity(writer, "host", authorization.HostPath, authorization.HostSha256); + WriteFileIdentity(writer, "lua", FindLoadedModulePath(LuaModule.CheatEngine64ModuleName), null); + WriteFileIdentity(writer, "bridge", Path.Combine(AppContext.BaseDirectory, BridgeFileName), null); + WriteFileIdentity(writer, "plugin", typeof(HostProfileObservation).Assembly.Location, null); + + writer.WriteStartObject("target"); + writer.WriteNumber("processId", authorization.TargetProcessId); + WriteFileIdentityFields(writer, authorization.TargetPath, authorization.TargetSha256); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteFileIdentity(Utf8JsonWriter writer, string name, string? path, string? knownHash) + { + writer.WriteStartObject(name); + WriteFileIdentityFields(writer, path, knownHash); + writer.WriteEndObject(); + } + + private static void WriteFileIdentityFields(Utf8JsonWriter writer, string? path, string? knownHash) + { + if (string.IsNullOrWhiteSpace(path)) + { + writer.WriteString("outcome", "not-observed"); + return; + } + + writer.WriteString("path", path); + string outcome = InspectFileForIdentity(path, static filePath => File.OpenRead(filePath)); + writer.WriteString("outcome", outcome); + if (!string.Equals(outcome, ObservedOutcome, StringComparison.Ordinal)) + { + return; + } + + writer.WriteString("sha256", knownHash ?? HashFile(path)); + writer.WriteString("fileVersion", ObserveFileVersion(path, + static filePath => FileVersionInfo.GetVersionInfo(filePath).FileVersion)); + + try + { + using FileStream file = File.OpenRead(path); + using PEReader reader = new(file); + writer.WriteString("machine", reader.PEHeaders.CoffHeader.Machine.ToString()); + } + catch (Exception exception) when (exception is BadImageFormatException or IOException + or UnauthorizedAccessException) + { + writer.WriteString("machine", "unavailable: " + exception.GetType().Name); + } + } + + // The opener is an internal test seam only. Production passes File.OpenRead, which already underpins the later hash + // and PE reads; this preliminary open avoids turning an access or sharing failure into a missing-file claim. + internal static string InspectFileForIdentity(string? path, Func openRead) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "not-observed"; + } + + try + { + using Stream file = openRead(path); + return ObservedOutcome; + } + catch (FileNotFoundException) + { + return "file-not-found"; + } + catch (DirectoryNotFoundException) + { + return "file-not-found"; + } + catch (UnauthorizedAccessException) + { + return "unavailable: UnauthorizedAccessException"; + } + catch (IOException exception) + { + return "unavailable: " + exception.GetType().Name; + } + } + + // File metadata is read after the preliminary open, so it needs its own failure classification when the file is + // deleted, locked, or access is revoked in between. The reader is an internal test seam only. + internal static string ObserveFileVersion(string path, Func getFileVersion) + { + try + { + return getFileVersion(path) ?? "not-present"; + } + catch (Exception exception) when (exception is ArgumentException or Win32Exception + or IOException or UnauthorizedAccessException) + { + return "unavailable: " + exception.GetType().Name; + } + } + + private static string? FindLoadedModulePath(string moduleName) + { + try + { + using Process process = Process.GetCurrentProcess(); + foreach (ProcessModule module in process.Modules) + { + if (string.Equals(Path.GetFileName(module.FileName), moduleName, StringComparison.OrdinalIgnoreCase)) + { + return module.FileName; + } + } + } + catch (Exception exception) when (exception is InvalidOperationException or NotSupportedException + or Win32Exception) + { + HostLog.Write(HostLogLevel.Warning, + "CE 7.7 host-profile probe could not enumerate loaded modules.", exception); + } + + return null; + } + + private static string HashFile(string path) + { + try + { + using FileStream file = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(file)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return "unavailable: " + exception.GetType().Name; + } + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs index 34312437..9f500e56 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs @@ -1,4 +1,6 @@ +using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Reflection.PortableExecutable; using System.Security.Cryptography; @@ -11,192 +13,247 @@ namespace LiveProbe; // disposable process alive. This is deliberately an internal test-only contract, not an SDK configuration API. internal static class LiveProbeAuthorization { - internal const string ExactCheatEngineSha256 = "9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D"; - internal const string ExactCheatEngineFileVersion = "7.7.0.10621"; - internal const string Acknowledgement = "I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET"; - private const string AcknowledgementVariable = "CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT"; - private const string ManifestVariable = "CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE"; - - internal static AuthorizationDecision Evaluate() - { - if (IntPtr.Size != 8) - return AuthorizationDecision.Denied("The current process is not x64."); - - var acknowledgement = Environment.GetEnvironmentVariable(AcknowledgementVariable); - if (!string.Equals(acknowledgement, Acknowledgement, StringComparison.Ordinal)) - return AuthorizationDecision.Denied("The explicit CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT phrase is absent."); - - var manifestPath = Environment.GetEnvironmentVariable(ManifestVariable); - if (string.IsNullOrWhiteSpace(manifestPath)) - return AuthorizationDecision.Denied("CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent."); - - if (!TryReadManifest(manifestPath, out var manifest, out var manifestFailure)) - return AuthorizationDecision.Denied(manifestFailure); - - if (!string.Equals(manifest.Acknowledgement, Acknowledgement, StringComparison.Ordinal)) - return AuthorizationDecision.Denied("The authorization manifest has no matching acknowledgement."); - - if (!manifest.Disposable) - return AuthorizationDecision.Denied("The authorization manifest does not mark the target disposable."); - - if (manifest.ExpiresUtc <= DateTimeOffset.UtcNow) - return AuthorizationDecision.Denied("The authorization manifest has expired."); - - if (!TryGetProcessImage(Environment.ProcessId, out var hostPath, out var hostFailure)) - return AuthorizationDecision.Denied("The CE host image cannot be inspected: " + hostFailure); - - if (!IsAmd64Pe(hostPath, out var hostArchitectureFailure)) - return AuthorizationDecision.Denied("The CE host image is not an AMD64 PE: " + hostArchitectureFailure); - - if (!TryHash(hostPath, out var hostHash, out var hostHashFailure)) - return AuthorizationDecision.Denied("The CE host image cannot be hashed: " + hostHashFailure); - - if (!string.Equals(hostHash, ExactCheatEngineSha256, StringComparison.Ordinal)) - return AuthorizationDecision.Denied("The host SHA-256 is not the pinned CE 7.7.0.10621 x64 binary."); - - if (!string.Equals(manifest.HostSha256, ExactCheatEngineSha256, StringComparison.Ordinal)) - return AuthorizationDecision.Denied("The manifest does not pin the CE 7.7.0.10621 x64 SHA-256."); - - var hostVersion = FileVersionInfo.GetVersionInfo(hostPath).FileVersion; - if (!string.Equals(hostVersion, ExactCheatEngineFileVersion, StringComparison.Ordinal)) - return AuthorizationDecision.Denied("The pinned CE executable has an unexpected file version: " + hostVersion + "."); - - if (manifest.TargetProcessId == Environment.ProcessId) - return AuthorizationDecision.Denied("The declared disposable target is the Cheat Engine host itself."); - - if (!TryGetProcessImage(manifest.TargetProcessId, out var targetPath, out var targetFailure)) - return AuthorizationDecision.Denied("The declared disposable target cannot be inspected: " + targetFailure); - - if (!TryHash(targetPath, out var targetHash, out var targetHashFailure)) - return AuthorizationDecision.Denied("The declared disposable target cannot be hashed: " + targetHashFailure); - - if (!string.Equals(targetHash, manifest.TargetSha256, StringComparison.Ordinal)) - return AuthorizationDecision.Denied("The declared target SHA-256 differs from its live process image."); - - return AuthorizationDecision.Allowed(hostPath, hostHash, manifest.TargetProcessId, targetPath, targetHash, - manifest.ExpiresUtc); - } - - private static bool TryReadManifest(string path, out AuthorizationManifest manifest, out string failure) - { - manifest = default; - failure = "The authorization manifest is invalid."; - try - { - var fullPath = Path.GetFullPath(path); - if (!File.Exists(fullPath)) - { - failure = "The authorization manifest does not exist."; - return false; - } - - using var stream = File.OpenRead(fullPath); - using var document = JsonDocument.Parse(stream); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object || !TryString(root, "schema", out var schema) || - !string.Equals(schema, "ce77-live-probe-v1", StringComparison.Ordinal) || - !TryString(root, "acknowledgement", out var acknowledgement) || - !TryString(root, "hostSha256", out var hostSha256) || - !TryString(root, "targetSha256", out var targetSha256) || - !TryString(root, "expiresUtc", out var expiresText) || !root.TryGetProperty("targetProcessId", out var pid) || - !pid.TryGetInt32(out var targetProcessId) || !root.TryGetProperty("disposable", out var disposable) || - (disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False) || - !DateTimeOffset.TryParse(expiresText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var expiresUtc)) - { - failure = "The authorization manifest is missing a required ce77-live-probe-v1 field."; - return false; - } - - manifest = new AuthorizationManifest(acknowledgement, NormalizeHash(hostSha256), targetProcessId, - NormalizeHash(targetSha256), disposable.GetBoolean(), expiresUtc); - return true; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or ArgumentException) - { - failure = exception.GetType().Name + ": " + exception.Message; - return false; - } - } - - private static bool TryString(JsonElement parent, string name, out string value) - { - value = string.Empty; - if (!parent.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.String) return false; - - value = property.GetString() ?? string.Empty; - return !string.IsNullOrWhiteSpace(value); - } - - private static bool TryGetProcessImage(int processId, out string path, out string failure) - { - path = string.Empty; - failure = string.Empty; - try - { - using var process = Process.GetProcessById(processId); - path = process.MainModule?.FileName ?? string.Empty; - if (path.Length == 0) - { - failure = "MainModule.FileName is empty."; - return false; - } - - return true; - } - catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) - { - failure = exception.GetType().Name + ": " + exception.Message; - return false; - } - } - - private static bool IsAmd64Pe(string path, out string failure) - { - failure = string.Empty; - try - { - using var stream = File.OpenRead(path); - using var reader = new PEReader(stream); - if (!reader.HasMetadata && reader.PEHeaders.PEHeader is null) - { - failure = "The file is not a PE image."; - return false; - } - - if (reader.PEHeaders.CoffHeader.Machine == Machine.Amd64) return true; - - failure = "COFF machine is " + reader.PEHeaders.CoffHeader.Machine + "."; - return false; - } - catch (Exception exception) when (exception is BadImageFormatException or IOException or UnauthorizedAccessException) - { - failure = exception.GetType().Name + ": " + exception.Message; - return false; - } - } - - private static bool TryHash(string path, out string sha256, out string failure) - { - sha256 = string.Empty; - failure = string.Empty; - try - { - using var stream = File.OpenRead(path); - sha256 = Convert.ToHexString(SHA256.HashData(stream)); - return true; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - failure = exception.GetType().Name + ": " + exception.Message; - return false; - } - } - - private static string NormalizeHash(string hash) - { - return hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); - } - - private readonly record struct AuthorizationManifest(string Acknowledgement, string HostSha256, int TargetProcessId, - string TargetSha256, bool Disposable, DateTimeOffset ExpiresUtc); + internal const string ExactCheatEngineSha256 = "9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D"; + internal const string ExactCheatEngineFileVersion = "7.7.0.10621"; + internal const string Acknowledgement = "I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET"; + private const string AcknowledgementVariable = "CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT"; + private const string ManifestVariable = "CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE"; + + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "Authorization evaluation keeps all fail-closed checks and diagnostics in one auditable sequence.")] + internal static AuthorizationDecision Evaluate() + { + if (IntPtr.Size != 8) + { + return AuthorizationDecision.Denied("The current process is not x64."); + } + + string? acknowledgement = Environment.GetEnvironmentVariable(AcknowledgementVariable); + if (!string.Equals(acknowledgement, Acknowledgement, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied("The explicit CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT phrase is absent."); + } + + string? manifestPath = Environment.GetEnvironmentVariable(ManifestVariable); + if (string.IsNullOrWhiteSpace(manifestPath)) + { + return AuthorizationDecision.Denied("CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent."); + } + + if (!TryReadManifest(manifestPath, out AuthorizationManifest manifest, out string manifestFailure)) + { + return AuthorizationDecision.Denied(manifestFailure); + } + + if (!string.Equals(manifest.Acknowledgement, Acknowledgement, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied("The authorization manifest has no matching acknowledgement."); + } + + if (!manifest.Disposable) + { + return AuthorizationDecision.Denied("The authorization manifest does not mark the target disposable."); + } + + if (manifest.ExpiresUtc <= DateTimeOffset.UtcNow) + { + return AuthorizationDecision.Denied("The authorization manifest has expired."); + } + + if (!TryGetProcessImage(Environment.ProcessId, out string hostPath, out string hostFailure)) + { + return AuthorizationDecision.Denied("The CE host image cannot be inspected: " + hostFailure); + } + + if (!IsAmd64Pe(hostPath, out string hostArchitectureFailure)) + { + return AuthorizationDecision.Denied("The CE host image is not an AMD64 PE: " + hostArchitectureFailure); + } + + if (!TryHash(hostPath, out string hostHash, out string hostHashFailure)) + { + return AuthorizationDecision.Denied("The CE host image cannot be hashed: " + hostHashFailure); + } + + if (!string.Equals(hostHash, ExactCheatEngineSha256, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied("The host SHA-256 is not the pinned CE 7.7.0.10621 x64 binary."); + } + + if (!string.Equals(manifest.HostSha256, ExactCheatEngineSha256, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied("The manifest does not pin the CE 7.7.0.10621 x64 SHA-256."); + } + + string? hostVersion = FileVersionInfo.GetVersionInfo(hostPath).FileVersion; + if (!string.Equals(hostVersion, ExactCheatEngineFileVersion, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied("The pinned CE executable has an unexpected file version: " + + hostVersion + "."); + } + + if (manifest.TargetProcessId == Environment.ProcessId) + { + return AuthorizationDecision.Denied("The declared disposable target is the Cheat Engine host itself."); + } + + if (!TryGetProcessImage(manifest.TargetProcessId, out string targetPath, out string targetFailure)) + { + return AuthorizationDecision.Denied("The declared disposable target cannot be inspected: " + targetFailure); + } + + if (!TryHash(targetPath, out string targetHash, out string targetHashFailure)) + { + return AuthorizationDecision.Denied("The declared disposable target cannot be hashed: " + + targetHashFailure); + } + + if (!string.Equals(targetHash, manifest.TargetSha256, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied("The declared target SHA-256 differs from its live process image."); + } + + return AuthorizationDecision.Allowed(hostPath, hostHash, manifest.TargetProcessId, targetPath, targetHash, + manifest.ExpiresUtc); + } + + private static bool TryReadManifest(string path, out AuthorizationManifest manifest, out string failure) + { + manifest = default; + failure = "The authorization manifest is invalid."; + try + { + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + { + failure = "The authorization manifest does not exist."; + return false; + } + + using FileStream stream = File.OpenRead(fullPath); + using JsonDocument document = JsonDocument.Parse(stream); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || !TryString(root, "schema", out string schema) || + !string.Equals(schema, "ce77-live-probe-v1", StringComparison.Ordinal) || + !TryString(root, "acknowledgement", out string acknowledgement) || + !TryString(root, "hostSha256", out string hostSha256) || + !TryString(root, "targetSha256", out string targetSha256) || + !TryString(root, "expiresUtc", out string expiresText) || + !root.TryGetProperty("targetProcessId", out JsonElement pid) || + !pid.TryGetInt32(out int targetProcessId) || + !root.TryGetProperty("disposable", out JsonElement disposable) || + disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || + !DateTimeOffset.TryParse(expiresText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, + out DateTimeOffset expiresUtc)) + { + failure = "The authorization manifest is missing a required ce77-live-probe-v1 field."; + return false; + } + + manifest = new AuthorizationManifest(acknowledgement, NormalizeHash(hostSha256), targetProcessId, + NormalizeHash(targetSha256), disposable.GetBoolean(), expiresUtc); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException + or ArgumentException) + { + failure = exception.GetType().Name + ": " + exception.Message; + return false; + } + } + + private static bool TryString(JsonElement parent, string name, out string value) + { + value = string.Empty; + if (!parent.TryGetProperty(name, out JsonElement property) || property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private static bool TryGetProcessImage(int processId, out string path, out string failure) + { + path = string.Empty; + failure = string.Empty; + try + { + using Process process = Process.GetProcessById(processId); + path = process.MainModule?.FileName ?? string.Empty; + if (path.Length == 0) + { + failure = "MainModule.FileName is empty."; + return false; + } + + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException + or NotSupportedException or Win32Exception) + { + failure = exception.GetType().Name + ": " + exception.Message; + return false; + } + } + + private static bool IsAmd64Pe(string path, out string failure) + { + failure = string.Empty; + try + { + using FileStream stream = File.OpenRead(path); + using PEReader reader = new(stream); + if (!reader.HasMetadata && reader.PEHeaders.PEHeader is null) + { + failure = "The file is not a PE image."; + return false; + } + + if (reader.PEHeaders.CoffHeader.Machine == Machine.Amd64) + { + return true; + } + + failure = "COFF machine is " + reader.PEHeaders.CoffHeader.Machine + "."; + return false; + } + catch (Exception exception) when (exception is BadImageFormatException or IOException + or UnauthorizedAccessException) + { + failure = exception.GetType().Name + ": " + exception.Message; + return false; + } + } + + private static bool TryHash(string path, out string sha256, out string failure) + { + sha256 = string.Empty; + failure = string.Empty; + try + { + using FileStream stream = File.OpenRead(path); + sha256 = Convert.ToHexString(SHA256.HashData(stream)); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + failure = exception.GetType().Name + ": " + exception.Message; + return false; + } + } + + private static string NormalizeHash(string hash) + { + return hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); + } + + private readonly record struct AuthorizationManifest( + string Acknowledgement, + string HostSha256, + int TargetProcessId, + string TargetSha256, + bool Disposable, + DateTimeOffset ExpiresUtc); } diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs index bc54b007..fee5e7d3 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs @@ -2,10 +2,13 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; + using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Threading; +using CheatEngine.SDK.LiveProbe.Tests; using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -16,601 +19,704 @@ namespace LiveProbe; // report fields; Lua is never called while it is held. internal static unsafe class LiveProbeState { - private const uint TailCanary = 0x7A_51_CE_77U; - private static readonly Lock Gate = new(); - private static BootstrapObservation s_bootstrap; - private static AuthorizationDecision s_bootstrapAuthorization = AuthorizationDecision.Denied("Not evaluated."); - private static AuthorizationDecision s_enableAuthorization = AuthorizationDecision.Denied("Not enabled."); - private static bool s_targetMatchesCe; - private static string s_targetMatchFailure = "Not checked."; - private static SynchronizeObservation s_synchronize = SynchronizeObservation.NotStarted; - private static LuaThreadObservation s_luaThread = LuaThreadObservation.NotStarted; - private static ResetObservation s_reset = ResetObservation.NotStarted; - private static LuaRef? s_resetReference; - private static LuaCallback? s_callback; - private static CallbackCounter? s_callbackCounter; - private static int s_luaProbeSerial; - - internal static void CaptureBootstrap(nint initRecord, int opaqueHostArgument) - { - lock (Gate) - { - s_bootstrap = s_bootstrap with - { - Calls = checked(s_bootstrap.Calls + 1), - InitRecord = initRecord, - OpaqueArgument = opaqueHostArgument, - Captured = true - }; - } - } - - internal static void TryWriteTailCanaryAfterPackedRecord(nint initRecord, int bootstrapResult) - { - if (bootstrapResult != ManagedEntryPoint.Success || initRecord == 0) return; - - // This is the one deliberately high-risk probe. It does not run unless the exact CE executable, an unexpired - // operator acknowledgement, and a live hash-verified disposable target all pass. A tail value surviving does not - // by itself prove allocation capacity; the raw before/after records are evidence for manual review. - var authorization = LiveProbeAuthorization.Evaluate(); - lock (Gate) - { - s_bootstrapAuthorization = authorization; - } - - if (!authorization.IsAllowed) return; - - try - { - var tail = (byte*)initRecord + sizeof(PluginInitRecord); - var before = ReadUnalignedUInt32(tail); - WriteUnalignedUInt32(tail, TailCanary); - lock (Gate) - { - s_bootstrap = s_bootstrap with - { - TailReadBeforeWrite = before, - TailCanaryWritten = true, - TailWriteCount = checked(s_bootstrap.TailWriteCount + 1) - }; - } - } - catch (Exception exception) - { - lock (Gate) - { - s_bootstrap = s_bootstrap with { TailFailure = Describe(exception) }; - } - - HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe tail canary failed.", exception); - } - } - - internal static void ValidateAfterEnable() - { - ValidateAfterEnable(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId); - } - - // The delegates keep the refresh decision deterministic in unit tests. They are internal to this manually loaded - // harness; the only operator-facing path always supplies the authorization evaluator and CE PID reader above. - internal static void ValidateAfterEnable(Func evaluateAuthorization, - Func getOpenedProcessId) - { - RecordRuntimeAuthorization(EvaluateRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId)); - } - - private static RuntimeAuthorization EvaluateRuntimeAuthorization(Func evaluateAuthorization, - Func getOpenedProcessId) - { - AuthorizationDecision authorization = evaluateAuthorization(); - var targetMatchesCe = false; - var targetFailure = authorization.IsAllowed - ? "CE target attachment is not yet checked." - : authorization.Reason; - - if (authorization.IsAllowed) - { - try - { - long openedProcess = getOpenedProcessId(); - targetMatchesCe = openedProcess == authorization.TargetProcessId; - targetFailure = targetMatchesCe - ? "CE's opened process matches the disposable-target manifest." - : string.Create(CultureInfo.InvariantCulture, - $"CE reports opened process {openedProcess}, not manifest process {authorization.TargetProcessId}."); - } - catch (Exception exception) - { - targetFailure = "Could not read CE getOpenedProcessID(): " + Describe(exception); - } - } - - return new RuntimeAuthorization(authorization, targetMatchesCe, targetFailure); - } - - private static void RecordRuntimeAuthorization(RuntimeAuthorization runtimeAuthorization) - { - lock (Gate) - { - s_enableAuthorization = runtimeAuthorization.Authorization; - s_targetMatchesCe = runtimeAuthorization.TargetMatchesCe; - s_targetMatchFailure = runtimeAuthorization.TargetMatchFailure; - } - } - - internal static void RecordDisable() - { - CallbackCounter? counter; - LuaCallback? callback; - lock (Gate) - { - counter = s_callbackCounter; - callback = s_callback; - } - - if (callback is null) return; - - HostLog.Write(HostLogLevel.Information, string.Create(CultureInfo.InvariantCulture, - $"CE 7.7 live probe: callback shutdown handoff; IsReleased before LuaRuntime.Detach={callback.IsReleased}, " + - $"managed calls={counter?.Calls ?? 0}.")); - } - - internal static string GetStatus() - { - lock (Gate) - { - var builder = new StringBuilder(1024); - builder.Append("CE 7.7 live probe status; bootstrapCalls=").Append(s_bootstrap.Calls) - .Append(", bootstrapAddress=0x").Append(s_bootstrap.InitRecord.ToString("X", CultureInfo.InvariantCulture)) - .Append(", opaqueSecondInt=").Append(s_bootstrap.OpaqueArgument) - .Append(" (raw; no size/version meaning assigned)") - .Append(", tailCanaryWritten=").Append(s_bootstrap.TailCanaryWritten) - .Append(", tailWrites=").Append(s_bootstrap.TailWriteCount); - - if (s_bootstrap.TailCanaryWritten) - builder.Append(", tailBefore=0x").Append(s_bootstrap.TailReadBeforeWrite.ToString("X8", CultureInfo.InvariantCulture)) - .Append(", expectedPreviousCanary=0x").Append(TailCanary.ToString("X8", CultureInfo.InvariantCulture)); - - if (!string.IsNullOrEmpty(s_bootstrap.TailFailure)) builder.Append(", tailFailure=").Append(s_bootstrap.TailFailure); - - builder.Append(". Bootstrap gate: ").Append(s_bootstrapAuthorization.IsAllowed ? "allowed" : "denied") - .Append("; ").Append(s_bootstrapAuthorization.Reason) - .Append(". Runtime gate: ").Append(IsRuntimeProbeAllowedUnsafe() ? "allowed" : "denied") - .Append("; ").Append(s_targetMatchFailure) - .Append(". Synchronize: ").Append(s_synchronize.ToDisplayString()) - .Append(". Lua threads: ").Append(s_luaThread.ToDisplayString()) - .Append(". Reset: ").Append(s_reset.ToDisplayString()); - - if (s_callback is not null) - builder.Append(". Callback: prepared=true, released=").Append(s_callback.IsReleased) - .Append(", managedCalls=").Append(s_callbackCounter?.Calls ?? 0); - - return builder.ToString(); - } - } - - internal static string CaptureHostProfile() - { - return CaptureHostProfile(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, - HostProfileObservation.Capture); - } - - // A profile is evidence only for the instant it is captured. Revalidate the manifest, target image and CE's opened - // PID immediately beforehand rather than accepting the enable-time diagnostic snapshot. This fresh decision stays - // local to capture; every other protected command performs its own fresh check as well. - internal static string CaptureHostProfile(Func evaluateAuthorization, - Func getOpenedProcessId, Func capture) - { - RuntimeAuthorization runtimeAuthorization = EvaluateRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId); - if (!runtimeAuthorization.IsAllowed) return "Live probe denied: " + runtimeAuthorization.Denial; - - return capture(runtimeAuthorization.Authorization); - } - - internal static string BeginSynchronizeProbe() - { - if (!TryRequireRuntimeAuthorization(out var denied)) return denied; - - lock (Gate) - { - if (s_synchronize.IsRunning) return "Synchronize probe is already running; call ce77_live_probe_synchronize_status()."; - - s_synchronize = SynchronizeObservation.Started; - } - - var thread = new Thread(RunSynchronizeProbe) - { - IsBackground = true, - Name = "CheatEngine.SDK CE77 synchronize probe" - }; - thread.Start(); - return "Synchronize probe started. Do not block the CE GUI thread; poll ce77_live_probe_synchronize_status()."; - } - - internal static string GetSynchronizeStatus() - { - lock (Gate) - { - return s_synchronize.ToDisplayString(); - } - } - - internal static string BeginLuaThreadProbe() - { - if (!TryRequireRuntimeAuthorization(out var denied)) return denied; - - LuaState state = LuaRuntime.AcquireState(); - var serial = Interlocked.Increment(ref s_luaProbeSerial); - var token = string.Create(CultureInfo.InvariantCulture, $"ce77-live-probe-registry-{serial}"); - using (var frame = new LuaFrame(state)) - { - state.PushString("CheatEngine.SDK.CE77.LiveProbe.Registry"u8); - state.PushString(token); - if (!state.TryRawSet(LuaState.RegistryIndex)) - return "Lua thread probe could not write its private registry marker."; - } - - lock (Gate) - { - if (s_luaThread.IsRunning) return "Lua thread probe is already running; call ce77_live_probe_lua_threads_status()."; - - s_luaThread = LuaThreadObservation.Started(state.Handle, token); - } - - // The delayed worker starts only after this Lua callback has returned its string to CE. It is still a live, - // opt-in observation against CE's per-thread state contract, never a general concurrency guarantee for Lua. - var thread = new Thread(RunLuaThreadProbe) - { - IsBackground = true, - Name = "CheatEngine.SDK CE77 Lua thread probe" - }; - thread.Start(); - return "Lua thread/registry probe started. Do not run other Lua code for one second; poll ce77_live_probe_lua_threads_status()."; - } - - internal static string GetLuaThreadStatus() - { - lock (Gate) - { - return s_luaThread.ToDisplayString(); - } - } - - internal static string SnapshotBeforeReset() - { - if (!TryRequireRuntimeAuthorization(out var denied)) return denied; - - var state = LuaRuntime.AcquireState(); - LuaRef reference; - using (var frame = new LuaFrame(state)) - { - state.PushString("ce77-live-probe-reset-reference"u8); - reference = state.CreateRef(); - } - - lock (Gate) - { - s_resetReference = reference; - s_reset = ResetObservation.Before(state.Handle, reference.Epoch, reference.Reference); - } - - return "Reset snapshot captured. In CE's Lua Engine call resetLuaState() manually, then call ce77_live_probe_snapshot_after_reset(). The SDK does not support an external reset that it was not told about; this only records the raw outcome."; - } - - internal static string SnapshotAfterReset() - { - if (!TryRequireRuntimeAuthorization(out var denied)) return denied; - - LuaRef? reference; - lock (Gate) - { - reference = s_resetReference; - } - - if (reference is null) return "No before-reset snapshot exists. Call ce77_live_probe_snapshot_before_reset() first."; - - var state = LuaRuntime.AcquireState(); - bool oldReferencePushed; - string? oldReferenceValue = null; - using (var frame = new LuaFrame(state)) - { - oldReferencePushed = state.TryPushRef(reference); - if (oldReferencePushed) _ = state.TryReadString(-1, out oldReferenceValue); - } - - lock (Gate) - { - s_reset = s_reset.After(state.Handle, LuaRuntime.Epoch, oldReferencePushed, oldReferenceValue); - } - - return GetStatus(); - } - - internal static string ObserveHostUserdata() - { - if (!TryRequireRuntimeAuthorization(out var denied)) return denied; - - var state = LuaRuntime.AcquireState(); - using var frame = new LuaFrame(state); - var status = state.TryExecute("local value = getMainForm(); return type(value), tostring(value)"u8, 2, - "=ce77_live_probe_userdata"u8); - if (!status.IsOk) return "getMainForm userdata observation failed with " + status + "."; - - var hasType = state.TryReadString(-2, out var type); - var hasText = state.TryReadString(-1, out var text); - return string.Create(CultureInfo.InvariantCulture, - $"getMainForm observation: luaType={(hasType ? type : "")}, identityText={(hasText ? text : "")}. No userdata was retained or pushed through the host pusher."); - } - - internal static string PrepareCallbackShutdownProbe() - { - if (!TryRequireRuntimeAuthorization(out var denied)) return denied; - - lock (Gate) - { - if (s_callback is { IsReleased: false }) - return "Callback shutdown probe is already prepared. Disable this plugin, then run pcall(ce77_live_probe_callback_shutdown) from CE's Lua Engine."; - } - - var state = LuaRuntime.AcquireState(); - var counter = new CallbackCounter(); - LuaCallback? callback; - using (var frame = new LuaFrame(state)) - { - var createStatus = LuaCallback.TryCreate(state, new LuaNativeFunction(&CallbackShutdownThunk), counter, out callback); - if (!createStatus.IsOk || callback is null) - return "Callback shutdown probe could not create its callback: " + createStatus + "."; - - var registerStatus = callback.TryRegister(state, "ce77_live_probe_callback_shutdown"u8); - if (!registerStatus.IsOk) - { - callback.Dispose(); - return "Callback shutdown probe could not register its callback: " + registerStatus + "."; - } - } - - lock (Gate) - { - s_callback = callback; - s_callbackCounter = counter; - } - - return "Callback prepared. First run pcall(ce77_live_probe_callback_shutdown) once (it returns a count). Then disable this plugin in CE, run pcall(ce77_live_probe_callback_shutdown) again, and preserve the raw pcall result. Re-enable and call ce77_live_probe_status()."; - } - - private static void RunSynchronizeProbe() - { - var observation = SynchronizeObservation.Started; - try - { - var returnThread = MainThread.Invoke(static _ => SynchronizeProbeState.RecordSuccessfulInvocation(), 0); - var expectedThread = Environment.CurrentManagedThreadId; - // The returned object stores the exact thread that ran the thunk action, including an inline nested Invoke. - // It is carried through the result rather than read by the GUI thread after the fact. - observation = observation with - { - Completion = "completed", - WorkThreadId = returnThread.WorkThreadId, - NestedThreadId = returnThread.NestedThreadId, - ReturnRoundTrip = returnThread.ReturnValue, - WorkerThreadId = expectedThread - }; - - try - { - MainThread.Invoke(static _ => throw new InvalidOperationException("CE77-live-probe expected dispatch failure."), 0); - observation = observation with { ExceptionResult = "unexpectedly returned" }; - } - catch (InvalidOperationException exception) - { - observation = observation with { ExceptionResult = "re-thrown: " + exception.Message }; - } - } - catch (Exception exception) - { - observation = observation with { Completion = "failed", Failure = Describe(exception) }; - } - - lock (Gate) - { - s_synchronize = observation with { IsRunning = false }; - } - } - - private static void RunLuaThreadProbe() - { - // Give the generated Lua-function marshaller time to finish returning to CE before touching the worker's state. - Thread.Sleep(500); - LuaThreadObservation observation; - lock (Gate) - { - observation = s_luaThread; - } - - try - { - var state = LuaRuntime.AcquireState(); - string? marker = null; - var markerType = LuaType.None; - using (var frame = new LuaFrame(state)) - { - state.PushString("CheatEngine.SDK.CE77.LiveProbe.Registry"u8); - markerType = state.RawGet(LuaState.RegistryIndex); - _ = state.TryReadString(-1, out marker); - } - - observation = observation with - { - Completion = "completed", - WorkerState = state.Handle, - RegistryType = markerType.ToString(), - RegistryMarker = marker, - RegistryMatches = string.Equals(marker, observation.ExpectedMarker, StringComparison.Ordinal) - }; - } - catch (Exception exception) - { - observation = observation with { Completion = "failed", Failure = Describe(exception) }; - } - - lock (Gate) - { - s_luaThread = observation with { IsRunning = false }; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int CallbackShutdownThunk(nint statePointer) - { - var state = new LuaState(statePointer); - try - { - if (!LuaThunk.TryGetState(state, out CallbackCounter? counter)) - return LuaThunk.Fail(state, "callback shutdown probe state is unavailable"u8); - - state.PushInteger(Interlocked.Increment(ref counter.Calls)); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(state, exception); - } - } - - private static bool TryRequireRuntimeAuthorization(out string denied) - { - return TryRequireRuntimeAuthorization(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, - out denied); - } - - // Each command performs a fresh, local check. The recorded enable-time result remains a diagnostic and cannot be - // refreshed by a capture or command into authority for a later command. - internal static bool TryRequireRuntimeAuthorization(Func evaluateAuthorization, - Func getOpenedProcessId, out string denied) - { - RuntimeAuthorization runtimeAuthorization = EvaluateRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId); - if (runtimeAuthorization.IsAllowed) - { - denied = string.Empty; - return true; - } - - denied = "Live probe denied: " + runtimeAuthorization.Denial; - return false; - } - - private static bool IsRuntimeProbeAllowedUnsafe() - { - return s_enableAuthorization.IsAllowed && s_targetMatchesCe; - } - - private static uint ReadUnalignedUInt32(byte* address) - { - return (uint)(address[0] | (address[1] << 8) | (address[2] << 16) | (address[3] << 24)); - } - - private static void WriteUnalignedUInt32(byte* address, uint value) - { - address[0] = (byte)value; - address[1] = (byte)(value >> 8); - address[2] = (byte)(value >> 16); - address[3] = (byte)(value >> 24); - } - - private static string Describe(Exception exception) - { - return exception.GetType().Name + ": " + exception.Message; - } - - private sealed class CallbackCounter - { - internal int Calls; - } - - private static class SynchronizeProbeState - { - internal static SynchronizeInvocation RecordSuccessfulInvocation() - { - var workThread = Environment.CurrentManagedThreadId; - var nestedThread = MainThread.Invoke(static _ => Environment.CurrentManagedThreadId, 0); - return new SynchronizeInvocation(workThread, nestedThread, "ce77-synchronize-return"); - } - } - - private readonly record struct SynchronizeInvocation(int WorkThreadId, int NestedThreadId, string ReturnValue); - - private readonly record struct RuntimeAuthorization(AuthorizationDecision Authorization, bool TargetMatchesCe, - string TargetMatchFailure) - { - internal bool IsAllowed => Authorization.IsAllowed && TargetMatchesCe; - - internal string Denial => Authorization.IsAllowed ? TargetMatchFailure : Authorization.Reason; - } - - private readonly record struct BootstrapObservation(int Calls, nint InitRecord, int OpaqueArgument, bool Captured, - bool TailCanaryWritten, int TailWriteCount, uint TailReadBeforeWrite, string? TailFailure) - { - internal static BootstrapObservation Empty => new(0, 0, 0, false, false, 0, 0, null); - } - - private readonly record struct SynchronizeObservation(bool IsRunning, string Completion, int WorkerThreadId, - int WorkThreadId, int NestedThreadId, string? ReturnRoundTrip, string? ExceptionResult, string? Failure) - { - internal static SynchronizeObservation NotStarted => new(false, "not started", 0, 0, 0, null, null, null); - internal static SynchronizeObservation Started => new(true, "running", 0, 0, 0, null, null, null); - - internal string ToDisplayString() - { - return string.Create(CultureInfo.InvariantCulture, - $"completion={Completion}, workerThread={WorkerThreadId}, thunkThread={WorkThreadId}, nestedThread={NestedThreadId}, return={ReturnRoundTrip ?? ""}, exception={ExceptionResult ?? ""}, failure={Failure ?? ""}"); - } - } - - private readonly record struct LuaThreadObservation(bool IsRunning, string Completion, nint GuiState, nint WorkerState, - string ExpectedMarker, string? RegistryMarker, string RegistryType, bool RegistryMatches, string? Failure) - { - internal static LuaThreadObservation NotStarted => new(false, "not started", 0, 0, string.Empty, null, string.Empty, false, null); - - internal static LuaThreadObservation Started(nint guiState, string marker) - { - return new LuaThreadObservation(true, "running", guiState, 0, marker, null, string.Empty, false, null); - } - - internal string ToDisplayString() - { - return string.Create(CultureInfo.InvariantCulture, - $"completion={Completion}, guiState=0x{GuiState:X}, workerState=0x{WorkerState:X}, registryType={RegistryType}, registryMatches={RegistryMatches}, failure={Failure ?? ""}"); - } - } - - private readonly record struct ResetObservation(bool HasBefore, nint BeforeState, int BeforeEpoch, int BeforeReference, - bool HasAfter, nint AfterState, int AfterEpoch, bool OldReferencePushed, string? OldReferenceValue) - { - internal static ResetObservation NotStarted => new(false, 0, 0, 0, false, 0, 0, false, null); - - internal static ResetObservation Before(nint state, int epoch, int reference) - { - return new ResetObservation(true, state, epoch, reference, false, 0, 0, false, null); - } - - internal ResetObservation After(nint state, int epoch, bool oldReferencePushed, string? oldReferenceValue) - { - return this with - { - HasAfter = true, - AfterState = state, - AfterEpoch = epoch, - OldReferencePushed = oldReferencePushed, - OldReferenceValue = oldReferenceValue - }; - } - - internal string ToDisplayString() - { - if (!HasBefore) return "not started"; - - return string.Create(CultureInfo.InvariantCulture, - $"beforeState=0x{BeforeState:X}, beforeEpoch={BeforeEpoch}, beforeReference={BeforeReference}, afterCaptured={HasAfter}, afterState=0x{AfterState:X}, afterEpoch={AfterEpoch}, oldReferencePushed={OldReferencePushed}, oldReferenceValue={OldReferenceValue ?? ""}"); - } - } + private const uint TailCanary = 0x7A_51_CE_77U; + private static readonly Lock Gate = new(); + private static BootstrapObservation s_bootstrap; + private static AuthorizationDecision s_bootstrapAuthorization = AuthorizationDecision.Denied("Not evaluated."); + private static AuthorizationDecision s_enableAuthorization = AuthorizationDecision.Denied("Not enabled."); + private static bool s_targetMatchesCe; + private static string s_targetMatchFailure = "Not checked."; + private static SynchronizeObservation s_synchronize = SynchronizeObservation.NotStarted; + private static LuaThreadObservation s_luaThread = LuaThreadObservation.NotStarted; + private static ResetObservation s_reset = ResetObservation.NotStarted; + private static LuaRef? s_resetReference; + private static LuaCallback? s_callback; + private static CallbackCounter? s_callbackCounter; + private static int s_luaProbeSerial; + + internal static void CaptureBootstrap(nint initRecord, int opaqueHostArgument) + { + lock (Gate) + { + s_bootstrap = s_bootstrap with + { + Calls = checked(s_bootstrap.Calls + 1), + InitRecord = initRecord, + OpaqueArgument = opaqueHostArgument, + Captured = true + }; + } + } + + internal static void TryWriteTailCanaryAfterPackedRecord(nint initRecord, int bootstrapResult) + { + if (bootstrapResult != ManagedEntryPoint.Success || initRecord == 0) + { + return; + } + + // This is the one deliberately high-risk probe. It does not run unless the exact CE executable, an unexpired + // operator acknowledgement, and a live hash-verified disposable target all pass. A tail value surviving does not + // by itself prove allocation capacity; the raw before/after records are evidence for manual review. + AuthorizationDecision authorization = LiveProbeAuthorization.Evaluate(); + lock (Gate) + { + s_bootstrapAuthorization = authorization; + } + + if (!authorization.IsAllowed) + { + return; + } + + try + { + byte* tail = (byte*) initRecord + sizeof(PluginInitRecord); + uint before = ReadUnalignedUInt32(tail); + WriteUnalignedUInt32(tail, TailCanary); + lock (Gate) + { + s_bootstrap = s_bootstrap with + { + TailReadBeforeWrite = before, + TailCanaryWritten = true, + TailWriteCount = checked(s_bootstrap.TailWriteCount + 1) + }; + } + } + catch (Exception exception) + { + lock (Gate) + { + s_bootstrap = s_bootstrap with { TailFailure = Describe(exception) }; + } + + HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe tail canary failed.", exception); + } + } + + internal static void ValidateAfterEnable() + { + ValidateAfterEnable(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId); + } + + // The delegates keep the refresh decision deterministic in unit tests. They are internal to this manually loaded + // harness; the only operator-facing path always supplies the authorization evaluator and CE PID reader above. + internal static void ValidateAfterEnable(Func evaluateAuthorization, + Func getOpenedProcessId) + { + RecordRuntimeAuthorization(EvaluateRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId)); + } + + private static RuntimeAuthorization EvaluateRuntimeAuthorization(Func evaluateAuthorization, + Func getOpenedProcessId) + { + AuthorizationDecision authorization = evaluateAuthorization(); + bool targetMatchesCe = false; + string targetFailure = authorization.IsAllowed + ? "CE target attachment is not yet checked." + : authorization.Reason; + + if (authorization.IsAllowed) + { + try + { + long openedProcess = getOpenedProcessId(); + targetMatchesCe = openedProcess == authorization.TargetProcessId; + targetFailure = targetMatchesCe + ? "CE's opened process matches the disposable-target manifest." + : string.Create(CultureInfo.InvariantCulture, + $"CE reports opened process {openedProcess}, not manifest process {authorization.TargetProcessId}."); + } + catch (Exception exception) + { + targetFailure = "Could not read CE getOpenedProcessID(): " + Describe(exception); + } + } + + return new RuntimeAuthorization(authorization, targetMatchesCe, targetFailure); + } + + private static void RecordRuntimeAuthorization(RuntimeAuthorization runtimeAuthorization) + { + lock (Gate) + { + s_enableAuthorization = runtimeAuthorization.Authorization; + s_targetMatchesCe = runtimeAuthorization.TargetMatchesCe; + s_targetMatchFailure = runtimeAuthorization.TargetMatchFailure; + } + } + + internal static void RecordDisable() + { + CallbackCounter? counter; + LuaCallback? callback; + lock (Gate) + { + counter = s_callbackCounter; + callback = s_callback; + } + + if (callback is null) + { + return; + } + + HostLog.Write(HostLogLevel.Information, string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: callback shutdown handoff; IsReleased before LuaRuntime.Detach={callback.IsReleased}, " + + $"managed calls={counter?.Calls ?? 0}.")); + } + + internal static string GetStatus() + { + lock (Gate) + { + StringBuilder builder = new(1024); + builder.Append("CE 7.7 live probe status; bootstrapCalls=").Append(s_bootstrap.Calls) + .Append(", bootstrapAddress=0x") + .Append(s_bootstrap.InitRecord.ToString("X", CultureInfo.InvariantCulture)) + .Append(", opaqueSecondInt=").Append(s_bootstrap.OpaqueArgument) + .Append(" (raw; no size/version meaning assigned)") + .Append(", tailCanaryWritten=").Append(s_bootstrap.TailCanaryWritten) + .Append(", tailWrites=").Append(s_bootstrap.TailWriteCount); + + if (s_bootstrap.TailCanaryWritten) + { + builder.Append(", tailBefore=0x") + .Append(s_bootstrap.TailReadBeforeWrite.ToString("X8", CultureInfo.InvariantCulture)) + .Append(", expectedPreviousCanary=0x") + .Append(TailCanary.ToString("X8", CultureInfo.InvariantCulture)); + } + + if (!string.IsNullOrEmpty(s_bootstrap.TailFailure)) + { + builder.Append(", tailFailure=").Append(s_bootstrap.TailFailure); + } + + builder.Append(". Bootstrap gate: ").Append(s_bootstrapAuthorization.IsAllowed ? "allowed" : "denied") + .Append("; ").Append(s_bootstrapAuthorization.Reason) + .Append(". Runtime gate: ").Append(IsRuntimeProbeAllowedUnsafe() ? "allowed" : "denied") + .Append("; ").Append(s_targetMatchFailure) + .Append(". Synchronize: ").Append(s_synchronize.ToDisplayString()) + .Append(". Lua threads: ").Append(s_luaThread.ToDisplayString()) + .Append(". Reset: ").Append(s_reset.ToDisplayString()); + + if (s_callback is not null) + { + builder.Append(". Callback: prepared=true, released=").Append(s_callback.IsReleased) + .Append(", managedCalls=").Append(s_callbackCounter?.Calls ?? 0); + } + + return builder.ToString(); + } + } + + internal static string CaptureHostProfile() + { + return CaptureHostProfile(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, + HostProfileObservation.Capture); + } + + // A profile is evidence only for the instant it is captured. Revalidate the manifest, target image and CE's opened + // PID immediately beforehand rather than accepting the enable-time diagnostic snapshot. This fresh decision stays + // local to capture; every other protected command performs its own fresh check as well. + internal static string CaptureHostProfile(Func evaluateAuthorization, + Func getOpenedProcessId, Func capture) + { + RuntimeAuthorization runtimeAuthorization = + EvaluateRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId); + if (!runtimeAuthorization.IsAllowed) + { + return "Live probe denied: " + runtimeAuthorization.Denial; + } + + return capture(runtimeAuthorization.Authorization); + } + + internal static string BeginSynchronizeProbe() + { + if (!TryRequireRuntimeAuthorization(out string denied)) + { + return denied; + } + + lock (Gate) + { + if (s_synchronize.IsRunning) + { + return "Synchronize probe is already running; call ce77_live_probe_synchronize_status()."; + } + + s_synchronize = SynchronizeObservation.Started; + } + + Thread thread = new(RunSynchronizeProbe) + { + IsBackground = true, Name = "CheatEngine.SDK CE77 synchronize probe" + }; + thread.Start(); + return "Synchronize probe started. Do not block the CE GUI thread; poll ce77_live_probe_synchronize_status()."; + } + + internal static string GetSynchronizeStatus() + { + lock (Gate) + { + return s_synchronize.ToDisplayString(); + } + } + + internal static string BeginLuaThreadProbe() + { + if (!TryRequireRuntimeAuthorization(out string denied)) + { + return denied; + } + + LuaState state = LuaRuntime.AcquireState(); + int serial = Interlocked.Increment(ref s_luaProbeSerial); + string token = string.Create(CultureInfo.InvariantCulture, $"ce77-live-probe-registry-{serial}"); + using (LuaFrame frame = new(state)) + { + state.PushString("CheatEngine.SDK.CE77.LiveProbe.Registry"u8); + state.PushString(token); + if (!state.TryRawSet(LuaState.RegistryIndex)) + { + return "Lua thread probe could not write its private registry marker."; + } + } + + lock (Gate) + { + if (s_luaThread.IsRunning) + { + return "Lua thread probe is already running; call ce77_live_probe_lua_threads_status()."; + } + + s_luaThread = LuaThreadObservation.Started(state.Handle, token); + } + + // The delayed worker starts only after this Lua callback has returned its string to CE. It is still a live, + // opt-in observation against CE's per-thread state contract, never a general concurrency guarantee for Lua. + Thread thread = new(RunLuaThreadProbe) { IsBackground = true, Name = "CheatEngine.SDK CE77 Lua thread probe" }; + thread.Start(); + return + "Lua thread/registry probe started. Do not run other Lua code for one second; poll ce77_live_probe_lua_threads_status()."; + } + + internal static string GetLuaThreadStatus() + { + lock (Gate) + { + return s_luaThread.ToDisplayString(); + } + } + + internal static string SnapshotBeforeReset() + { + if (!TryRequireRuntimeAuthorization(out string denied)) + { + return denied; + } + + LuaState state = LuaRuntime.AcquireState(); + LuaRef reference; + using (LuaFrame frame = new(state)) + { + state.PushString("ce77-live-probe-reset-reference"u8); + reference = state.CreateRef(); + } + + lock (Gate) + { + s_resetReference = reference; + s_reset = ResetObservation.Before(state.Handle, reference.Epoch, reference.Reference); + } + + return + "Reset snapshot captured. In CE's Lua Engine call resetLuaState() manually, then call ce77_live_probe_snapshot_after_reset(). The SDK does not support an external reset that it was not told about; this only records the raw outcome."; + } + + internal static string SnapshotAfterReset() + { + if (!TryRequireRuntimeAuthorization(out string denied)) + { + return denied; + } + + LuaRef? reference; + lock (Gate) + { + reference = s_resetReference; + } + + if (reference is null) + { + return "No before-reset snapshot exists. Call ce77_live_probe_snapshot_before_reset() first."; + } + + LuaState state = LuaRuntime.AcquireState(); + bool oldReferencePushed; + string? oldReferenceValue = null; + using (LuaFrame frame = new(state)) + { + oldReferencePushed = state.TryPushRef(reference); + if (oldReferencePushed) + { + _ = state.TryReadString(-1, out oldReferenceValue); + } + } + + lock (Gate) + { + s_reset = s_reset.After(state.Handle, LuaRuntime.Epoch, oldReferencePushed, oldReferenceValue); + } + + return GetStatus(); + } + + internal static string ObserveHostUserdata() + { + if (!TryRequireRuntimeAuthorization(out string denied)) + { + return denied; + } + + LuaState state = LuaRuntime.AcquireState(); + using LuaFrame frame = new(state); + LuaStatus status = state.TryExecute("local value = getMainForm(); return type(value), tostring(value)"u8, 2, + "=ce77_live_probe_userdata"u8); + if (!status.IsOk) + { + return "getMainForm userdata observation failed with " + status + "."; + } + + bool hasType = state.TryReadString(-2, out string? type); + bool hasText = state.TryReadString(-1, out string? text); + return string.Create(CultureInfo.InvariantCulture, + $"getMainForm observation: luaType={(hasType ? type : "")}, identityText={(hasText ? text : "")}. No userdata was retained or pushed through the host pusher."); + } + + internal static string PrepareCallbackShutdownProbe() + { + if (!TryRequireRuntimeAuthorization(out string denied)) + { + return denied; + } + + lock (Gate) + { + if (s_callback is { IsReleased: false }) + { + return + "Callback shutdown probe is already prepared. Disable this plugin, then run pcall(ce77_live_probe_callback_shutdown) from CE's Lua Engine."; + } + } + + LuaState state = LuaRuntime.AcquireState(); + CallbackCounter counter = new(); + LuaCallback? callback; + using (LuaFrame frame = new(state)) + { + LuaStatus createStatus = LuaCallback.TryCreate(state, new LuaNativeFunction(&CallbackShutdownThunk), + counter, out callback); + if (!createStatus.IsOk || callback is null) + { + return "Callback shutdown probe could not create its callback: " + createStatus + "."; + } + + LuaStatus registerStatus = callback.TryRegister(state, "ce77_live_probe_callback_shutdown"u8); + if (!registerStatus.IsOk) + { + callback.Dispose(); + return "Callback shutdown probe could not register its callback: " + registerStatus + "."; + } + } + + lock (Gate) + { + s_callback = callback; + s_callbackCounter = counter; + } + + return + "Callback prepared. First run pcall(ce77_live_probe_callback_shutdown) once (it returns a count). Then disable this plugin in CE, run pcall(ce77_live_probe_callback_shutdown) again, and preserve the raw pcall result. Re-enable and call ce77_live_probe_status()."; + } + + private static void RunSynchronizeProbe() + { + SynchronizeObservation observation = SynchronizeObservation.Started; + try + { + SynchronizeInvocation returnThread = + MainThread.Invoke(static _ => SynchronizeProbeState.RecordSuccessfulInvocation(), 0); + int expectedThread = Environment.CurrentManagedThreadId; + // The returned object stores the exact thread that ran the thunk action, including an inline nested Invoke. + // It is carried through the result rather than read by the GUI thread after the fact. + observation = observation with + { + Completion = "completed", + WorkThreadId = returnThread.WorkThreadId, + NestedThreadId = returnThread.NestedThreadId, + ReturnRoundTrip = returnThread.ReturnValue, + WorkerThreadId = expectedThread + }; + + try + { + MainThread.Invoke( + static _ => throw new InvalidOperationException("CE77-live-probe expected dispatch failure."), 0); + observation = observation with { ExceptionResult = "unexpectedly returned" }; + } + catch (InvalidOperationException exception) + { + observation = observation with { ExceptionResult = "re-thrown: " + exception.Message }; + } + } + catch (Exception exception) + { + observation = observation with { Completion = "failed", Failure = Describe(exception) }; + } + + lock (Gate) + { + s_synchronize = observation with { IsRunning = false }; + } + } + + private static void RunLuaThreadProbe() + { + // Give the generated Lua-function marshaller time to finish returning to CE before touching the worker's state. + Thread.Sleep(500); + LuaThreadObservation observation; + lock (Gate) + { + observation = s_luaThread; + } + + try + { + LuaState state = LuaRuntime.AcquireState(); + string? marker = null; + LuaType markerType = LuaType.None; + using (LuaFrame frame = new(state)) + { + state.PushString("CheatEngine.SDK.CE77.LiveProbe.Registry"u8); + markerType = state.RawGet(LuaState.RegistryIndex); + _ = state.TryReadString(-1, out marker); + } + + observation = observation with + { + Completion = "completed", + WorkerState = state.Handle, + RegistryType = markerType.ToString(), + RegistryMarker = marker, + RegistryMatches = string.Equals(marker, observation.ExpectedMarker, StringComparison.Ordinal) + }; + } + catch (Exception exception) + { + observation = observation with { Completion = "failed", Failure = Describe(exception) }; + } + + lock (Gate) + { + s_luaThread = observation with { IsRunning = false }; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int CallbackShutdownThunk(nint statePointer) + { + LuaState state = new(statePointer); + try + { + if (!LuaThunk.TryGetState(state, out CallbackCounter? counter)) + { + return LuaThunk.Fail(state, "callback shutdown probe state is unavailable"u8); + } + + state.PushInteger(Interlocked.Increment(ref counter.Calls)); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + + private static bool TryRequireRuntimeAuthorization(out string denied) + { + return TryRequireRuntimeAuthorization(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, + out denied); + } + + // Each command performs a fresh, local check. The recorded enable-time result remains a diagnostic and cannot be + // refreshed by a capture or command into authority for a later command. + internal static bool TryRequireRuntimeAuthorization(Func evaluateAuthorization, + Func getOpenedProcessId, out string denied) + { + RuntimeAuthorization runtimeAuthorization = + EvaluateRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId); + if (runtimeAuthorization.IsAllowed) + { + denied = string.Empty; + return true; + } + + denied = "Live probe denied: " + runtimeAuthorization.Denial; + return false; + } + + private static bool IsRuntimeProbeAllowedUnsafe() + { + return s_enableAuthorization.IsAllowed && s_targetMatchesCe; + } + + private static uint ReadUnalignedUInt32(byte* address) + { + return (uint) (address[0] | (address[1] << 8) | (address[2] << 16) | (address[3] << 24)); + } + + private static void WriteUnalignedUInt32(byte* address, uint value) + { + address[0] = (byte) value; + address[1] = (byte) (value >> 8); + address[2] = (byte) (value >> 16); + address[3] = (byte) (value >> 24); + } + + private static string Describe(Exception exception) + { + return exception.GetType().Name + ": " + exception.Message; + } + + private sealed class CallbackCounter + { + internal int Calls; + } + + private static class SynchronizeProbeState + { + internal static SynchronizeInvocation RecordSuccessfulInvocation() + { + int workThread = Environment.CurrentManagedThreadId; + int nestedThread = MainThread.Invoke(static _ => Environment.CurrentManagedThreadId, 0); + return new SynchronizeInvocation(workThread, nestedThread, "ce77-synchronize-return"); + } + } + + private readonly record struct SynchronizeInvocation(int WorkThreadId, int NestedThreadId, string ReturnValue); + + private readonly record struct RuntimeAuthorization( + AuthorizationDecision Authorization, + bool TargetMatchesCe, + string TargetMatchFailure) + { + internal bool IsAllowed => Authorization.IsAllowed && TargetMatchesCe; + + internal string Denial => Authorization.IsAllowed ? TargetMatchFailure : Authorization.Reason; + } + + private readonly record struct BootstrapObservation( + int Calls, + nint InitRecord, + int OpaqueArgument, + bool Captured, + bool TailCanaryWritten, + int TailWriteCount, + uint TailReadBeforeWrite, + string? TailFailure) + { + internal static BootstrapObservation Empty => new(0, 0, 0, false, false, 0, 0, null); + } + + private readonly record struct SynchronizeObservation( + bool IsRunning, + string Completion, + int WorkerThreadId, + int WorkThreadId, + int NestedThreadId, + string? ReturnRoundTrip, + string? ExceptionResult, + string? Failure) + { + internal static SynchronizeObservation NotStarted => new(false, "not started", 0, 0, 0, null, null, null); + internal static SynchronizeObservation Started => new(true, "running", 0, 0, 0, null, null, null); + + internal string ToDisplayString() + { + return string.Create(CultureInfo.InvariantCulture, + $"completion={Completion}, workerThread={WorkerThreadId}, thunkThread={WorkThreadId}, nestedThread={NestedThreadId}, return={ReturnRoundTrip ?? ""}, exception={ExceptionResult ?? ""}, failure={Failure ?? ""}"); + } + } + + private readonly record struct LuaThreadObservation( + bool IsRunning, + string Completion, + nint GuiState, + nint WorkerState, + string ExpectedMarker, + string? RegistryMarker, + string RegistryType, + bool RegistryMatches, + string? Failure) + { + internal static LuaThreadObservation NotStarted => + new(false, "not started", 0, 0, string.Empty, null, string.Empty, false, null); + + internal static LuaThreadObservation Started(nint guiState, string marker) + { + return new LuaThreadObservation(true, "running", guiState, 0, marker, null, string.Empty, false, null); + } + + internal string ToDisplayString() + { + return string.Create(CultureInfo.InvariantCulture, + $"completion={Completion}, guiState=0x{GuiState:X}, workerState=0x{WorkerState:X}, registryType={RegistryType}, registryMatches={RegistryMatches}, failure={Failure ?? ""}"); + } + } + + private readonly record struct ResetObservation( + bool HasBefore, + nint BeforeState, + int BeforeEpoch, + int BeforeReference, + bool HasAfter, + nint AfterState, + int AfterEpoch, + bool OldReferencePushed, + string? OldReferenceValue) + { + internal static ResetObservation NotStarted => new(false, 0, 0, 0, false, 0, 0, false, null); + + internal static ResetObservation Before(nint state, int epoch, int reference) + { + return new ResetObservation(true, state, epoch, reference, false, 0, 0, false, null); + } + + internal ResetObservation After(nint state, int epoch, bool oldReferencePushed, string? oldReferenceValue) + { + return this with + { + HasAfter = true, + AfterState = state, + AfterEpoch = epoch, + OldReferencePushed = oldReferencePushed, + OldReferenceValue = oldReferenceValue + }; + } + + internal string ToDisplayString() + { + if (!HasBefore) + { + return "not started"; + } + + return string.Create(CultureInfo.InvariantCulture, + $"beforeState=0x{BeforeState:X}, beforeEpoch={BeforeEpoch}, beforeReference={BeforeReference}, afterCaptured={HasAfter}, afterState=0x{AfterState:X}, afterEpoch={AfterEpoch}, oldReferencePushed={OldReferencePushed}, oldReferenceValue={OldReferenceValue ?? ""}"); + } + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index 1ad6ea29..14f762a1 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -1,31 +1,49 @@ # CheatEngine.SDK.LiveProbe -`CheatEngine.SDK.LiveProbe` is a manually loaded **evidence harness**, not a unit-test project, sample plugin, package asset, or normal CI input. It records CE 7.7 behaviours that fixture tests cannot establish: the raw managed bootstrap argument, the disputed packed-record tail, `synchronize`, per-thread Lua states and registry sharing, external `resetLuaState`, CE userdata, and callback cleanup on plugin disable. +`CheatEngine.SDK.LiveProbe` is a manually loaded **evidence harness**, not a unit-test project, sample plugin, package +asset, or normal CI input. It records CE 7.7 behaviours that fixture tests cannot establish: the raw managed bootstrap +argument, the disputed packed-record tail, `synchronize`, per-thread Lua states and registry sharing, external +`resetLuaState`, CE userdata, and callback cleanup on plugin disable. ## Safety boundary The probe is deliberately inert unless all of these are true: -1. The process is x64 and its main executable is exactly CE `7.7.0.10621` x64, SHA-256 `9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D`. +1. The process is x64 and its main executable is exactly CE `7.7.0.10621` x64, SHA-256 + `9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D`. 2. `CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT` exactly equals `I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET`. -3. `CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE` names an unexpired JSON manifest whose schema is `ce77-live-probe-v1`, repeats that acknowledgement and host hash, identifies a live target PID, marks it `disposable: true`, and gives the SHA-256 of that target's currently running image. +3. `CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE` names an unexpired JSON manifest whose schema is `ce77-live-probe-v1`, repeats + that acknowledgement and host hash, identifies a live target PID, marks it `disposable: true`, and gives the SHA-256 + of that target's currently running image. 4. After enable, CE's `getOpenedProcessID()` is exactly that manifest PID. -The harness never opens or selects a process, changes target memory, injects, starts a debugger, or executes arbitrary target code. The one intentionally risky observation is an operator-authorized four-byte canary immediately after the SDK's conservative packed 36-byte bootstrap record. It runs only during bootstrap after conditions 1–3 pass. It is isolated to the CE process, not the target, but must still be run only with a disposable test setup. A canary value that survives **does not prove allocation capacity** on its own; preserve all raw observations for review. +The harness never opens or selects a process, changes target memory, injects, starts a debugger, or executes arbitrary +target code. The one intentionally risky observation is an operator-authorized four-byte canary immediately after the +SDK's conservative packed 36-byte bootstrap record. It runs only during bootstrap after conditions 1–3 pass. It is +isolated to the CE process, not the target, but must still be run only with a disposable test setup. A canary value that +survives **does not prove allocation capacity** on its own; preserve all raw observations for review. -The harness does not write a result file: it logs raw values through `HostLog`/`OutputDebugString`, so capture it with a debugger or DebugView and save the transcript outside the repository. Do not place installed CE binaries, target binaries, manifests containing sensitive paths, or captured process memory in source control. +The harness does not write a result file: it logs raw values through `HostLog`/`OutputDebugString`, so capture it with a +debugger or DebugView and save the transcript outside the repository. Do not place installed CE binaries, target +binaries, manifests containing sensitive paths, or captured process memory in source control. ## Build and load -Build it manually; it is intentionally absent from `CheatEngine.SDK.slnx`, so ordinary SDK builds and CI never load or run it. +Build it manually; it is intentionally absent from `CheatEngine.SDK.slnx`, so ordinary SDK builds and CI never load or +run it. ```powershell dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release ``` -Keep the complete `artifacts/bin/CheatEngine.SDK.LiveProbe/release/` folder together when loading `CheatEngine.SDK.LiveProbe.dll` in CE's plugin settings. It needs the SDK assemblies, `.deps.json`, `.runtimeconfig.json` and `cheatengine-sdk-lua-bridge.dll` next to the plugin. Follow the CE/.NET runtime-host setup requirements documented by [`CheatEngine.SDK.LivePlugin`](../CheatEngine.SDK.LivePlugin/README.md) before attempting a live run. +Keep the complete `artifacts/bin/CheatEngine.SDK.LiveProbe/release/` folder together when loading +`CheatEngine.SDK.LiveProbe.dll` in CE's plugin settings. It needs the SDK assemblies, `.deps.json`, +`.runtimeconfig.json` and `cheatengine-sdk-lua-bridge.dll` next to the plugin. Follow the CE/.NET runtime-host setup +requirements documented by [`CheatEngine.SDK.LivePlugin`](../CheatEngine.SDK.LivePlugin/README.md) before attempting a +live run. -Create a short-lived authorization file on a secure local volume. Substitute only the hash and PID of the disposable program that the operator has deliberately launched and attached in CE: +Create a short-lived authorization file on a secure local volume. Substitute only the hash and PID of the disposable +program that the operator has deliberately launched and attached in CE: ```json { @@ -39,24 +57,31 @@ Create a short-lived authorization file on a secure local volume. Substitute onl } ``` -Set both environment variables in the same process tree that starts CE. Check `ce77_live_probe_status()` immediately after enabling. If it reports any denied gate, stop: none of the action commands should be used and no result is evidence. +Set both environment variables in the same process tree that starts CE. Check `ce77_live_probe_status()` immediately +after enabling. If it reports any denied gate, stop: none of the action commands should be used and no result is +evidence. ## Console protocol -Run every command from CE's Lua Engine and preserve the command, UTC time, returned text, DebugView transcript, CE binary hash, target image hash, PID, architecture and manifest expiry with the result. Commands intentionally do not guess a pass/fail conclusion. - -| Command | Observation | Operator action / interpretation | -|---|---|---| -| `ce77_live_probe_status()` | Every raw bootstrap integer, tail-canary record, gate decision and prior outcome. | The second integer is reported as `opaqueSecondInt`; never label it size/version from this output alone. | -| `ce77_live_probe_host_profile()` | One JSON identity record for the authorized CE host, loaded Lua module, adjacent bridge binary, plugin binary, and disposable target. | Save the returned JSON with the DebugView transcript outside the repository. An observed file is not a live qualification until the artifact is reviewed against the catalogue. | -| `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | Do not block the GUI; poll until completion. Compare IDs with the enable-thread log. | -| `ce77_live_probe_begin_lua_threads()` then `ce77_live_probe_lua_threads_status()` | GUI and worker `lua_State*` identities and a private raw-registry marker read by the worker. | Do not execute other Lua for one second. This is a narrow observation, not permission for arbitrary concurrent Lua. | -| `ce77_live_probe_snapshot_before_reset()` | State pointer, SDK epoch and reference slot before reset. | Manually call CE's `resetLuaState()`; the harness never calls it. | -| `ce77_live_probe_snapshot_after_reset()` | State/epoch after reset and whether the old SDK reference pushed. | Record raw outcome. External reset without a corresponding SDK notification remains unsupported. | -| `ce77_live_probe_userdata()` | `type(getMainForm())` and `tostring(getMainForm())`. | This observes CE userdata without retaining it or invoking the host-object pusher. | -| `ce77_live_probe_prepare_callback_shutdown()` | Installs a counter callback. | Call `pcall(ce77_live_probe_callback_shutdown)` once; disable the plugin; call it again under `pcall`; then re-enable and collect `status()`. | - -The callback probe intentionally leaves the callback registered in `OnDisable`; `LuaRuntime.Detach` is responsible for neutralizing it. Do not force-unload assemblies or use CE's process-killing actions to end a run. Disable the plugin, close CE normally, delete the short-lived authorization manifest, and terminate only the disposable target through its normal cleanup route. +Run every command from CE's Lua Engine and preserve the command, UTC time, returned text, DebugView transcript, CE +binary hash, target image hash, PID, architecture and manifest expiry with the result. Commands intentionally do not +guess a pass/fail conclusion. + +| Command | Observation | Operator action / interpretation | +|-----------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ce77_live_probe_status()` | Every raw bootstrap integer, tail-canary record, gate decision and prior outcome. | The second integer is reported as `opaqueSecondInt`; never label it size/version from this output alone. | +| `ce77_live_probe_host_profile()` | One JSON identity record for the authorized CE host, loaded Lua module, adjacent bridge binary, plugin binary, and disposable target. | Save the returned JSON with the DebugView transcript outside the repository. An observed file is not a live qualification until the artifact is reviewed against the catalogue. | +| `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | Do not block the GUI; poll until completion. Compare IDs with the enable-thread log. | +| `ce77_live_probe_begin_lua_threads()` then `ce77_live_probe_lua_threads_status()` | GUI and worker `lua_State*` identities and a private raw-registry marker read by the worker. | Do not execute other Lua for one second. This is a narrow observation, not permission for arbitrary concurrent Lua. | +| `ce77_live_probe_snapshot_before_reset()` | State pointer, SDK epoch and reference slot before reset. | Manually call CE's `resetLuaState()`; the harness never calls it. | +| `ce77_live_probe_snapshot_after_reset()` | State/epoch after reset and whether the old SDK reference pushed. | Record raw outcome. External reset without a corresponding SDK notification remains unsupported. | +| `ce77_live_probe_userdata()` | `type(getMainForm())` and `tostring(getMainForm())`. | This observes CE userdata without retaining it or invoking the host-object pusher. | +| `ce77_live_probe_prepare_callback_shutdown()` | Installs a counter callback. | Call `pcall(ce77_live_probe_callback_shutdown)` once; disable the plugin; call it again under `pcall`; then re-enable and collect `status()`. | + +The callback probe intentionally leaves the callback registered in `OnDisable`; `LuaRuntime.Detach` is responsible for +neutralizing it. Do not force-unload assemblies or use CE's process-killing actions to end a run. Disable the plugin, +close CE normally, delete the short-lived authorization manifest, and terminate only the disposable target through its +normal cleanup route. ## Scope and limitations @@ -64,12 +89,20 @@ The callback probe intentionally leaves the callback registered in `OnDisable`; and CE opened-process PID immediately before acting. This is a current-state check, not proof that CE did not select another target between observations; PID reuse by an identical executable is not distinguishable without an operator-supplied incarnation value, which the `ce77-live-probe-v1` manifest does not contain. -- The plugin does not implement the classic native plugin Type-6 popup callback. That callback belongs to the classic ABI and needs a separately compiled, header-pinned native probe after the CE 7.7 header/Pascal divergence has been resolved. -- The tail canary is a bounded experimental write, not a new ABI rule. The SDK production path continues to write only the packed 36-byte record. -- `synchronize` facts become evidence only when raw thread IDs, exception text, return value and re-entrancy outcome are captured from the pinned host. `inMainThread()` capture and disable-while-worker drain remain future controlled probes; this harness does not claim either result. -- This version observes an external `resetLuaState()` only. It must not be mistaken for the planned SDK-controlled reset/generation contract. +- The plugin does not implement the classic native plugin Type-6 popup callback. That callback belongs to the classic + ABI and needs a separately compiled, header-pinned native probe after the CE 7.7 header/Pascal divergence has been + resolved. +- The tail canary is a bounded experimental write, not a new ABI rule. The SDK production path continues to write only + the packed 36-byte record. +- `synchronize` facts become evidence only when raw thread IDs, exception text, return value and re-entrancy outcome are + captured from the pinned host. `inMainThread()` capture and disable-while-worker drain remain future controlled + probes; this harness does not claim either result. +- This version observes an external `resetLuaState()` only. It must not be mistaken for the planned SDK-controlled + reset/generation contract. - The worker-and-registry observation is opt-in only. A distinct worker Lua pointer may be a coroutine sharing the main virtual machine, heap and registry, so it is not evidence of independent heaps or safe concurrent execution. -- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. The only automated validation of this project is compilation. +- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. The only automated validation of + this project is compilation. -Detailed result templates and evidence rules live in [`documentations/CheatEngine.SDK/live-probes`](../../documentations/CheatEngine.SDK/live-probes/README.md). +Detailed result templates and evidence rules live in [ +`documentations/CheatEngine.SDK/live-probes`](../../documentations/CheatEngine.SDK/live-probes/README.md). diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs index 488e4e2e..7261da7c 100644 --- a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; @@ -12,700 +13,1126 @@ namespace CheatEngine.SDK.Lua.FailureProbe; internal static unsafe class Program { - private const string CheckStackGrowthMode = "--checkstack-growth"; - - private const string GeneratedFunctionAllocationMode = "--generated-function-allocation"; - - private const int CheckStackGrowthSlots = 4096; - - // Exact C11 CHEATENGINE_SDK_NO_ERROR sentinel. The internal production alias is LuaProtectedApi.NoErrorStatus. - private const int BridgeNoErrorStatus = -100; - private const int PushBytesOperation = 0; - private const int PushHostObjectOperation = 10; - private const int ProtectedExportCount = 20; - private const long StackSentinel = 0x1CEB_00DA_5EED_1234; - - private const string UncheckedFunctionReservationFailureMessage = - "Lua could not reserve one stack slot for the bare C function; the stack is unchanged."; - - private static delegate* unmanaged[Cdecl] s_originalAllocator; - private static void* s_originalAllocatorData; - private static lua_State* s_runtimeState; - private static int s_rejectAllocations; - - public static int Main(string[] arguments) - { - if (arguments.Length is < 1 or > 2 || - (arguments.Length == 2 && - !string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) && - !string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal))) - return Fail("expected the Lua DLL path, optionally followed by --checkstack-growth or --generated-function-allocation"); - - nint module = 0; - lua_State* nativeState = null; - try - { - module = NativeLibrary.Load(Path.GetFullPath(arguments[0])); - if (!LuaApi.TryInitialize(module, out var bindFailure)) return Fail("could not bind Lua: " + bindFailure); - - nativeState = LuaApi.luaL_newstate(); - if (nativeState is null) return Fail("luaL_newstate returned null"); - LuaApi.luaL_openlibs(nativeState); - - void* allocatorData = null; - s_originalAllocator = LuaApi.lua_getallocf(nativeState, &allocatorData); - s_originalAllocatorData = allocatorData; - LuaApi.lua_setallocf(nativeState, &RejectingAllocator, null); - var state = new LuaState((nint)nativeState); - if (arguments.Length == 2) - { - return string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) - ? RunCheckStackGrowthProbe(state) - : RunGeneratedFunctionAllocationProbe(state); - } - - return RunProbe(state, module); - } - catch (Exception exception) - { - Volatile.Write(ref s_rejectAllocations, 0); - return Fail(exception.ToString()); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - if (nativeState is not null) - { - if (s_originalAllocator != null) - LuaApi.lua_setallocf(nativeState, s_originalAllocator, s_originalAllocatorData); - LuaApi.lua_close(nativeState); - } - - if (module != 0) NativeLibrary.Free(module); - } - } - - private static int RunProbe(LuaState state, nint luaModule) - { - var message = new byte[4096]; - Array.Fill(message, (byte)'x'); - - if (ProbeStringAllocation(state, message) != 0) return 1; - if (ProbeThunkFailure(state, message) != 0) return 1; - if (ProbeTableAllocation(state) != 0) return 1; - if (ProbeByteTableAllocation(state) != 0) return 1; - if (ProbeUserdataAllocation(state) != 0) return 1; - if (ProbeRawSetAllocation(state) != 0) return 1; - if (ProbeRawSetIndexAllocation(state) != 0) return 1; - if (ProbeRawSetPointerAllocation(state) != 0) return 1; - if (ProbeReferenceAllocation(state) != 0) return 1; - if (ProbePrivateReferenceReleaseAllocation(state) != 0) return 1; - if (ProbeCallbackAllocation(state) != 0) return 1; - if (ProbeGeneratedFunctionAllocation(state) != 0) return 1; - if (ProbeFailingFinalizer(state, message) != 0) return 1; - if (ProbeHostObjectPusherLongJump(state, luaModule) != 0) return 1; - - var recoveryStatus = state.TryExecute("return 6 * 7"u8, 1, "=post-failure-recovery"u8); - if (!recoveryStatus.IsOk) - return Fail("the Lua state could not execute a new protected call after the failures"); - if (!state.TryReadInteger(-1, out var recoveryValue) || recoveryValue != 42) - return Fail("the Lua state returned an unexpected post-failure recovery value"); - state.Pop(1); - - Console.WriteLine("PASS native protected allocation, finalizer, and host-object longjmp boundaries"); - return 0; - } - - private static int RunGeneratedFunctionAllocationProbe(LuaState state) - { - if (ProbeGeneratedFunctionAllocation(state) != 0) return 1; - WriteMarker("PASS generated function closure allocation failure returns status and restores stack"); - return 0; - } - - private static int ProbeStringAllocation(LuaState state, byte[] message) - { - if (PushSentinel(state, "TryPushString") != 0) return 1; - LuaStatus status; - Volatile.Write(ref s_rejectAllocations, 1); - try - { - status = state.TryPushString(message); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - if (status != LuaStatus.MemoryError) return Fail("TryPushString did not return LUA_ERRMEM"); - return AssertErrorThenRestoreSentinel(state, "TryPushString"); - } - - private static int RunCheckStackGrowthProbe(LuaState state) - { - if (state.Top != 0) return Fail("lua_checkstack probe started with a non-empty Lua stack"); - - WriteMarker("MARK lua_checkstack-direct-growth-before-reject"); - bool reserved; - Volatile.Write(ref s_rejectAllocations, 1); - // Deliberately call the current managed binding without LuaProtectedApi: this is the pre-bridge path whose - // fixture behavior we must observe under an allocation failure. - try - { - reserved = state.TryEnsureStack(CheckStackGrowthSlots); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - if (reserved) - return Fail("lua_checkstack unexpectedly reserved 4096 slots while the allocator rejected growth"); - if (state.Top != 0) return Fail("lua_checkstack changed the stack after rejected growth"); - WriteMarker("MARK lua_checkstack-direct-growth-returned-zero"); - - if (!state.TryEnsureStack(CheckStackGrowthSlots)) - return Fail("lua_checkstack could not reserve the same 4096 slots after allocator recovery"); - if (state.Top != 0) return Fail("lua_checkstack recovery changed the stack"); - WriteMarker("MARK lua_checkstack-direct-growth-recovery-reserved"); - - if (ProbeBridgeNoErrorWithFullStack(state) != 0) return 1; - if (ProbeUncheckedFunctionWithFullStack(state) != 0) return 1; - - var recoveryStatus = state.TryExecute("return 6 * 7"u8, 1, "=checkstack-recovery"u8); - if (!recoveryStatus.IsOk) return Fail("the Lua state could not execute after rejected lua_checkstack growth"); - if (!state.TryReadInteger(-1, out var recoveryValue) || recoveryValue != 42) - return Fail("the Lua state returned an unexpected checkstack recovery value"); - state.Pop(1); - - WriteMarker("PASS lua_checkstack direct rejected-growth returns 0 and the state recovers"); - return 0; - } - - private static int ProbeBridgeNoErrorWithFullStack(LuaState state) - { - var bridgePath = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); - if (!File.Exists(bridgePath)) return Fail("the native Lua bridge was not copied beside the checkstack probe"); - - var bridge = NativeLibrary.Load(bridgePath); - try - { - var protectedOperation = - (delegate* unmanaged[Cdecl])NativeLibrary - .GetExport( - bridge, - "cheatengine_sdk_lua_protected"); - var exports = stackalloc nint[ProtectedExportCount]; - PopulateProtectedExports(LuaApi.ModuleHandle, exports); - - var initialTop = state.Top; - int status; - WriteMarker("MARK lua_checkstack-bridge-fill-before-reject"); - Volatile.Write(ref s_rejectAllocations, 1); - try - { - if (!TryFillStackUntilGrowthIsRejected(state, out var fullTop)) - return Fail("lua_checkstack could not fill any already-reserved stack slot"); - WriteMarker("MARK lua_checkstack-bridge-stack-full"); - - // PushBytes with an empty payload is a valid zero-input operation. The bridge must return its native - // no-error sentinel before pushing the closure, rather than modifying this full Lua stack. - status = protectedOperation( - (lua_State*)state.Handle, - exports, - PushBytesOperation, - 0, - null, - 0, - 0, - 0); - if (status != BridgeNoErrorStatus) - return Fail("the full-stack bridge call did not return its NoErrorStatus sentinel"); - if (state.Top != fullTop) - return Fail("the full-stack bridge call changed the Lua stack before returning NoErrorStatus"); - WriteMarker("MARK lua_checkstack-bridge-returned-no-error-status"); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - state.SetTop(initialTop); - if (state.Top != initialTop) return Fail("the full-stack bridge probe could not restore the Lua stack"); - if (!state.TryEnsureStack(1)) - return Fail("lua_checkstack could not reserve a slot after bridge-stack recovery"); - WriteMarker("MARK lua_checkstack-bridge-stack-restored"); - return 0; - } - finally - { - NativeLibrary.Free(bridge); - } - } - - private static bool TryFillStackUntilGrowthIsRejected(LuaState state, out int fullTop) - { - var pushes = 0; - // Every push is preceded by the direct binding. The first false proves the stack needs an allocation that the - // fixture rejects; PushInteger itself is allocation-free while the reservation is true. - while (state.TryEnsureStack(1)) - { - state.PushInteger(pushes); - pushes++; - } - - fullTop = state.Top; - return pushes != 0; - } - - private static int ProbeUncheckedFunctionWithFullStack(LuaState state) - { - var initialTop = state.Top; - var function = new LuaNativeFunction(&NoOp); - WriteMarker("MARK lua_pushuncheckedfunction-fill-before-reject"); - Volatile.Write(ref s_rejectAllocations, 1); - try - { - if (!TryFillStackUntilGrowthIsRejected(state, out var fullTop)) - return Fail("PushUncheckedFunction could not fill any already-reserved stack slot"); - WriteMarker("MARK lua_pushuncheckedfunction-stack-full"); - - try - { - state.PushUncheckedFunction(function); - return Fail("PushUncheckedFunction unexpectedly pushed on a full Lua stack"); - } - catch (InvalidOperationException exception) - { - if (!string.Equals(exception.Message, UncheckedFunctionReservationFailureMessage, - StringComparison.Ordinal)) - return Fail("PushUncheckedFunction returned an unstable reservation failure message"); - } - - if (state.Top != fullTop) - return Fail("PushUncheckedFunction changed the full Lua stack after reservation rejection"); - WriteMarker("MARK lua_pushuncheckedfunction-reservation-rejected"); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - state.SetTop(initialTop); - if (state.Top != initialTop) - return Fail("PushUncheckedFunction could not restore the Lua stack after reservation rejection"); - if (!state.TryEnsureStack(1)) - return Fail("lua_checkstack could not reserve a slot after PushUncheckedFunction recovery"); - - state.PushUncheckedFunction(function); - if (state.Top != initialTop + 1) - return Fail("PushUncheckedFunction did not recover after allocator rejection"); - state.Pop(1); - WriteMarker("MARK lua_pushuncheckedfunction-stack-restored"); - return 0; - } - - private static int ProbeThunkFailure(LuaState state, byte[] message) - { - if (PushSentinel(state, "LuaThunk.Fail") != 0) return 1; - int results; - Volatile.Write(ref s_rejectAllocations, 1); - try - { - results = LuaThunk.Fail(state, message); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - if (results != LuaThunk.FailureResultCount) return Fail("LuaThunk.Fail returned the wrong result count"); - return AssertResultsThenRestoreSentinel(state, LuaThunk.FailureResultCount, "LuaThunk.Fail"); - } - - private static int ProbeTableAllocation(LuaState state) - { - if (PushSentinel(state, "CreateTable") != 0) return 1; - var status = CaptureMemoryException(() => state.CreateTable()); - if (status != LuaStatus.MemoryError) return Fail("CreateTable did not throw LUA_ERRMEM"); - return AssertOnlySentinelRemains(state, "CreateTable"); - } - - private static int ProbeByteTableAllocation(LuaState state) - { - if (PushSentinel(state, "PushByteTable") != 0) return 1; - var bytes = new byte[4096]; - var status = CaptureMemoryException(() => state.PushByteTable(bytes)); - if (status != LuaStatus.MemoryError) return Fail("PushByteTable did not throw LUA_ERRMEM"); - if (AssertOnlySentinelRemains(state, "PushByteTable") != 0) return 1; - WriteMarker("MARK PushByteTable protected allocator boundary recovered"); - return 0; - } - - private static int ProbeUserdataAllocation(LuaState state) - { - if (PushSentinel(state, "NewUserdata") != 0) return 1; - var status = CaptureMemoryException(() => state.NewUserdata(4096)); - if (status != LuaStatus.MemoryError) return Fail("NewUserdata did not throw LUA_ERRMEM"); - return AssertOnlySentinelRemains(state, "NewUserdata"); - } - - private static int ProbeRawSetAllocation(LuaState state) - { - if (PushSentinel(state, "TryRawSet") != 0) return 1; - state.CreateTable(); - state.PushInteger(1); - state.PushInteger(2); - var status = CaptureMemoryException(() => state.TryRawSet(2)); - if (status != LuaStatus.MemoryError) return Fail("TryRawSet did not throw LUA_ERRMEM"); - return AssertTableThenRestoreSentinel(state, "TryRawSet"); - } - - private static int ProbeRawSetIndexAllocation(LuaState state) - { - if (PushSentinel(state, "RawSetIndex") != 0) return 1; - state.CreateTable(); - state.PushInteger(2); - var status = CaptureMemoryException(() => state.RawSetIndex(2, 1)); - if (status != LuaStatus.MemoryError) return Fail("RawSetIndex did not throw LUA_ERRMEM"); - return AssertTableThenRestoreSentinel(state, "RawSetIndex"); - } - - private static int ProbeRawSetPointerAllocation(LuaState state) - { - if (PushSentinel(state, "RawSetPointer") != 0) return 1; - state.CreateTable(); - state.PushInteger(3); - var status = CaptureMemoryException(() => state.RawSetPointer(2, 0x1CEB)); - if (status != LuaStatus.MemoryError) return Fail("RawSetPointer did not throw LUA_ERRMEM"); - return AssertTableThenRestoreSentinel(state, "RawSetPointer"); - } - - private static int ProbeReferenceAllocation(LuaState state) - { - if (PushSentinel(state, "CreateRef") != 0) return 1; - state.PushInteger(42); - var status = CaptureMemoryException(() => state.CreateRef()); - if (status != LuaStatus.MemoryError) return Fail("CreateRef did not throw LUA_ERRMEM"); - return AssertOnlySentinelRemains(state, "CreateRef"); - } - - private static int ProbePrivateReferenceReleaseAllocation(LuaState state) - { - if (state.Top != 0) return Fail("LuaRef.Release started with a non-empty Lua stack"); - - s_runtimeState = (lua_State*)state.Handle; - delegate* unmanaged[Stdcall] provider = &ProvideRuntimeState; - LuaHostBinding binding = new( - stateProvider: provider, - hostObjectPusher: null, - mainThreadId: Environment.CurrentManagedThreadId); - LuaRuntime.Attach(in binding); - try - { - state.PushInteger(42); - var reference = state.CreateRef(); - if (!reference.IsCurrent) return Fail("CreateRef did not produce a current private reference"); - if (PushSentinel(state, "LuaRef.Release") != 0) return 1; - - var status = ReleaseWithRejectedAllocator(reference, state); - - if (!status.IsOk && status != LuaStatus.MemoryError) - return Fail("LuaRef.Release returned an unexpected status while allocator rejection was active: " + - status); - if (reference.IsResolved || state.TryPushRef(reference)) - return Fail("LuaRef.Release left its released private reference usable"); - if (AssertOnlySentinelRemains(state, "LuaRef.Release") != 0) return 1; - - // The release path may be allocation-free (LUA_OK) or may return LUA_ERRMEM after Lua's private free-list - // bookkeeping. In either case the private table must still accept, return and release a fresh slot once the - // allocator has recovered. - state.PushInteger(99); - var replacement = state.CreateRef(); - try - { - if (!replacement.IsCurrent || !state.TryPushRef(replacement)) - return Fail("the private reference table did not recover after LuaRef.Release"); - if (!state.TryReadInteger(-1, out var value) || value != 99) - return Fail("the recovered private reference returned the wrong value"); - state.Pop(1); - } - finally - { - replacement.Release(state); - } - - if (state.Top != 0) return Fail("LuaRef.Release recovery left values on the Lua stack"); - WriteMarker("MARK LuaRef.Release protected allocator boundary recovered"); - return 0; - } - finally - { - LuaRuntime.Detach(); - s_runtimeState = null; - } - } - - private static LuaStatus ReleaseWithRejectedAllocator(LuaRef reference, LuaState state) - { - Volatile.Write(ref s_rejectAllocations, 1); - try - { - reference.Release(state); - return LuaStatus.Ok; - } - catch (LuaException exception) - { - return exception.Status; - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - } - - private static int ProbeCallbackAllocation(LuaState state) - { - if (PushSentinel(state, "LuaCallback.TryCreate") != 0) return 1; - LuaStatus status; - LuaCallback? callback; - var function = new LuaNativeFunction(&NoOp); - Volatile.Write(ref s_rejectAllocations, 1); - try - { - status = LuaCallback.TryCreate(state, function, new object(), out callback); - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - if (status != LuaStatus.MemoryError) return Fail("LuaCallback.TryCreate did not return LUA_ERRMEM"); - if (callback is not null) return Fail("LuaCallback.TryCreate returned a callback after failure"); - return AssertErrorThenRestoreSentinel(state, "LuaCallback.TryCreate"); - } - - private static int ProbeGeneratedFunctionAllocation(LuaState state) - { - var function = new LuaNativeFunction(&NoOp); - - // Install the wrapper while allocation is permitted. The guarded closure below is then the only allocating - // operation, so a rejected allocation is guaranteed to exercise PushClosure rather than helper installation. - var warmup = LuaRuntime.TryPushGeneratedFunction(state, function); - if (!warmup.IsOk) return Fail("TryPushGeneratedFunction could not install its wrapper before allocation rejection"); - state.Pop(1); - if (state.Top != 0) return Fail("TryPushGeneratedFunction warm-up left values on the Lua stack"); - - if (PushSentinel(state, "TryPushGeneratedFunction") != 0) return 1; - LuaStatus status; - Volatile.Write(ref s_rejectAllocations, 1); - try - { - try - { - status = LuaRuntime.TryPushGeneratedFunction(state, function); - } - catch (LuaException) - { - return Fail("TryPushGeneratedFunction converted the PushClosure failure into LuaException"); - } - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - - if (status != LuaStatus.MemoryError) - return Fail("TryPushGeneratedFunction did not return PushClosure's LUA_ERRMEM status"); - if (AssertErrorThenRestoreSentinel(state, "TryPushGeneratedFunction") != 0) return 1; - WriteMarker("MARK TryPushGeneratedFunction PushClosure status, stack, and ownership recovered"); - - var recovery = LuaRuntime.TryPushGeneratedFunction(state, function); - if (!recovery.IsOk) return Fail("TryPushGeneratedFunction did not recover after PushClosure allocation failure"); - state.Pop(1); - return state.Top == 0 - ? 0 - : Fail("TryPushGeneratedFunction recovery left values on the Lua stack"); - } - - private static int ProbeFailingFinalizer(LuaState state, byte[] message) - { - if (PushSentinel(state, "failing __gc") != 0) return 1; - var setup = state.TryExecute( - "collectgarbage('stop'); setmetatable({}, { __gc = function() error('expected finalizer failure') end }); collectgarbage('restart')"u8, - 0); - if (!setup.IsOk) return Fail("could not install the failing finalizer: " + setup); - - for (var attempt = 0; attempt < 100_000; attempt++) - { - // Long strings are not interned, so each protected push gives the incremental collector work to do. - message[0] = (byte)(attempt & 0x7f); - var status = state.TryPushString(message); - if (status == LuaStatus.GcMetamethodError) return AssertErrorThenRestoreSentinel(state, "failing __gc"); - - if (!status.IsOk) - return Fail("string allocation returned an unexpected status while awaiting __gc: " + status); - state.Pop(1); - } - - return Fail("the protected allocation path did not observe the failing __gc"); - } - - private static int ProbeHostObjectPusherLongJump(LuaState state, nint luaModule) - { - var bridgePath = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); - if (!File.Exists(bridgePath)) return Fail("the native Lua bridge was not copied beside the failure probe"); - - var bridge = NativeLibrary.Load(bridgePath); - try - { - var protectedOperation = - (delegate* unmanaged[Cdecl])NativeLibrary - .GetExport( - bridge, - "cheatengine_sdk_lua_protected"); - var luaCheckInteger = NativeLibrary.GetExport(luaModule, "luaL_checkinteger"); - var exports = stackalloc nint[ProtectedExportCount]; - PopulateProtectedExports(luaModule, exports); - - // Windows x64 has one native calling convention. luaL_checkinteger is an actual native Lua helper that - // raises when its first C-function argument is absent. Passing nativeObject=1 selects that missing - // argument while inputCount=0 proves OP_PUSH_HOST_OBJECT reaches the pusher rather than failing its - // own input validation. No managed reverse-P/Invoke frame participates in the non-local exit. - if (PushSentinel(state, "PushHostObject") != 0) return 1; - var status = protectedOperation( - (lua_State*)state.Handle, - exports, - PushHostObjectOperation, - 0, - (void*)luaCheckInteger, - 0, - 1, - 0); - if (status != LuaApi.LUA_ERRRUN) - return Fail("PushHostObject did not return LUA_ERRRUN after a host-pusher longjmp"); - if (!state.TryReadString(-1, out var error) || - !error.Contains("bad argument #1", StringComparison.Ordinal)) - return Fail("PushHostObject did not leave the native luaL_checkinteger failure message on the stack"); - if (AssertErrorThenRestoreSentinel(state, "PushHostObject") != 0) return 1; - WriteMarker("MARK PushHostObject native pusher longjmp observed"); - return 0; - } - finally - { - NativeLibrary.Free(bridge); - } - } - - private static void PopulateProtectedExports(nint module, nint* exports) - { - exports[0] = NativeLibrary.GetExport(module, "lua_gettop"); - exports[1] = NativeLibrary.GetExport(module, "lua_settop"); - exports[2] = NativeLibrary.GetExport(module, "lua_checkstack"); - exports[3] = NativeLibrary.GetExport(module, "lua_rotate"); - exports[4] = NativeLibrary.GetExport(module, "lua_pushlstring"); - exports[5] = NativeLibrary.GetExport(module, "lua_pushinteger"); - exports[6] = NativeLibrary.GetExport(module, "lua_createtable"); - exports[7] = NativeLibrary.GetExport(module, "lua_newuserdata"); - exports[8] = NativeLibrary.GetExport(module, "lua_pushcclosure"); - exports[9] = NativeLibrary.GetExport(module, "lua_pushlightuserdata"); - exports[10] = NativeLibrary.GetExport(module, "lua_rawset"); - exports[11] = NativeLibrary.GetExport(module, "lua_rawseti"); - exports[12] = NativeLibrary.GetExport(module, "lua_rawsetp"); - exports[13] = NativeLibrary.GetExport(module, "lua_rawgetp"); - exports[14] = NativeLibrary.GetExport(module, "lua_rawgeti"); - exports[15] = NativeLibrary.GetExport(module, "lua_type"); - exports[16] = NativeLibrary.GetExport(module, "lua_pcallk"); - exports[17] = NativeLibrary.GetExport(module, "lua_error"); - exports[18] = NativeLibrary.GetExport(module, "luaL_ref"); - exports[19] = NativeLibrary.GetExport(module, "luaL_unref"); - } - - private static LuaStatus CaptureMemoryException(Action operation) - { - Volatile.Write(ref s_rejectAllocations, 1); - try - { - operation(); - return LuaStatus.Ok; - } - catch (LuaException exception) - { - return exception.Status; - } - finally - { - Volatile.Write(ref s_rejectAllocations, 0); - } - } - - private static int PushSentinel(LuaState state, string operation) - { - if (state.Top != 0) return Fail(operation + " started with a non-empty Lua stack"); - state.PushInteger(StackSentinel); - return state.Top == 1 ? 0 : Fail(operation + " could not establish its Lua stack sentinel"); - } - - private static int AssertErrorThenRestoreSentinel(LuaState state, string operation) - { - if (state.Top != 2) - return Fail(operation + " did not leave exactly one error above the pre-existing stack value"); - state.Pop(1); - return AssertOnlySentinelRemains(state, operation); - } - - private static int AssertResultsThenRestoreSentinel(LuaState state, int resultCount, string operation) - { - if (state.Top != resultCount + 1) - return Fail(operation + " did not preserve its pre-existing stack value and leave its documented results"); - state.Pop(resultCount); - return AssertOnlySentinelRemains(state, operation); - } - - private static int AssertTableThenRestoreSentinel(LuaState state, string operation) - { - if (state.Top != 2 || !state.IsTable(2)) - return Fail(operation + " did not preserve its table while consuming its failing inputs"); - state.Pop(1); - return AssertOnlySentinelRemains(state, operation); - } - - private static int AssertOnlySentinelRemains(LuaState state, string operation) - { - if (state.Top != 1 || !state.TryReadInteger(1, out var value) || value != StackSentinel) - return Fail(operation + " did not restore the pre-existing Lua stack exactly"); - state.Pop(1); - return state.Top == 0 ? 0 : Fail(operation + " left values on the Lua stack after restoration"); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int NoOp(nint _) - { - return 0; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* ProvideRuntimeState() - { - return s_runtimeState; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void* RejectingAllocator(void* _, void* pointer, nuint oldSize, nuint newSize) - { - if (newSize > oldSize && Volatile.Read(ref s_rejectAllocations) != 0) return null; - return s_originalAllocator(s_originalAllocatorData, pointer, oldSize, newSize); - } - - private static int Fail(string message) - { - Console.Error.WriteLine("FAIL " + message); - return 1; - } - - private static void WriteMarker(string marker) - { - Console.WriteLine(marker); - Console.Out.Flush(); - } + private const string CheckStackGrowthMode = "--checkstack-growth"; + + private const string GeneratedFunctionAllocationMode = "--generated-function-allocation"; + + private const int CheckStackGrowthSlots = 4096; + + // Exact C11 CHEATENGINE_SDK_NO_ERROR sentinel. The internal production alias is LuaProtectedApi.NoErrorStatus. + private const int BridgeNoErrorStatus = -100; + private const int PushBytesOperation = 0; + private const int PushHostObjectOperation = 10; + private const int ProtectedExportCount = 20; + private const long StackSentinel = 0x1CEB_00DA_5EED_1234; + + private const string UncheckedFunctionReservationFailureMessage = + "Lua could not reserve one stack slot for the bare C function; the stack is unchanged."; + + private static delegate* unmanaged[Cdecl] s_originalAllocator; + private static void* s_originalAllocatorData; + private static lua_State* s_runtimeState; + private static int s_rejectAllocations; + + public static int Main(string[] arguments) + { + if (!HasValidArguments(arguments)) + { + return Fail( + "expected the Lua DLL path, optionally followed by --checkstack-growth or --generated-function-allocation"); + } + + nint module = 0; + lua_State* nativeState = null; + try + { + module = NativeLibrary.Load(Path.GetFullPath(arguments[0])); + if (!LuaApi.TryInitialize(module, out string? bindFailure)) + { + return Fail("could not bind Lua: " + bindFailure); + } + + nativeState = LuaApi.luaL_newstate(); + if (nativeState is null) + { + return Fail("luaL_newstate returned null"); + } + + LuaApi.luaL_openlibs(nativeState); + + void* allocatorData = null; + s_originalAllocator = LuaApi.lua_getallocf(nativeState, &allocatorData); + s_originalAllocatorData = allocatorData; + LuaApi.lua_setallocf(nativeState, &RejectingAllocator, null); + LuaState state = new((nint) nativeState); + return RunRequestedProbe(state, module, arguments); + } + catch (Exception exception) + { + Volatile.Write(ref s_rejectAllocations, 0); + return Fail(exception.ToString()); + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + if (nativeState is not null) + { + if (s_originalAllocator != null) + { + LuaApi.lua_setallocf(nativeState, s_originalAllocator, s_originalAllocatorData); + } + + LuaApi.lua_close(nativeState); + } + + if (module != 0) + { + NativeLibrary.Free(module); + } + } + } + + private static bool HasValidArguments(string[] arguments) + { + return arguments.Length is >= 1 and <= 2 && + (arguments.Length != 2 || + string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) || + string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal)); + } + + private static int RunRequestedProbe(LuaState state, nint module, string[] arguments) + { + if (arguments.Length == 2) + { + return string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) + ? RunCheckStackGrowthProbe(state) + : RunGeneratedFunctionAllocationProbe(state); + } + + return RunProbe(state, module); + } + + private static int RunProbe(LuaState state, nint luaModule) + { + byte[] message = new byte[4096]; + Array.Fill(message, (byte) 'x'); + + if (RunAllocationProbes(state, message) != 0) + { + return 1; + } + + if (RunNativeBoundaryProbes(state, luaModule, message) != 0) + { + return 1; + } + + LuaStatus recoveryStatus = state.TryExecute("return 6 * 7"u8, 1, "=post-failure-recovery"u8); + if (!recoveryStatus.IsOk) + { + return Fail("the Lua state could not execute a new protected call after the failures"); + } + + if (!state.TryReadInteger(-1, out long recoveryValue) || recoveryValue != 42) + { + return Fail("the Lua state returned an unexpected post-failure recovery value"); + } + + state.Pop(1); + + Console.WriteLine("PASS native protected allocation, finalizer, and host-object longjmp boundaries"); + return 0; + } + + private static int RunAllocationProbes(LuaState state, byte[] message) + { + if (ProbeStringAllocation(state, message) != 0) + { + return 1; + } + + if (ProbeThunkFailure(state, message) != 0) + { + return 1; + } + + if (ProbeTableAllocation(state) != 0) + { + return 1; + } + + if (ProbeByteTableAllocation(state) != 0) + { + return 1; + } + + if (ProbeUserdataAllocation(state) != 0) + { + return 1; + } + + if (ProbeRawSetAllocation(state) != 0) + { + return 1; + } + + return ProbeRawSetIndexAllocation(state) == 0 ? 0 : 1; + } + + private static int RunNativeBoundaryProbes(LuaState state, nint luaModule, byte[] message) + { + if (ProbeRawSetPointerAllocation(state) != 0) + { + return 1; + } + + if (ProbeReferenceAllocation(state) != 0) + { + return 1; + } + + if (ProbePrivateReferenceReleaseAllocation(state) != 0) + { + return 1; + } + + if (ProbeCallbackAllocation(state) != 0) + { + return 1; + } + + if (ProbeGeneratedFunctionAllocation(state) != 0) + { + return 1; + } + + if (ProbeFailingFinalizer(state, message) != 0) + { + return 1; + } + + return ProbeHostObjectPusherLongJump(state, luaModule) == 0 ? 0 : 1; + } + + private static int RunGeneratedFunctionAllocationProbe(LuaState state) + { + if (ProbeGeneratedFunctionAllocation(state) != 0) + { + return 1; + } + + WriteMarker("PASS generated function closure allocation failure returns status and restores stack"); + return 0; + } + + private static int ProbeStringAllocation(LuaState state, byte[] message) + { + if (PushSentinel(state, "TryPushString") != 0) + { + return 1; + } + + LuaStatus status; + Volatile.Write(ref s_rejectAllocations, 1); + try + { + status = state.TryPushString(message); + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + + if (status != LuaStatus.MemoryError) + { + return Fail("TryPushString did not return LUA_ERRMEM"); + } + + return AssertErrorThenRestoreSentinel(state, "TryPushString"); + } + + private static int RunCheckStackGrowthProbe(LuaState state) + { + if (ProbeCheckStackGrowth(state) != 0) + { + return 1; + } + + if (ProbeBridgeNoErrorWithFullStack(state) != 0) + { + return 1; + } + + if (ProbeUncheckedFunctionWithFullStack(state) != 0) + { + return 1; + } + + LuaStatus recoveryStatus = state.TryExecute("return 6 * 7"u8, 1, "=checkstack-recovery"u8); + if (!recoveryStatus.IsOk) + { + return Fail("the Lua state could not execute after rejected lua_checkstack growth"); + } + + if (!state.TryReadInteger(-1, out long recoveryValue) || recoveryValue != 42) + { + return Fail("the Lua state returned an unexpected checkstack recovery value"); + } + + state.Pop(1); + + WriteMarker("PASS lua_checkstack direct rejected-growth returns 0 and the state recovers"); + return 0; + } + + private static int ProbeCheckStackGrowth(LuaState state) + { + if (state.Top != 0) + { + return Fail("lua_checkstack probe started with a non-empty Lua stack"); + } + + WriteMarker("MARK lua_checkstack-direct-growth-before-reject"); + bool reserved; + Volatile.Write(ref s_rejectAllocations, 1); + // Deliberately call the current managed binding without LuaProtectedApi: this is the pre-bridge path whose + // fixture behavior we must observe under an allocation failure. + try + { + reserved = state.TryEnsureStack(CheckStackGrowthSlots); + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + + if (reserved) + { + return Fail("lua_checkstack unexpectedly reserved 4096 slots while the allocator rejected growth"); + } + + if (state.Top != 0) + { + return Fail("lua_checkstack changed the stack after rejected growth"); + } + + WriteMarker("MARK lua_checkstack-direct-growth-returned-zero"); + + if (!state.TryEnsureStack(CheckStackGrowthSlots)) + { + return Fail("lua_checkstack could not reserve the same 4096 slots after allocator recovery"); + } + + if (state.Top != 0) + { + return Fail("lua_checkstack recovery changed the stack"); + } + + WriteMarker("MARK lua_checkstack-direct-growth-recovery-reserved"); + return 0; + } + + private static int ProbeBridgeNoErrorWithFullStack(LuaState state) + { + string bridgePath = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); + if (!File.Exists(bridgePath)) + { + return Fail("the native Lua bridge was not copied beside the checkstack probe"); + } + + IntPtr bridge = NativeLibrary.Load(bridgePath); + try + { + delegate* unmanaged[Cdecl] + protectedOperation = + (delegate* unmanaged[Cdecl]) + NativeLibrary + .GetExport( + bridge, + "cheatengine_sdk_lua_protected"); + IntPtr* exports = stackalloc nint[ProtectedExportCount]; + PopulateProtectedExports(LuaApi.ModuleHandle, exports); + return ProbeBridgeNoErrorWithFullStack(state, protectedOperation, exports); + } + finally + { + NativeLibrary.Free(bridge); + } + } + + private static int ProbeBridgeNoErrorWithFullStack( + LuaState state, + delegate* unmanaged[Cdecl] + protectedOperation, + IntPtr* exports) + { + int initialTop = state.Top; + if (InvokeBridgeWithFullStack(state, protectedOperation, exports) != 0) + { + return 1; + } + + state.SetTop(initialTop); + if (state.Top != initialTop) + { + return Fail("the full-stack bridge probe could not restore the Lua stack"); + } + + if (!state.TryEnsureStack(1)) + { + return Fail("lua_checkstack could not reserve a slot after bridge-stack recovery"); + } + + WriteMarker("MARK lua_checkstack-bridge-stack-restored"); + return 0; + } + + private static int InvokeBridgeWithFullStack( + LuaState state, + delegate* unmanaged[Cdecl] + protectedOperation, + IntPtr* exports) + { + WriteMarker("MARK lua_checkstack-bridge-fill-before-reject"); + Volatile.Write(ref s_rejectAllocations, 1); + try + { + if (!TryFillStackUntilGrowthIsRejected(state, out int fullTop)) + { + return Fail("lua_checkstack could not fill any already-reserved stack slot"); + } + + WriteMarker("MARK lua_checkstack-bridge-stack-full"); + + // PushBytes with an empty payload is a valid zero-input operation. The bridge must return its native + // no-error sentinel before pushing the closure, rather than modifying this full Lua stack. + int status = protectedOperation( + (lua_State*) state.Handle, + exports, + PushBytesOperation, + 0, + null, + 0, + 0, + 0); + if (status != BridgeNoErrorStatus) + { + return Fail("the full-stack bridge call did not return its NoErrorStatus sentinel"); + } + + if (state.Top != fullTop) + { + return Fail("the full-stack bridge call changed the Lua stack before returning NoErrorStatus"); + } + + WriteMarker("MARK lua_checkstack-bridge-returned-no-error-status"); + return 0; + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + } + + private static bool TryFillStackUntilGrowthIsRejected(LuaState state, out int fullTop) + { + int pushes = 0; + // Every push is preceded by the direct binding. The first false proves the stack needs an allocation that the + // fixture rejects; PushInteger itself is allocation-free while the reservation is true. + while (state.TryEnsureStack(1)) + { + state.PushInteger(pushes); + pushes++; + } + + fullTop = state.Top; + return pushes != 0; + } + + private static int ProbeUncheckedFunctionWithFullStack(LuaState state) + { + int initialTop = state.Top; + LuaNativeFunction function = new(&NoOp); + if (VerifyUncheckedFunctionReservationFailure(state, function) != 0) + { + return 1; + } + + state.SetTop(initialTop); + if (state.Top != initialTop) + { + return Fail("PushUncheckedFunction could not restore the Lua stack after reservation rejection"); + } + + if (!state.TryEnsureStack(1)) + { + return Fail("lua_checkstack could not reserve a slot after PushUncheckedFunction recovery"); + } + + state.PushUncheckedFunction(function); + if (state.Top != initialTop + 1) + { + return Fail("PushUncheckedFunction did not recover after allocator rejection"); + } + + state.Pop(1); + WriteMarker("MARK lua_pushuncheckedfunction-stack-restored"); + return 0; + } + + private static int VerifyUncheckedFunctionReservationFailure(LuaState state, LuaNativeFunction function) + { + WriteMarker("MARK lua_pushuncheckedfunction-fill-before-reject"); + Volatile.Write(ref s_rejectAllocations, 1); + try + { + if (!TryFillStackUntilGrowthIsRejected(state, out int fullTop)) + { + return Fail("PushUncheckedFunction could not fill any already-reserved stack slot"); + } + + WriteMarker("MARK lua_pushuncheckedfunction-stack-full"); + + try + { + state.PushUncheckedFunction(function); + return Fail("PushUncheckedFunction unexpectedly pushed on a full Lua stack"); + } + catch (InvalidOperationException exception) + { + if (!string.Equals(exception.Message, UncheckedFunctionReservationFailureMessage, + StringComparison.Ordinal)) + { + return Fail("PushUncheckedFunction returned an unstable reservation failure message"); + } + } + + if (state.Top != fullTop) + { + return Fail("PushUncheckedFunction changed the full Lua stack after reservation rejection"); + } + + WriteMarker("MARK lua_pushuncheckedfunction-reservation-rejected"); + return 0; + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + } + + private static int ProbeThunkFailure(LuaState state, byte[] message) + { + if (PushSentinel(state, "LuaThunk.Fail") != 0) + { + return 1; + } + + int results; + Volatile.Write(ref s_rejectAllocations, 1); + try + { + results = LuaThunk.Fail(state, message); + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + + if (results != LuaThunk.FailureResultCount) + { + return Fail("LuaThunk.Fail returned the wrong result count"); + } + + return AssertResultsThenRestoreSentinel(state, LuaThunk.FailureResultCount, "LuaThunk.Fail"); + } + + private static int ProbeTableAllocation(LuaState state) + { + if (PushSentinel(state, "CreateTable") != 0) + { + return 1; + } + + LuaStatus status = CaptureMemoryException(() => state.CreateTable()); + if (status != LuaStatus.MemoryError) + { + return Fail("CreateTable did not throw LUA_ERRMEM"); + } + + return AssertOnlySentinelRemains(state, "CreateTable"); + } + + private static int ProbeByteTableAllocation(LuaState state) + { + if (PushSentinel(state, "PushByteTable") != 0) + { + return 1; + } + + byte[] bytes = new byte[4096]; + LuaStatus status = CaptureMemoryException(() => state.PushByteTable(bytes)); + if (status != LuaStatus.MemoryError) + { + return Fail("PushByteTable did not throw LUA_ERRMEM"); + } + + if (AssertOnlySentinelRemains(state, "PushByteTable") != 0) + { + return 1; + } + + WriteMarker("MARK PushByteTable protected allocator boundary recovered"); + return 0; + } + + private static int ProbeUserdataAllocation(LuaState state) + { + if (PushSentinel(state, "NewUserdata") != 0) + { + return 1; + } + + LuaStatus status = CaptureMemoryException(() => state.NewUserdata(4096)); + if (status != LuaStatus.MemoryError) + { + return Fail("NewUserdata did not throw LUA_ERRMEM"); + } + + return AssertOnlySentinelRemains(state, "NewUserdata"); + } + + private static int ProbeRawSetAllocation(LuaState state) + { + if (PushSentinel(state, "TryRawSet") != 0) + { + return 1; + } + + state.CreateTable(); + state.PushInteger(1); + state.PushInteger(2); + LuaStatus status = CaptureMemoryException(() => state.TryRawSet(2)); + if (status != LuaStatus.MemoryError) + { + return Fail("TryRawSet did not throw LUA_ERRMEM"); + } + + return AssertTableThenRestoreSentinel(state, "TryRawSet"); + } + + private static int ProbeRawSetIndexAllocation(LuaState state) + { + if (PushSentinel(state, "RawSetIndex") != 0) + { + return 1; + } + + state.CreateTable(); + state.PushInteger(2); + LuaStatus status = CaptureMemoryException(() => state.RawSetIndex(2, 1)); + if (status != LuaStatus.MemoryError) + { + return Fail("RawSetIndex did not throw LUA_ERRMEM"); + } + + return AssertTableThenRestoreSentinel(state, "RawSetIndex"); + } + + private static int ProbeRawSetPointerAllocation(LuaState state) + { + if (PushSentinel(state, "RawSetPointer") != 0) + { + return 1; + } + + state.CreateTable(); + state.PushInteger(3); + LuaStatus status = CaptureMemoryException(() => state.RawSetPointer(2, 0x1CEB)); + if (status != LuaStatus.MemoryError) + { + return Fail("RawSetPointer did not throw LUA_ERRMEM"); + } + + return AssertTableThenRestoreSentinel(state, "RawSetPointer"); + } + + private static int ProbeReferenceAllocation(LuaState state) + { + if (PushSentinel(state, "CreateRef") != 0) + { + return 1; + } + + state.PushInteger(42); + LuaStatus status = CaptureMemoryException(() => state.CreateRef()); + if (status != LuaStatus.MemoryError) + { + return Fail("CreateRef did not throw LUA_ERRMEM"); + } + + return AssertOnlySentinelRemains(state, "CreateRef"); + } + + private static int ProbePrivateReferenceReleaseAllocation(LuaState state) + { + if (state.Top != 0) + { + return Fail("LuaRef.Release started with a non-empty Lua stack"); + } + + s_runtimeState = (lua_State*) state.Handle; + delegate* unmanaged[Stdcall] provider = &ProvideRuntimeState; + LuaHostBinding binding = new( + provider, + null, + Environment.CurrentManagedThreadId); + LuaRuntime.Attach(in binding); + try + { + return ProbePrivateReferenceRelease(state); + } + finally + { + LuaRuntime.Detach(); + s_runtimeState = null; + } + } + + private static int ProbePrivateReferenceRelease(LuaState state) + { + state.PushInteger(42); + LuaRef reference = state.CreateRef(); + if (!reference.IsCurrent) + { + return Fail("CreateRef did not produce a current private reference"); + } + + if (VerifyPrivateReferenceRelease(state, reference) != 0) + { + return 1; + } + + return VerifyPrivateReferenceRecovery(state); + } + + private static int VerifyPrivateReferenceRelease(LuaState state, LuaRef reference) + { + if (PushSentinel(state, "LuaRef.Release") != 0) + { + return 1; + } + + LuaStatus status = ReleaseWithRejectedAllocator(reference, state); + if (!status.IsOk && status != LuaStatus.MemoryError) + { + return Fail("LuaRef.Release returned an unexpected status while allocator rejection was active: " + status); + } + + if (reference.IsResolved || state.TryPushRef(reference)) + { + return Fail("LuaRef.Release left its released private reference usable"); + } + + return AssertOnlySentinelRemains(state, "LuaRef.Release") == 0 ? 0 : 1; + } + + private static int VerifyPrivateReferenceRecovery(LuaState state) + { + // The release path may be allocation-free (LUA_OK) or may return LUA_ERRMEM after Lua's private free-list + // bookkeeping. In either case the private table must still accept, return and release a fresh slot once the + // allocator has recovered. + state.PushInteger(99); + LuaRef replacement = state.CreateRef(); + try + { + if (!replacement.IsCurrent || !state.TryPushRef(replacement)) + { + return Fail("the private reference table did not recover after LuaRef.Release"); + } + + if (!state.TryReadInteger(-1, out long value) || value != 99) + { + return Fail("the recovered private reference returned the wrong value"); + } + + state.Pop(1); + } + finally + { + replacement.Release(state); + } + + if (state.Top != 0) + { + return Fail("LuaRef.Release recovery left values on the Lua stack"); + } + + WriteMarker("MARK LuaRef.Release protected allocator boundary recovered"); + return 0; + } + + private static LuaStatus ReleaseWithRejectedAllocator(LuaRef reference, LuaState state) + { + Volatile.Write(ref s_rejectAllocations, 1); + try + { + reference.Release(state); + return LuaStatus.Ok; + } + catch (LuaException exception) + { + return exception.Status; + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + } + + private static int ProbeCallbackAllocation(LuaState state) + { + if (PushSentinel(state, "LuaCallback.TryCreate") != 0) + { + return 1; + } + + LuaStatus status; + LuaCallback? callback; + LuaNativeFunction function = new(&NoOp); + Volatile.Write(ref s_rejectAllocations, 1); + try + { + status = LuaCallback.TryCreate(state, function, new object(), out callback); + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + + if (status != LuaStatus.MemoryError) + { + return Fail("LuaCallback.TryCreate did not return LUA_ERRMEM"); + } + + if (callback is not null) + { + return Fail("LuaCallback.TryCreate returned a callback after failure"); + } + + return AssertErrorThenRestoreSentinel(state, "LuaCallback.TryCreate"); + } + + private static int ProbeGeneratedFunctionAllocation(LuaState state) + { + LuaNativeFunction function = new(&NoOp); + if (WarmUpGeneratedFunction(state, function) != 0) + { + return 1; + } + + if (PushSentinel(state, "TryPushGeneratedFunction") != 0) + { + return 1; + } + + LuaStatus status; + Volatile.Write(ref s_rejectAllocations, 1); + try + { + try + { + status = LuaRuntime.TryPushGeneratedFunction(state, function); + } + catch (LuaException) + { + return Fail("TryPushGeneratedFunction converted the PushClosure failure into LuaException"); + } + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + + if (status != LuaStatus.MemoryError) + { + return Fail("TryPushGeneratedFunction did not return PushClosure's LUA_ERRMEM status"); + } + + if (AssertErrorThenRestoreSentinel(state, "TryPushGeneratedFunction") != 0) + { + return 1; + } + + WriteMarker("MARK TryPushGeneratedFunction PushClosure status, stack, and ownership recovered"); + + LuaStatus recovery = LuaRuntime.TryPushGeneratedFunction(state, function); + if (!recovery.IsOk) + { + return Fail("TryPushGeneratedFunction did not recover after PushClosure allocation failure"); + } + + state.Pop(1); + return state.Top == 0 + ? 0 + : Fail("TryPushGeneratedFunction recovery left values on the Lua stack"); + } + + private static int WarmUpGeneratedFunction(LuaState state, LuaNativeFunction function) + { + // Install the wrapper while allocation is permitted. The guarded closure below is then the only allocating + // operation, so a rejected allocation is guaranteed to exercise PushClosure rather than helper installation. + LuaStatus warmup = LuaRuntime.TryPushGeneratedFunction(state, function); + if (!warmup.IsOk) + { + return Fail("TryPushGeneratedFunction could not install its wrapper before allocation rejection"); + } + + state.Pop(1); + return state.Top == 0 + ? 0 + : Fail("TryPushGeneratedFunction warm-up left values on the Lua stack"); + } + + private static int ProbeFailingFinalizer(LuaState state, byte[] message) + { + if (PushSentinel(state, "failing __gc") != 0) + { + return 1; + } + + LuaStatus setup = state.TryExecute( + "collectgarbage('stop'); setmetatable({}, { __gc = function() error('expected finalizer failure') end }); collectgarbage('restart')"u8, + 0); + if (!setup.IsOk) + { + return Fail("could not install the failing finalizer: " + setup); + } + + for (int attempt = 0; attempt < 100_000; attempt++) + { + // Long strings are not interned, so each protected push gives the incremental collector work to do. + message[0] = (byte) (attempt & 0x7f); + LuaStatus status = state.TryPushString(message); + if (status == LuaStatus.GcMetamethodError) + { + return AssertErrorThenRestoreSentinel(state, "failing __gc"); + } + + if (!status.IsOk) + { + return Fail("string allocation returned an unexpected status while awaiting __gc: " + status); + } + + state.Pop(1); + } + + return Fail("the protected allocation path did not observe the failing __gc"); + } + + private static int ProbeHostObjectPusherLongJump(LuaState state, nint luaModule) + { + string bridgePath = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); + if (!File.Exists(bridgePath)) + { + return Fail("the native Lua bridge was not copied beside the failure probe"); + } + + IntPtr bridge = NativeLibrary.Load(bridgePath); + try + { + delegate* unmanaged[Cdecl] + protectedOperation = + (delegate* unmanaged[Cdecl]) + NativeLibrary + .GetExport( + bridge, + "cheatengine_sdk_lua_protected"); + IntPtr luaCheckInteger = NativeLibrary.GetExport(luaModule, "luaL_checkinteger"); + IntPtr* exports = stackalloc nint[ProtectedExportCount]; + PopulateProtectedExports(luaModule, exports); + return ProbeHostObjectPusherLongJump(state, protectedOperation, exports, luaCheckInteger); + } + finally + { + NativeLibrary.Free(bridge); + } + } + + private static int ProbeHostObjectPusherLongJump( + LuaState state, + delegate* unmanaged[Cdecl] + protectedOperation, + IntPtr* exports, + IntPtr luaCheckInteger) + { + // Windows x64 has one native calling convention. luaL_checkinteger is an actual native Lua helper that + // raises when its first C-function argument is absent. Passing nativeObject=1 selects that missing + // argument while inputCount=0 proves OP_PUSH_HOST_OBJECT reaches the pusher rather than failing its + // own input validation. No managed reverse-P/Invoke frame participates in the non-local exit. + if (PushSentinel(state, "PushHostObject") != 0) + { + return 1; + } + + int status = protectedOperation( + (lua_State*) state.Handle, + exports, + PushHostObjectOperation, + 0, + (void*) luaCheckInteger, + 0, + 1, + 0); + if (status != LuaApi.LUA_ERRRUN) + { + return Fail("PushHostObject did not return LUA_ERRRUN after a host-pusher longjmp"); + } + + if (!state.TryReadString(-1, out string? error) || + !error.Contains("bad argument #1", StringComparison.Ordinal)) + { + return Fail("PushHostObject did not leave the native luaL_checkinteger failure message on the stack"); + } + + if (AssertErrorThenRestoreSentinel(state, "PushHostObject") != 0) + { + return 1; + } + + WriteMarker("MARK PushHostObject native pusher longjmp observed"); + return 0; + } + + private static void PopulateProtectedExports(nint module, nint* exports) + { + exports[0] = NativeLibrary.GetExport(module, "lua_gettop"); + exports[1] = NativeLibrary.GetExport(module, "lua_settop"); + exports[2] = NativeLibrary.GetExport(module, "lua_checkstack"); + exports[3] = NativeLibrary.GetExport(module, "lua_rotate"); + exports[4] = NativeLibrary.GetExport(module, "lua_pushlstring"); + exports[5] = NativeLibrary.GetExport(module, "lua_pushinteger"); + exports[6] = NativeLibrary.GetExport(module, "lua_createtable"); + exports[7] = NativeLibrary.GetExport(module, "lua_newuserdata"); + exports[8] = NativeLibrary.GetExport(module, "lua_pushcclosure"); + exports[9] = NativeLibrary.GetExport(module, "lua_pushlightuserdata"); + exports[10] = NativeLibrary.GetExport(module, "lua_rawset"); + exports[11] = NativeLibrary.GetExport(module, "lua_rawseti"); + exports[12] = NativeLibrary.GetExport(module, "lua_rawsetp"); + exports[13] = NativeLibrary.GetExport(module, "lua_rawgetp"); + exports[14] = NativeLibrary.GetExport(module, "lua_rawgeti"); + exports[15] = NativeLibrary.GetExport(module, "lua_type"); + exports[16] = NativeLibrary.GetExport(module, "lua_pcallk"); + exports[17] = NativeLibrary.GetExport(module, "lua_error"); + exports[18] = NativeLibrary.GetExport(module, "luaL_ref"); + exports[19] = NativeLibrary.GetExport(module, "luaL_unref"); + } + + private static LuaStatus CaptureMemoryException(Action operation) + { + Volatile.Write(ref s_rejectAllocations, 1); + try + { + operation(); + return LuaStatus.Ok; + } + catch (LuaException exception) + { + return exception.Status; + } + finally + { + Volatile.Write(ref s_rejectAllocations, 0); + } + } + + private static int PushSentinel(LuaState state, string operation) + { + if (state.Top != 0) + { + return Fail(operation + " started with a non-empty Lua stack"); + } + + state.PushInteger(StackSentinel); + return state.Top == 1 ? 0 : Fail(operation + " could not establish its Lua stack sentinel"); + } + + private static int AssertErrorThenRestoreSentinel(LuaState state, string operation) + { + if (state.Top != 2) + { + return Fail(operation + " did not leave exactly one error above the pre-existing stack value"); + } + + state.Pop(1); + return AssertOnlySentinelRemains(state, operation); + } + + private static int AssertResultsThenRestoreSentinel(LuaState state, int resultCount, string operation) + { + if (state.Top != resultCount + 1) + { + return Fail(operation + " did not preserve its pre-existing stack value and leave its documented results"); + } + + state.Pop(resultCount); + return AssertOnlySentinelRemains(state, operation); + } + + private static int AssertTableThenRestoreSentinel(LuaState state, string operation) + { + if (state.Top != 2 || !state.IsTable(2)) + { + return Fail(operation + " did not preserve its table while consuming its failing inputs"); + } + + state.Pop(1); + return AssertOnlySentinelRemains(state, operation); + } + + private static int AssertOnlySentinelRemains(LuaState state, string operation) + { + if (state.Top != 1 || !state.TryReadInteger(1, out long value) || value != StackSentinel) + { + return Fail(operation + " did not restore the pre-existing Lua stack exactly"); + } + + state.Pop(1); + return state.Top == 0 ? 0 : Fail(operation + " left values on the Lua stack after restoration"); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int NoOp(nint _) + { + return 0; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* ProvideRuntimeState() + { + return s_runtimeState; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void* RejectingAllocator(void* _, void* pointer, nuint oldSize, nuint newSize) + { + if (newSize > oldSize && Volatile.Read(ref s_rejectAllocations) != 0) + { + return null; + } + + return s_originalAllocator(s_originalAllocatorData, pointer, oldSize, newSize); + } + + private static int Fail(string message) + { + Console.Error.WriteLine("FAIL " + message); + return 1; + } + + private static void WriteMarker(string marker) + { + Console.WriteLine(marker); + Console.Out.Flush(); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Constants/LuaConstantsTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Constants/LuaConstantsTests.cs index 289c9927..488c4125 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Constants/LuaConstantsTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Constants/LuaConstantsTests.cs @@ -5,130 +5,130 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Constants; /// DLL-free: the numeric contract of lua.h / lauxlib.h / luaconf.h for a default 64-bit Lua 5.3 build. public sealed class LuaConstantsTests { - [Fact] - public void RegistryIndex_default_build_is_minus_1001000() - { - // Merge gate: the pseudo-index is baked into the native library at compile time. - Assert.Equal(-1001000, LUA_REGISTRYINDEX); - Assert.Equal(1000000, LUAI_MAXSTACK); - Assert.Equal(LUA_REGISTRYINDEX, LUAI_FIRSTPSEUDOIDX); - } + [Fact] + public void RegistryIndex_default_build_is_minus_1001000() + { + // Merge gate: the pseudo-index is baked into the native library at compile time. + Assert.Equal(-1001000, LUA_REGISTRYINDEX); + Assert.Equal(1000000, LUAI_MAXSTACK); + Assert.Equal(LUA_REGISTRYINDEX, LUAI_FIRSTPSEUDOIDX); + } - [Fact] - public void Call_and_reference_markers_match_headers() - { - Assert.Equal(-1, LUA_MULTRET); - Assert.Equal(-2, LUA_NOREF); - Assert.Equal(-1, LUA_REFNIL); - Assert.Equal(20, LUA_MINSTACK); - Assert.Equal(503, LUA_VERSION_NUM); - Assert.Equal(136, LUAL_NUMSIZES); - } + [Fact] + public void Call_and_reference_markers_match_headers() + { + Assert.Equal(-1, LUA_MULTRET); + Assert.Equal(-2, LUA_NOREF); + Assert.Equal(-1, LUA_REFNIL); + Assert.Equal(20, LUA_MINSTACK); + Assert.Equal(503, LUA_VERSION_NUM); + Assert.Equal(136, LUAL_NUMSIZES); + } - [Fact] - public void Registry_slots_match_headers() - { - Assert.Equal(1, LUA_RIDX_MAINTHREAD); - Assert.Equal(2, LUA_RIDX_GLOBALS); - Assert.Equal(LUA_RIDX_GLOBALS, LUA_RIDX_LAST); - } + [Fact] + public void Registry_slots_match_headers() + { + Assert.Equal(1, LUA_RIDX_MAINTHREAD); + Assert.Equal(2, LUA_RIDX_GLOBALS); + Assert.Equal(LUA_RIDX_GLOBALS, LUA_RIDX_LAST); + } - [Fact] - public void Status_codes_match_headers() - { - Assert.Equal(0, LUA_OK); - Assert.Equal(1, LUA_YIELD); - Assert.Equal(2, LUA_ERRRUN); - Assert.Equal(3, LUA_ERRSYNTAX); - Assert.Equal(4, LUA_ERRMEM); - Assert.Equal(5, LUA_ERRGCMM); - Assert.Equal(6, LUA_ERRERR); - Assert.Equal(7, LUA_ERRFILE); - } + [Fact] + public void Status_codes_match_headers() + { + Assert.Equal(0, LUA_OK); + Assert.Equal(1, LUA_YIELD); + Assert.Equal(2, LUA_ERRRUN); + Assert.Equal(3, LUA_ERRSYNTAX); + Assert.Equal(4, LUA_ERRMEM); + Assert.Equal(5, LUA_ERRGCMM); + Assert.Equal(6, LUA_ERRERR); + Assert.Equal(7, LUA_ERRFILE); + } - [Fact] - public void Type_tags_match_headers() - { - Assert.Equal(-1, LUA_TNONE); - Assert.Equal(0, LUA_TNIL); - Assert.Equal(1, LUA_TBOOLEAN); - Assert.Equal(2, LUA_TLIGHTUSERDATA); - Assert.Equal(3, LUA_TNUMBER); - Assert.Equal(4, LUA_TSTRING); - Assert.Equal(5, LUA_TTABLE); - Assert.Equal(6, LUA_TFUNCTION); - Assert.Equal(7, LUA_TUSERDATA); - Assert.Equal(8, LUA_TTHREAD); - Assert.Equal(9, LUA_NUMTAGS); - } + [Fact] + public void Type_tags_match_headers() + { + Assert.Equal(-1, LUA_TNONE); + Assert.Equal(0, LUA_TNIL); + Assert.Equal(1, LUA_TBOOLEAN); + Assert.Equal(2, LUA_TLIGHTUSERDATA); + Assert.Equal(3, LUA_TNUMBER); + Assert.Equal(4, LUA_TSTRING); + Assert.Equal(5, LUA_TTABLE); + Assert.Equal(6, LUA_TFUNCTION); + Assert.Equal(7, LUA_TUSERDATA); + Assert.Equal(8, LUA_TTHREAD); + Assert.Equal(9, LUA_NUMTAGS); + } - [Fact] - public void Arithmetic_operators_match_headers() - { - int[] expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; - int[] actual = - [ - LUA_OPADD, LUA_OPSUB, LUA_OPMUL, LUA_OPMOD, LUA_OPPOW, LUA_OPDIV, LUA_OPIDIV, - LUA_OPBAND, LUA_OPBOR, LUA_OPBXOR, LUA_OPSHL, LUA_OPSHR, LUA_OPUNM, LUA_OPBNOT - ]; + [Fact] + public void Arithmetic_operators_match_headers() + { + int[] expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + int[] actual = + [ + LUA_OPADD, LUA_OPSUB, LUA_OPMUL, LUA_OPMOD, LUA_OPPOW, LUA_OPDIV, LUA_OPIDIV, + LUA_OPBAND, LUA_OPBOR, LUA_OPBXOR, LUA_OPSHL, LUA_OPSHR, LUA_OPUNM, LUA_OPBNOT + ]; - Assert.Equal(expected, actual); - } + Assert.Equal(expected, actual); + } - [Fact] - public void Comparison_operators_match_headers() - { - Assert.Equal(0, LUA_OPEQ); - Assert.Equal(1, LUA_OPLT); - Assert.Equal(2, LUA_OPLE); - } + [Fact] + public void Comparison_operators_match_headers() + { + Assert.Equal(0, LUA_OPEQ); + Assert.Equal(1, LUA_OPLT); + Assert.Equal(2, LUA_OPLE); + } - [Fact] - public void Gc_commands_match_headers() - { - int[] expected = [0, 1, 2, 3, 4, 5, 6, 7, 9]; - int[] actual = - [ - LUA_GCSTOP, LUA_GCRESTART, LUA_GCCOLLECT, LUA_GCCOUNT, LUA_GCCOUNTB, - LUA_GCSTEP, LUA_GCSETPAUSE, LUA_GCSETSTEPMUL, LUA_GCISRUNNING - ]; + [Fact] + public void Gc_commands_match_headers() + { + int[] expected = [0, 1, 2, 3, 4, 5, 6, 7, 9]; + int[] actual = + [ + LUA_GCSTOP, LUA_GCRESTART, LUA_GCCOLLECT, LUA_GCCOUNT, LUA_GCCOUNTB, + LUA_GCSTEP, LUA_GCSETPAUSE, LUA_GCSETSTEPMUL, LUA_GCISRUNNING + ]; - Assert.Equal(expected, actual); - } + Assert.Equal(expected, actual); + } - [Fact] - public void Hook_events_and_masks_match_headers() - { - Assert.Equal(0, LUA_HOOKCALL); - Assert.Equal(1, LUA_HOOKRET); - Assert.Equal(2, LUA_HOOKLINE); - Assert.Equal(3, LUA_HOOKCOUNT); - Assert.Equal(4, LUA_HOOKTAILCALL); - Assert.Equal(1, LUA_MASKCALL); - Assert.Equal(2, LUA_MASKRET); - Assert.Equal(4, LUA_MASKLINE); - Assert.Equal(8, LUA_MASKCOUNT); - } + [Fact] + public void Hook_events_and_masks_match_headers() + { + Assert.Equal(0, LUA_HOOKCALL); + Assert.Equal(1, LUA_HOOKRET); + Assert.Equal(2, LUA_HOOKLINE); + Assert.Equal(3, LUA_HOOKCOUNT); + Assert.Equal(4, LUA_HOOKTAILCALL); + Assert.Equal(1, LUA_MASKCALL); + Assert.Equal(2, LUA_MASKRET); + Assert.Equal(4, LUA_MASKLINE); + Assert.Equal(8, LUA_MASKCOUNT); + } - [Fact] - public void Integer_limits_are_64_bit() - { - Assert.Equal(long.MaxValue, LUA_MAXINTEGER); - Assert.Equal(long.MinValue, LUA_MININTEGER); - } + [Fact] + public void Integer_limits_are_64_bit() + { + Assert.Equal(long.MaxValue, LUA_MAXINTEGER); + Assert.Equal(long.MinValue, LUA_MININTEGER); + } - [Fact] - public void Byte_string_constants_match_headers() - { - Assert.True(LUA_SIGNATURE.SequenceEqual((ReadOnlySpan)[0x1B, (byte)'L', (byte)'u', (byte)'a'])); - Assert.True(LUA_COLIBNAME.SequenceEqual("coroutine"u8)); - Assert.True(LUA_TABLIBNAME.SequenceEqual("table"u8)); - Assert.True(LUA_IOLIBNAME.SequenceEqual("io"u8)); - Assert.True(LUA_OSLIBNAME.SequenceEqual("os"u8)); - Assert.True(LUA_STRLIBNAME.SequenceEqual("string"u8)); - Assert.True(LUA_UTF8LIBNAME.SequenceEqual("utf8"u8)); - Assert.True(LUA_MATHLIBNAME.SequenceEqual("math"u8)); - Assert.True(LUA_DBLIBNAME.SequenceEqual("debug"u8)); - Assert.True(LUA_LOADLIBNAME.SequenceEqual("package"u8)); - } + [Fact] + public void Byte_string_constants_match_headers() + { + Assert.True(LUA_SIGNATURE.SequenceEqual((ReadOnlySpan) [0x1B, (byte) 'L', (byte) 'u', (byte) 'a'])); + Assert.True(LUA_COLIBNAME.SequenceEqual("coroutine"u8)); + Assert.True(LUA_TABLIBNAME.SequenceEqual("table"u8)); + Assert.True(LUA_IOLIBNAME.SequenceEqual("io"u8)); + Assert.True(LUA_OSLIBNAME.SequenceEqual("os"u8)); + Assert.True(LUA_STRLIBNAME.SequenceEqual("string"u8)); + Assert.True(LUA_UTF8LIBNAME.SequenceEqual("utf8"u8)); + Assert.True(LUA_MATHLIBNAME.SequenceEqual("math"u8)); + Assert.True(LUA_DBLIBNAME.SequenceEqual("debug"u8)); + Assert.True(LUA_LOADLIBNAME.SequenceEqual("package"u8)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/BundledLuaLibraryTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/BundledLuaLibraryTests.cs index c575fcf0..ae57f67a 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/BundledLuaLibraryTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/BundledLuaLibraryTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; + using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -11,28 +12,28 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Fixture; /// public sealed class BundledLuaLibraryTests { - // Cheat Engine 7.7, x64. - private const long ExpectedLength = 539496; - private const string ExpectedSha256 = "C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2"; + // Cheat Engine 7.7, x64. + private const long ExpectedLength = 539496; + private const string ExpectedSha256 = "C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2"; - [Fact] - public void Bundled_copy_is_the_recorded_Cheat_Engine_build() - { - var path = NativeLuaLibrary.BundledPath; + [Fact] + public void Bundled_copy_is_the_recorded_Cheat_Engine_build() + { + string path = NativeLuaLibrary.BundledPath; - Assert.True(File.Exists(path), $"The build did not copy '{path}' next to the test executable."); - Assert.Equal(ExpectedLength, new FileInfo(path).Length); - Assert.Equal(ExpectedSha256, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))); - } + Assert.True(File.Exists(path), $"The build did not copy '{path}' next to the test executable."); + Assert.Equal(ExpectedLength, new FileInfo(path).Length); + Assert.Equal(ExpectedSha256, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))); + } - [Fact] - [Trait("Category", "NativeLua")] - public void Fixture_binds_the_bundled_copy_unless_the_variable_overrides_it() - { - LuaTest.RequireNativeLua(); - Assert.SkipWhen(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NativeLuaLibrary.PathVariable)), - $"{NativeLuaLibrary.PathVariable} overrides the bundled copy."); + [Fact] + [Trait("Category", "NativeLua")] + public void Fixture_binds_the_bundled_copy_unless_the_variable_overrides_it() + { + LuaTest.RequireNativeLua(); + Assert.SkipWhen(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NativeLuaLibrary.PathVariable)), + $"{NativeLuaLibrary.PathVariable} overrides the bundled copy."); - Assert.Equal(Path.GetFullPath(NativeLuaLibrary.BundledPath), NativeLuaLibrary.LibraryPath); - } + Assert.Equal(Path.GetFullPath(NativeLuaLibrary.BundledPath), NativeLuaLibrary.LibraryPath); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/NativeLuaProbeTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/NativeLuaProbeTests.cs index 3ced1468..2e4688c3 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/NativeLuaProbeTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/NativeLuaProbeTests.cs @@ -9,41 +9,41 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Fixture; /// public sealed class NativeLuaProbeTests { - [Fact] - public void Relative_configured_path_is_made_absolute_before_it_is_checked_and_reported() - { - var relative = Path.Combine("cheatengine-sdk-no-such-directory", "lua53.dll"); - - var probe = NativeLuaProbe.Run(" " + relative + " "); - - // File.Exists resolves a relative path against the current directory, the native loader by its own search - // rules: the probe must settle on one absolute path first, and that is the path the reason has to name. - Assert.Equal(0, probe.Handle); - Assert.Null(probe.LibraryPath); - Assert.Contains("'" + Path.Combine(Environment.CurrentDirectory, relative) + "'", probe.Reason, - StringComparison.Ordinal); - Assert.Contains(NativeLuaLibrary.PathVariable, probe.Reason, StringComparison.Ordinal); - } - - [Fact] - public void Invalid_configured_path_becomes_a_reason_not_an_exception() - { - var probe = NativeLuaProbe.Run("lua\0.dll"); - - Assert.Equal(0, probe.Handle); - Assert.Null(probe.LibraryPath); - Assert.Contains("not a valid path", probe.Reason, StringComparison.Ordinal); - } - - [Fact] - public void Missing_absolute_path_is_reported_with_its_origin_and_never_falls_back() - { - var missing = Path.Combine(Path.GetTempPath(), "cheatengine-sdk-no-such-directory", "lua53.dll"); - - var probe = NativeLuaProbe.Run(missing); - - Assert.Equal(0, probe.Handle); - Assert.Contains("'" + missing + "'", probe.Reason, StringComparison.Ordinal); - Assert.Contains("does not exist", probe.Reason, StringComparison.Ordinal); - } + [Fact] + public void Relative_configured_path_is_made_absolute_before_it_is_checked_and_reported() + { + string relative = Path.Combine("cheatengine-sdk-no-such-directory", "lua53.dll"); + + NativeLuaProbe probe = NativeLuaProbe.Run(" " + relative + " "); + + // File.Exists resolves a relative path against the current directory, the native loader by its own search + // rules: the probe must settle on one absolute path first, and that is the path the reason has to name. + Assert.Equal(0, probe.Handle); + Assert.Null(probe.LibraryPath); + Assert.Contains("'" + Path.Combine(Environment.CurrentDirectory, relative) + "'", probe.Reason, + StringComparison.Ordinal); + Assert.Contains(NativeLuaLibrary.PathVariable, probe.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Invalid_configured_path_becomes_a_reason_not_an_exception() + { + NativeLuaProbe probe = NativeLuaProbe.Run("lua\0.dll"); + + Assert.Equal(0, probe.Handle); + Assert.Null(probe.LibraryPath); + Assert.Contains("not a valid path", probe.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Missing_absolute_path_is_reported_with_its_origin_and_never_falls_back() + { + string missing = Path.Combine(Path.GetTempPath(), "cheatengine-sdk-no-such-directory", "lua53.dll"); + + NativeLuaProbe probe = NativeLuaProbe.Run(missing); + + Assert.Equal(0, probe.Handle); + Assert.Contains("'" + missing + "'", probe.Reason, StringComparison.Ordinal); + Assert.Contains("does not exist", probe.Reason, StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs index a3b4800e..9a5a420c 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -10,82 +11,83 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Initialization; [Trait("Category", "NativeLua")] public sealed class LuaApiBoundTableTests { - [Fact] - public void Fixture_binds_the_table_to_its_module() - { - LuaTest.RequireNativeLua(); + [Fact] + public void Fixture_binds_the_table_to_its_module() + { + LuaTest.RequireNativeLua(); - Assert.True(LuaApi.IsInitialized); - Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); - Assert.Empty(LuaApi.GetMissingExports(NativeLuaLibrary.Handle)); - } + Assert.True(LuaApi.IsInitialized); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + Assert.Empty(LuaApi.GetMissingExports(NativeLuaLibrary.Handle)); + } - [Fact] - public void Initialize_same_module_again_is_a_no_op() - { - LuaTest.RequireNativeLua(); + [Fact] + public void Initialize_same_module_again_is_a_no_op() + { + LuaTest.RequireNativeLua(); - LuaApi.Initialize(NativeLuaLibrary.Handle); - Assert.True(LuaApi.TryInitialize(NativeLuaLibrary.Handle, out var failure)); + LuaApi.Initialize(NativeLuaLibrary.Handle); + Assert.True(LuaApi.TryInitialize(NativeLuaLibrary.Handle, out string? failure)); - Assert.Null(failure); - Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); - } + Assert.Null(failure); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + } - [Fact] - public void Every_slot_holds_an_address_inside_the_process() - { - LuaTest.RequireNativeLua(); + [Fact] + public void Every_slot_holds_an_address_inside_the_process() + { + LuaTest.RequireNativeLua(); - var table = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; - var slots = typeof(LuaApi.Table).GetFields(BindingFlags.Instance | BindingFlags.Public | - BindingFlags.NonPublic); + object table = + typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + FieldInfo[] slots = typeof(LuaApi.Table).GetFields(BindingFlags.Instance | BindingFlags.Public | + BindingFlags.NonPublic); - Assert.NotEmpty(slots); - foreach (var slot in slots) - { - // Reflection hands a function-pointer field back as a boxed IntPtr. - var address = Assert.IsType(slot.GetValue(table)); - Assert.True(address != 0, slot.Name); - Assert.True(NativeLibrary.TryGetExport(NativeLuaLibrary.Handle, slot.Name, out var export), slot.Name); - Assert.Equal(export, address); - } - } + Assert.NotEmpty(slots); + foreach (FieldInfo slot in slots) + { + // Reflection hands a function-pointer field back as a boxed IntPtr. + IntPtr address = Assert.IsType(slot.GetValue(table)); + Assert.True(address != 0, slot.Name); + Assert.True(NativeLibrary.TryGetExport(NativeLuaLibrary.Handle, slot.Name, out IntPtr export), slot.Name); + Assert.Equal(export, address); + } + } - [Fact] - public void Second_copy_of_the_library_is_refused_and_leaves_the_table_alone() - { - LuaTest.RequireNativeLua(); - var copy = LoadSecondCopy(); + [Fact] + public void Second_copy_of_the_library_is_refused_and_leaves_the_table_alone() + { + LuaTest.RequireNativeLua(); + IntPtr copy = LoadSecondCopy(); - var bound = LuaApi.TryInitialize(copy, out var failure); - var exception = Assert.Throws(() => LuaApi.Initialize(copy)); + bool bound = LuaApi.TryInitialize(copy, out string? failure); + InvalidOperationException exception = Assert.Throws(() => LuaApi.Initialize(copy)); - Assert.False(bound); - Assert.Contains("already bound", failure, StringComparison.Ordinal); - Assert.Contains("already bound", exception.Message, StringComparison.Ordinal); - Assert.Empty(LuaApi.GetMissingExports(copy)); - Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); - } + Assert.False(bound); + Assert.Contains("already bound", failure, StringComparison.Ordinal); + Assert.Contains("already bound", exception.Message, StringComparison.Ordinal); + Assert.Empty(LuaApi.GetMissingExports(copy)); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + } - /// Maps the same DLL a second time under another file name, which the loader treats as a different module. - private static nint LoadSecondCopy() - { - var directory = Path.Combine(Path.GetTempPath(), "CheatEngine.SDK.Lua.Interop.Tests"); - var path = Path.Combine(directory, "lua53-second-copy.dll"); - Directory.CreateDirectory(directory); - try - { - File.Copy(NativeLuaLibrary.LibraryPath!, path, true); - } - catch (IOException) when (File.Exists(path)) - { - // Another test process has the copy mapped: it is the same file, use it as it is. - } + /// Maps the same DLL a second time under another file name, which the loader treats as a different module. + private static nint LoadSecondCopy() + { + string directory = Path.Combine(Path.GetTempPath(), "CheatEngine.SDK.Lua.Interop.Tests"); + string path = Path.Combine(directory, "lua53-second-copy.dll"); + Directory.CreateDirectory(directory); + try + { + File.Copy(NativeLuaLibrary.LibraryPath!, path, true); + } + catch (IOException) when (File.Exists(path)) + { + // Another test process has the copy mapped: it is the same file, use it as it is. + } - Assert.SkipUnless(NativeLibrary.TryLoad(path, out var copy), - "The second copy of the Lua library could not be loaded from " + path); - Assert.NotEqual(NativeLuaLibrary.Handle, copy); - return copy; - } + Assert.SkipUnless(NativeLibrary.TryLoad(path, out IntPtr copy), + "The second copy of the Lua library could not be loaded from " + path); + Assert.NotEqual(NativeLuaLibrary.Handle, copy); + return copy; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiInitializationTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiInitializationTests.cs index a34fd603..81b075a7 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiInitializationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiInitializationTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Api; namespace CheatEngine.SDK.Lua.Interop.Tests.Initialization; @@ -11,59 +12,60 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Initialization; /// public sealed class LuaApiInitializationTests { - private static nint NotLua => NativeLibrary.GetMainProgramHandle(); + private static nint NotLua => NativeLibrary.GetMainProgramHandle(); - private static int SlotCount - => typeof(LuaApi.Table).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Length; + private static int SlotCount + => typeof(LuaApi.Table).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Length; - [Fact] - public void Initialize_zero_handle_throws_argument_exception() - { - var exception = Assert.Throws(static () => LuaApi.Initialize(0)); + [Fact] + public void Initialize_zero_handle_throws_argument_exception() + { + ArgumentException exception = Assert.Throws(static () => LuaApi.Initialize(0)); - Assert.Equal("moduleHandle", exception.ParamName); - } + Assert.Equal("moduleHandle", exception.ParamName); + } - [Fact] - public void TryInitialize_zero_handle_returns_false_with_reason() - { - Assert.False(LuaApi.TryInitialize(0, out var failure)); - Assert.Contains("zero", failure, StringComparison.Ordinal); - } + [Fact] + public void TryInitialize_zero_handle_returns_false_with_reason() + { + Assert.False(LuaApi.TryInitialize(0, out string? failure)); + Assert.Contains("zero", failure, StringComparison.Ordinal); + } - [Fact] - public void GetMissingExports_zero_handle_throws_argument_exception() - { - Assert.Throws(static () => LuaApi.GetMissingExports(0)); - } + [Fact] + public void GetMissingExports_zero_handle_throws_argument_exception() + { + Assert.Throws(static () => LuaApi.GetMissingExports(0)); + } - [Fact] - public void GetMissingExports_module_without_lua_lists_every_slot_once() - { - var missing = LuaApi.GetMissingExports(NotLua); + [Fact] + public void GetMissingExports_module_without_lua_lists_every_slot_once() + { + IReadOnlyList missing = LuaApi.GetMissingExports(NotLua); - Assert.Equal(SlotCount, missing.Count); - Assert.Equal(missing.Count, missing.Distinct(StringComparer.Ordinal).Count()); - Assert.Contains("lua_pcallk", missing, StringComparer.Ordinal); - Assert.Contains("luaL_ref", missing, StringComparer.Ordinal); - Assert.Contains("luaopen_base", missing, StringComparer.Ordinal); - } + Assert.Equal(SlotCount, missing.Count); + Assert.Equal(missing.Count, missing.Distinct(StringComparer.Ordinal).Count()); + Assert.Contains("lua_pcallk", missing, StringComparer.Ordinal); + Assert.Contains("luaL_ref", missing, StringComparer.Ordinal); + Assert.Contains("luaopen_base", missing, StringComparer.Ordinal); + } - [Fact] - public void Initialize_module_without_lua_throws_naming_the_missing_exports() - { - var exception = Assert.Throws(() => LuaApi.Initialize(NotLua)); + [Fact] + public void Initialize_module_without_lua_throws_naming_the_missing_exports() + { + EntryPointNotFoundException exception = + Assert.Throws(() => LuaApi.Initialize(NotLua)); - Assert.Contains("lua_gettop", exception.Message, StringComparison.Ordinal); - Assert.Contains("lua_pcallk", exception.Message, StringComparison.Ordinal); - } + Assert.Contains("lua_gettop", exception.Message, StringComparison.Ordinal); + Assert.Contains("lua_pcallk", exception.Message, StringComparison.Ordinal); + } - [Fact] - public void TryInitialize_module_without_lua_returns_false_and_stays_unbound_to_it() - { - Assert.False(LuaApi.TryInitialize(NotLua, out var failure)); + [Fact] + public void TryInitialize_module_without_lua_returns_false_and_stays_unbound_to_it() + { + Assert.False(LuaApi.TryInitialize(NotLua, out string? failure)); - Assert.Contains("lua_gettop", failure, StringComparison.Ordinal); - Assert.NotEqual(NotLua, LuaApi.ModuleHandle); - } + Assert.Contains("lua_gettop", failure, StringComparison.Ordinal); + Assert.NotEqual(NotLua, LuaApi.ModuleHandle); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs index 53a46129..512dbe25 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Loading; using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -7,67 +8,67 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Loading; public sealed class LuaModuleTests { - private const string NeverLoaded = "cheatengine-sdk-module-that-is-not-loaded-5f1c.dll"; + private const string NeverLoaded = "cheatengine-sdk-module-that-is-not-loaded-5f1c.dll"; - [Fact] - public void Cheat_engine_module_name_is_the_64_bit_lua53_dll() - { - Assert.Equal("lua53-64.dll", LuaModule.CheatEngine64ModuleName); - } + [Fact] + public void Cheat_engine_module_name_is_the_64_bit_lua53_dll() + { + Assert.Equal("lua53-64.dll", LuaModule.CheatEngine64ModuleName); + } - [Fact] - public void TryGetLoaded_null_name_throws() - { - Assert.Throws(static () => LuaModule.TryGetLoaded(null!, out _)); - } + [Fact] + public void TryGetLoaded_null_name_throws() + { + Assert.Throws(static () => LuaModule.TryGetLoaded(null!, out _)); + } - [Fact] - public void TryGetLoaded_empty_name_throws() - { - Assert.Throws(static () => LuaModule.TryGetLoaded(string.Empty, out _)); - } + [Fact] + public void TryGetLoaded_empty_name_throws() + { + Assert.Throws(static () => LuaModule.TryGetLoaded(string.Empty, out _)); + } - [Fact] - public void TryGetLoaded_unknown_module_returns_false_and_loads_nothing() - { - Assert.False(LuaModule.TryGetLoaded(NeverLoaded, out var handle)); - Assert.Equal(0, handle); + [Fact] + public void TryGetLoaded_unknown_module_returns_false_and_loads_nothing() + { + Assert.False(LuaModule.TryGetLoaded(NeverLoaded, out IntPtr handle)); + Assert.Equal(0, handle); - // Still not there afterwards: the lookup must not have gone through the DLL search path. - Assert.False(LuaModule.TryGetLoaded(NeverLoaded, out _)); - } + // Still not there afterwards: the lookup must not have gone through the DLL search path. + Assert.False(LuaModule.TryGetLoaded(NeverLoaded, out _)); + } - [Fact] - public void TryGetLoaded_module_every_windows_process_has_returns_the_loader_handle() - { - Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); + [Fact] + public void TryGetLoaded_module_every_windows_process_has_returns_the_loader_handle() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); - Assert.True(LuaModule.TryGetLoaded("kernel32.dll", out var handle)); - Assert.Equal(NativeLibrary.Load("kernel32.dll"), handle); - } + Assert.True(LuaModule.TryGetLoaded("kernel32.dll", out IntPtr handle)); + Assert.Equal(NativeLibrary.Load("kernel32.dll"), handle); + } - [Fact] - public void TryGetLoaded_default_name_agrees_with_the_explicit_name() - { - var byDefault = LuaModule.TryGetLoaded(out var defaultHandle); - var byName = LuaModule.TryGetLoaded(LuaModule.CheatEngine64ModuleName, out var namedHandle); + [Fact] + public void TryGetLoaded_default_name_agrees_with_the_explicit_name() + { + bool byDefault = LuaModule.TryGetLoaded(out IntPtr defaultHandle); + bool byName = LuaModule.TryGetLoaded(LuaModule.CheatEngine64ModuleName, out IntPtr namedHandle); - Assert.Equal(byName, byDefault); - Assert.Equal(namedHandle, defaultHandle); - } + Assert.Equal(byName, byDefault); + Assert.Equal(namedHandle, defaultHandle); + } - [Fact] - [Trait("Category", "NativeLua")] - public void TryGetLoaded_finds_the_lua_module_the_fixture_loaded_without_loading_another() - { - LuaTest.RequireNativeLua(); - Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); + [Fact] + [Trait("Category", "NativeLua")] + public void TryGetLoaded_finds_the_lua_module_the_fixture_loaded_without_loading_another() + { + LuaTest.RequireNativeLua(); + Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); - var fileName = Path.GetFileName(NativeLuaLibrary.LibraryPath!); + string fileName = Path.GetFileName(NativeLuaLibrary.LibraryPath!); - Assert.True(LuaModule.TryGetLoaded(fileName, out var byName)); - Assert.True(LuaModule.TryGetLoaded(NativeLuaLibrary.LibraryPath!, out var byPath)); - Assert.Equal(NativeLuaLibrary.Handle, byName); - Assert.Equal(NativeLuaLibrary.Handle, byPath); - } + Assert.True(LuaModule.TryGetLoaded(fileName, out IntPtr byName)); + Assert.True(LuaModule.TryGetLoaded(NativeLuaLibrary.LibraryPath!, out IntPtr byPath)); + Assert.Equal(NativeLuaLibrary.Handle, byName); + Assert.Equal(NativeLuaLibrary.Handle, byPath); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Macros/PureMacroTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Macros/PureMacroTests.cs index 4b4a6858..e810862e 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Macros/PureMacroTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Macros/PureMacroTests.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.Lua.Interop.Types; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.Macros; @@ -6,21 +7,21 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Macros; /// DLL-free: the macro equivalents that are plain arithmetic. public sealed unsafe class PureMacroTests { - [Theory] - [InlineData(1, -1001001)] - [InlineData(2, -1001002)] - [InlineData(255, -1001255)] - public void Upvalueindex_counts_down_from_the_registry_index(int upvalue, int expected) - { - Assert.Equal(expected, lua_upvalueindex(upvalue)); - } + [Theory] + [InlineData(1, -1001001)] + [InlineData(2, -1001002)] + [InlineData(255, -1001255)] + public void Upvalueindex_counts_down_from_the_registry_index(int upvalue, int expected) + { + Assert.Equal(expected, lua_upvalueindex(upvalue)); + } - [Fact] - public void Getextraspace_is_one_pointer_in_front_of_the_state() - { - var state = (lua_State*)0x10000; + [Fact] + public void Getextraspace_is_one_pointer_in_front_of_the_state() + { + lua_State* state = (lua_State*) 0x10000; - Assert.Equal(sizeof(nint), LUA_EXTRASPACE); - Assert.Equal((nint)0x10000 - sizeof(nint), (nint)lua_getextraspace(state)); - } + Assert.Equal(sizeof(nint), LUA_EXTRASPACE); + Assert.Equal((nint) 0x10000 - sizeof(nint), (nint) lua_getextraspace(state)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs index f96257cb..85143eba 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs @@ -11,224 +11,247 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; /// Verifies that the C11 bridge rejects every contract buffer shape except its exact ABI layout. public sealed unsafe class LuaBridgeContractBoundaryTests { - private const uint ContractMagic = 0x4345534B; - private const int NoErrorStatus = -100; - private const int ProtectedExportCount = 20; - private const int PushByteTableOperation = 11; - private static nint s_forwardedPCall; - private static int s_pcallCallCount; - - private static readonly string[] SProtectedExportNames = - [ - "lua_gettop", "lua_settop", "lua_checkstack", "lua_rotate", "lua_pushlstring", - "lua_pushinteger", "lua_createtable", "lua_newuserdata", "lua_pushcclosure", - "lua_pushlightuserdata", "lua_rawset", "lua_rawseti", "lua_rawsetp", "lua_rawgetp", - "lua_rawgeti", "lua_type", "lua_pcallk", "lua_error", "luaL_ref", "luaL_unref", - ]; - - [Fact] - public void Native_bridge_contract_rejects_invalid_buffers_and_zeroes_reserved_and_padding_bytes() - { - var path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); - Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); - - var module = NativeLibrary.Load(path); - try - { - var getContract = (delegate* unmanaged[Cdecl])NativeLibrary.GetExport( - module, - "cheatengine_sdk_lua_bridge_get_contract"); - var abiVersion = (delegate* unmanaged[Cdecl])NativeLibrary.GetExport( - module, - "cheatengine_sdk_lua_bridge_abi_version"); - var size = (nuint)Unsafe.SizeOf(); - LuaBridgeContract contract = default; - - Assert.Equal(1u, abiVersion()); - Assert.Equal(0, getContract(null, size)); - - FillWithSentinel(&contract); - Assert.Equal(0, getContract(&contract, size - 1)); - AssertAllBytesAreSentinel(&contract); - - Assert.Equal(0, getContract(&contract, size + 1)); - AssertAllBytesAreSentinel(&contract); - - Assert.Equal(1, getContract(&contract, size)); - Assert.Equal(ContractMagic, contract.Magic); - Assert.Equal((uint)size, contract.ContractSize); - AssertReservedAndPaddingAreZero(&contract); - } - finally - { - NativeLibrary.Free(module); - } - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Native_bridge_rejects_invalid_preconditions_without_mutating_the_Lua_stack() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var luaState = state.L; - LuaApi.lua_pushinteger(luaState, 0x1CEB_00DA_5EED_1234); - var top = LuaApi.lua_gettop(luaState); - - var path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); - Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); - - var module = NativeLibrary.Load(path); - try - { - var protectedOperation = - (delegate* unmanaged[Cdecl])NativeLibrary - .GetExport( - module, - "cheatengine_sdk_lua_protected"); - var completeExports = stackalloc nint[ProtectedExportCount]; - PopulateProtectedExports(LuaApi.ModuleHandle, completeExports); - var incompleteExports = stackalloc nint[ProtectedExportCount]; - for (var i = 0; i < ProtectedExportCount; i++) incompleteExports[i] = completeExports[i]; - incompleteExports[17] = 0; // lua_error is required before the bridge can enter lua_pcallk. - - Assert.Equal(NoErrorStatus, protectedOperation(luaState, incompleteExports, 0, 0, null, 0, 0, 0)); - AssertStackIsUnchanged(luaState, top); - - Assert.Equal(NoErrorStatus, protectedOperation(luaState, completeExports, 0, -1, null, 0, 0, 0)); - AssertStackIsUnchanged(luaState, top); - - Assert.Equal(NoErrorStatus, protectedOperation(luaState, completeExports, 0, top + 1, null, 0, 0, 0)); - AssertStackIsUnchanged(luaState, top); - } - finally - { - NativeLibrary.Free(module); - } - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Native_bridge_builds_a_page_sized_byte_table_with_one_protected_call() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var luaState = state.L; - var bytes = new byte[4096]; - for (var index = 0; index < bytes.Length; index++) bytes[index] = (byte)index; - - var path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); - Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); - - var module = NativeLibrary.Load(path); - try - { - var protectedOperation = - (delegate* unmanaged[Cdecl])NativeLibrary - .GetExport( - module, - "cheatengine_sdk_lua_protected"); - var exports = stackalloc nint[ProtectedExportCount]; - PopulateProtectedExports(LuaApi.ModuleHandle, exports); - s_forwardedPCall = exports[16]; - s_pcallCallCount = 0; - exports[16] = (nint)(delegate* unmanaged[Cdecl])&CountPCall; - - fixed (byte* data = bytes) - { - Assert.Equal(LuaApi.LUA_OK, protectedOperation( - luaState, - exports, - PushByteTableOperation, - 0, - data, - (nuint)bytes.Length, - 0, - 0)); - } - - Assert.Equal(1, s_pcallCallCount); - Assert.Equal(1, LuaApi.lua_gettop(luaState)); - Assert.Equal((nuint)bytes.Length, LuaApi.lua_rawlen(luaState, -1)); - for (var index = 0; index < bytes.Length; index++) - { - Assert.Equal(LuaApi.LUA_TNUMBER, LuaApi.lua_rawgeti(luaState, -1, index + 1L)); - Assert.Equal(bytes[index], LuaApi.lua_tointeger(luaState, -1)); - LuaApi.lua_settop(luaState, -2); - } - - Assert.Equal(1, LuaApi.lua_gettop(luaState)); - LuaApi.lua_settop(luaState, 0); - } - finally - { - s_forwardedPCall = 0; - s_pcallCallCount = 0; - NativeLibrary.Free(module); - } - } - - private static void PopulateProtectedExports(nint luaModule, nint* exports) - { - Assert.Equal(ProtectedExportCount, SProtectedExportNames.Length); - for (var i = 0; i < SProtectedExportNames.Length; i++) - exports[i] = NativeLibrary.GetExport(luaModule, SProtectedExportNames[i]); - } - - private static void AssertStackIsUnchanged(lua_State* luaState, int expectedTop) - { - Assert.Equal(expectedTop, LuaApi.lua_gettop(luaState)); - Assert.Equal(0x1CEB_00DA_5EED_1234, LuaApi.lua_tointeger(luaState, -1)); - } - - private static void FillWithSentinel(LuaBridgeContract* contract) - { - Span bytes = new(contract, Unsafe.SizeOf()); - bytes.Fill(0xA5); - } - - private static void AssertAllBytesAreSentinel(LuaBridgeContract* contract) - { - ReadOnlySpan bytes = new(contract, Unsafe.SizeOf()); - for (var i = 0; i < bytes.Length; i++) - Assert.Equal((byte)0xA5, bytes[i]); - } - - private static void AssertReservedAndPaddingAreZero(LuaBridgeContract* contract) - { - ReadOnlySpan bytes = new(contract, Unsafe.SizeOf()); - var reservedOffset = Marshal.OffsetOf(nameof(LuaBridgeContract.Reserved)).ToInt32(); - Assert.Equal(4, bytes.Length - reservedOffset - sizeof(byte)); - for (var index = reservedOffset; index < bytes.Length; index++) - Assert.Equal((byte)0, bytes[index]); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int CountPCall(lua_State* luaState, int argumentCount, int resultCount, int errorFunction, - nint context, nint continuation) - { - s_pcallCallCount++; - return ((delegate* unmanaged[Cdecl])s_forwardedPCall)( - luaState, - argumentCount, - resultCount, - errorFunction, - context, - continuation); - } - - [StructLayout(LayoutKind.Sequential)] - private struct LuaBridgeContract - { - internal uint Magic; - internal uint ContractSize; - internal ulong SupportedOperations; - internal uint ExportTableSize; - internal ushort AbiMajor; - internal ushort AbiMinor; - internal byte PointerSize; - internal byte LuaIntegerSize; - internal byte SizeTSize; - internal byte Reserved; - } + private const uint ContractMagic = 0x4345534B; + private const int NoErrorStatus = -100; + private const int ProtectedExportCount = 20; + private const int PushByteTableOperation = 11; + private static nint s_forwardedPCall; + private static int s_pcallCallCount; + + private static readonly string[] SProtectedExportNames = + [ + "lua_gettop", "lua_settop", "lua_checkstack", "lua_rotate", "lua_pushlstring", + "lua_pushinteger", "lua_createtable", "lua_newuserdata", "lua_pushcclosure", + "lua_pushlightuserdata", "lua_rawset", "lua_rawseti", "lua_rawsetp", "lua_rawgetp", + "lua_rawgeti", "lua_type", "lua_pcallk", "lua_error", "luaL_ref", "luaL_unref" + ]; + + [Fact] + public void Native_bridge_contract_rejects_invalid_buffers_and_zeroes_reserved_and_padding_bytes() + { + string path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); + Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); + + IntPtr module = NativeLibrary.Load(path); + try + { + delegate* unmanaged[Cdecl] getContract = + (delegate* unmanaged[Cdecl]) NativeLibrary.GetExport( + module, + "cheatengine_sdk_lua_bridge_get_contract"); + delegate* unmanaged[Cdecl] abiVersion = (delegate* unmanaged[Cdecl]) NativeLibrary.GetExport( + module, + "cheatengine_sdk_lua_bridge_abi_version"); + UIntPtr size = (nuint) Unsafe.SizeOf(); + LuaBridgeContract contract = default; + + Assert.Equal(1u, abiVersion()); + Assert.Equal(0, getContract(null, size)); + + FillWithSentinel(&contract); + Assert.Equal(0, getContract(&contract, size - 1)); + AssertAllBytesAreSentinel(&contract); + + Assert.Equal(0, getContract(&contract, size + 1)); + AssertAllBytesAreSentinel(&contract); + + Assert.Equal(1, getContract(&contract, size)); + Assert.Equal(ContractMagic, contract.Magic); + Assert.Equal(checked((uint) size), contract.ContractSize); + AssertReservedAndPaddingAreZero(&contract); + } + finally + { + NativeLibrary.Free(module); + } + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Native_bridge_rejects_invalid_preconditions_without_mutating_the_Lua_stack() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* luaState = state.L; + LuaApi.lua_pushinteger(luaState, 0x1CEB_00DA_5EED_1234); + int top = LuaApi.lua_gettop(luaState); + + string path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); + Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); + + IntPtr module = NativeLibrary.Load(path); + try + { + delegate* unmanaged[Cdecl] + protectedOperation = + (delegate* unmanaged[Cdecl]) + NativeLibrary + .GetExport( + module, + "cheatengine_sdk_lua_protected"); + IntPtr* completeExports = stackalloc nint[ProtectedExportCount]; + PopulateProtectedExports(LuaApi.ModuleHandle, completeExports); + IntPtr* incompleteExports = stackalloc nint[ProtectedExportCount]; + for (int i = 0; i < ProtectedExportCount; i++) + { + incompleteExports[i] = completeExports[i]; + } + + incompleteExports[17] = 0; // lua_error is required before the bridge can enter lua_pcallk. + + Assert.Equal(NoErrorStatus, protectedOperation(luaState, incompleteExports, 0, 0, null, 0, 0, 0)); + AssertStackIsUnchanged(luaState, top); + + Assert.Equal(NoErrorStatus, protectedOperation(luaState, completeExports, 0, -1, null, 0, 0, 0)); + AssertStackIsUnchanged(luaState, top); + + Assert.Equal(NoErrorStatus, protectedOperation(luaState, completeExports, 0, top + 1, null, 0, 0, 0)); + AssertStackIsUnchanged(luaState, top); + } + finally + { + NativeLibrary.Free(module); + } + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Native_bridge_builds_a_page_sized_byte_table_with_one_protected_call() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* luaState = state.L; + byte[] bytes = new byte[4096]; + for (int index = 0; index < bytes.Length; index++) + { + bytes[index] = (byte) index; + } + + string path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); + Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); + + IntPtr module = NativeLibrary.Load(path); + try + { + delegate* unmanaged[Cdecl] + protectedOperation = + (delegate* unmanaged[Cdecl]) + NativeLibrary + .GetExport( + module, + "cheatengine_sdk_lua_protected"); + IntPtr* exports = stackalloc nint[ProtectedExportCount]; + PopulateProtectedExports(LuaApi.ModuleHandle, exports); + s_forwardedPCall = exports[16]; + s_pcallCallCount = 0; + exports[16] = (nint) (delegate* unmanaged[Cdecl]) &CountPCall; + + fixed (byte* data = bytes) + { + Assert.Equal(LuaApi.LUA_OK, protectedOperation( + luaState, + exports, + PushByteTableOperation, + 0, + data, + (nuint) bytes.Length, + 0, + 0)); + } + + AssertPageSizedByteTable(luaState, bytes); + } + finally + { + s_forwardedPCall = 0; + s_pcallCallCount = 0; + NativeLibrary.Free(module); + } + } + + private static void AssertPageSizedByteTable(lua_State* luaState, byte[] bytes) + { + Assert.Equal(1, s_pcallCallCount); + Assert.Equal(1, LuaApi.lua_gettop(luaState)); + Assert.Equal((nuint) bytes.Length, LuaApi.lua_rawlen(luaState, -1)); + for (int index = 0; index < bytes.Length; index++) + { + Assert.Equal(LuaApi.LUA_TNUMBER, LuaApi.lua_rawgeti(luaState, -1, index + 1L)); + Assert.Equal(bytes[index], LuaApi.lua_tointeger(luaState, -1)); + LuaApi.lua_settop(luaState, -2); + } + + Assert.Equal(1, LuaApi.lua_gettop(luaState)); + LuaApi.lua_settop(luaState, 0); + } + + private static void PopulateProtectedExports(nint luaModule, nint* exports) + { + Assert.Equal(ProtectedExportCount, SProtectedExportNames.Length); + for (int i = 0; i < SProtectedExportNames.Length; i++) + { + exports[i] = NativeLibrary.GetExport(luaModule, SProtectedExportNames[i]); + } + } + + private static void AssertStackIsUnchanged(lua_State* luaState, int expectedTop) + { + Assert.Equal(expectedTop, LuaApi.lua_gettop(luaState)); + Assert.Equal(0x1CEB_00DA_5EED_1234, LuaApi.lua_tointeger(luaState, -1)); + } + + private static void FillWithSentinel(LuaBridgeContract* contract) + { + Span bytes = new(contract, Unsafe.SizeOf()); + bytes.Fill(0xA5); + } + + private static void AssertAllBytesAreSentinel(LuaBridgeContract* contract) + { + ReadOnlySpan bytes = new(contract, Unsafe.SizeOf()); + for (int i = 0; i < bytes.Length; i++) + { + Assert.Equal((byte) 0xA5, bytes[i]); + } + } + + private static void AssertReservedAndPaddingAreZero(LuaBridgeContract* contract) + { + ReadOnlySpan bytes = new(contract, Unsafe.SizeOf()); + int reservedOffset = Marshal.OffsetOf(nameof(LuaBridgeContract.Reserved)).ToInt32(); + Assert.Equal(4, bytes.Length - reservedOffset - sizeof(byte)); + for (int index = reservedOffset; index < bytes.Length; index++) + { + Assert.Equal((byte) 0, bytes[index]); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int CountPCall(lua_State* luaState, int argumentCount, int resultCount, int errorFunction, + nint context, nint continuation) + { + s_pcallCallCount++; + return ((delegate* unmanaged[Cdecl]) s_forwardedPCall)( + luaState, + argumentCount, + resultCount, + errorFunction, + context, + continuation); + } + + [StructLayout(LayoutKind.Sequential)] + private struct LuaBridgeContract + { + internal uint Magic; + internal uint ContractSize; + internal ulong SupportedOperations; + internal uint ExportTableSize; + internal ushort AbiMajor; + internal ushort AbiMinor; + internal byte PointerSize; + internal byte LuaIntegerSize; + internal byte SizeTSize; + internal byte Reserved; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs index 1616bc34..e37569dd 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs @@ -8,232 +8,256 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; /// Structural tests for the native boundary that protects allocating Lua operations. public sealed unsafe class LuaBridgeContractTests { - private const uint ContractMagic = 0x4345534B; - private const ushort WindowsAmd64Machine = 0x8664; - private const ulong RequiredOperations = (1UL << 12) - 1; - private static readonly uint s_exportTableSize = 20u * (uint)IntPtr.Size; - - private static readonly string[] s_fixedExports = - [ - "cheatengine_sdk_lua_bridge_abi_version", - "cheatengine_sdk_lua_bridge_get_contract", - "cheatengine_sdk_lua_bridge_source_fingerprint", - "cheatengine_sdk_lua_protected", - ]; - - private static readonly string[] s_allowedImportedModules = ["KERNEL32.dll"]; - - [Fact] - public void Native_bridge_has_the_fixed_contract_and_no_Lua_import() - { - var path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); - Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); - Assert.Equal(WindowsAmd64Machine, ReadMachine(path)); - - var module = NativeLibrary.Load(path); - try - { - for (var i = 0; i < s_fixedExports.Length; i++) - Assert.True(NativeLibrary.TryGetExport(module, s_fixedExports[i], out _), s_fixedExports[i]); - - var getContract = (delegate* unmanaged[Cdecl])NativeLibrary.GetExport( - module, - "cheatengine_sdk_lua_bridge_get_contract"); - LuaBridgeContract contract = default; - Assert.Equal(0, getContract(&contract, (nuint)Unsafe.SizeOf() - 1)); - Assert.Equal(1, getContract(&contract, (nuint)Unsafe.SizeOf())); - - Assert.Equal(ContractMagic, contract.Magic); - Assert.Equal((uint)Unsafe.SizeOf(), contract.ContractSize); - Assert.Equal(1, contract.AbiMajor); - Assert.True(contract.AbiMinor >= 1); - Assert.Equal((byte)IntPtr.Size, contract.PointerSize); - Assert.Equal((byte)sizeof(long), contract.LuaIntegerSize); - Assert.Equal((byte)sizeof(nuint), contract.SizeTSize); - Assert.Equal(s_exportTableSize, contract.ExportTableSize); - Assert.Equal(RequiredOperations, contract.SupportedOperations & RequiredOperations); - Assert.Equal(0, contract.Reserved); - } - finally - { - NativeLibrary.Free(module); - } - - var imports = ReadImportedModules(path); - Assert.Equal(s_allowedImportedModules, imports, StringComparer.OrdinalIgnoreCase); - Assert.False(HasDelayImports(path), "The Lua bridge must not delay-load a host or Lua module."); - - var exports = ReadExportedNames(path); - Assert.Equal(s_fixedExports, exports, StringComparer.Ordinal); - } - - private static ushort ReadMachine(string path) - { - var image = File.ReadAllBytes(path); - Require(image.Length >= 0x40 && image[0] == (byte)'M' && image[1] == (byte)'Z', - "The native bridge has no DOS header."); - - var peOffset = checked((int)ReadUInt32(image, 0x3c)); - Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); - return ReadUInt16(image, peOffset + 4); - } - - private static bool HasDelayImports(string path) - { - var image = File.ReadAllBytes(path); - Require(image.Length >= 0x40 && image[0] == (byte)'M' && image[1] == (byte)'Z', - "The native bridge has no DOS header."); - - var peOffset = checked((int)ReadUInt32(image, 0x3c)); - Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); - var optionalOffset = peOffset + 24; - Require(ReadUInt16(image, optionalOffset) == 0x20b, "The native bridge is not PE32+ (Windows x64)."); - var directories = ReadUInt32(image, optionalOffset + 108); - Require(directories > 13, "The native bridge has no delay-import directory slot."); - - var delayImportDirectoryOffset = optionalOffset + 112 + 13 * 8; - return ReadUInt32(image, delayImportDirectoryOffset) != 0 || - ReadUInt32(image, delayImportDirectoryOffset + sizeof(uint)) != 0; - } - - private static List ReadExportedNames(string path) - { - var image = File.ReadAllBytes(path); - Require(image.Length >= 0x40 && image[0] == (byte)'M' && image[1] == (byte)'Z', - "The native bridge has no DOS header."); - - var peOffset = checked((int)ReadUInt32(image, 0x3c)); - Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); - var sectionCount = ReadUInt16(image, peOffset + 6); - var optionalSize = ReadUInt16(image, peOffset + 20); - var optionalOffset = peOffset + 24; - Require(ReadUInt16(image, optionalOffset) == 0x20b, "The native bridge is not PE32+ (Windows x64)."); - Require(ReadUInt32(image, optionalOffset + 108) > 0, "The native bridge has no export-directory slot."); - - var exportRva = ReadUInt32(image, optionalOffset + 112); - var sectionOffset = optionalOffset + optionalSize; - var sizeOfHeaders = ReadUInt32(image, optionalOffset + 60); - List result = []; - if (exportRva == 0) return result; - - var exportOffset = RvaToFileOffset(image, exportRva, sizeOfHeaders, sectionOffset, sectionCount); - Require(exportOffset <= image.Length - 40, "The native bridge has a truncated export directory."); - var namesCount = ReadUInt32(image, exportOffset + 24); - var namesRva = ReadUInt32(image, exportOffset + 32); - var namesOffset = RvaToFileOffset(image, namesRva, sizeOfHeaders, sectionOffset, sectionCount); - Require(namesCount <= (uint)((image.Length - namesOffset) / sizeof(uint)), - "The native bridge has a truncated export-name table."); - - for (var i = 0u; i < namesCount; i++) - { - var nameRva = ReadUInt32(image, checked(namesOffset + (int)i * sizeof(uint))); - result.Add(ReadAsciiZ(image, RvaToFileOffset(image, nameRva, sizeOfHeaders, sectionOffset, sectionCount))); - } - - result.Sort(StringComparer.Ordinal); - return result; - } - - private static List ReadImportedModules(string path) - { - var image = File.ReadAllBytes(path); - Require(image.Length >= 0x40 && image[0] == (byte)'M' && image[1] == (byte)'Z', - "The native bridge has no DOS header."); - - var peOffset = checked((int)ReadUInt32(image, 0x3c)); - Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); - var sectionCount = ReadUInt16(image, peOffset + 6); - var optionalSize = ReadUInt16(image, peOffset + 20); - var optionalOffset = peOffset + 24; - Require(ReadUInt16(image, optionalOffset) == 0x20b, "The native bridge is not PE32+ (Windows x64)."); - Require(ReadUInt32(image, optionalOffset + 108) > 1, "The native bridge has no import-directory slot."); - - var importDirectoryOffset = optionalOffset + 112 + 8; - var importRva = ReadUInt32(image, importDirectoryOffset); - var sectionOffset = optionalOffset + optionalSize; - var sizeOfHeaders = ReadUInt32(image, optionalOffset + 60); - List result = []; - if (importRva == 0) return result; - - var descriptorOffset = RvaToFileOffset(image, importRva, sizeOfHeaders, sectionOffset, sectionCount); - while (true) - { - Require(descriptorOffset <= image.Length - 20, "The native bridge has a truncated import descriptor."); - var originalFirstThunk = ReadUInt32(image, descriptorOffset); - var timeDateStamp = ReadUInt32(image, descriptorOffset + 4); - var forwarderChain = ReadUInt32(image, descriptorOffset + 8); - var nameRva = ReadUInt32(image, descriptorOffset + 12); - var firstThunk = ReadUInt32(image, descriptorOffset + 16); - if (originalFirstThunk == 0 && timeDateStamp == 0 && forwarderChain == 0 && nameRva == 0 && firstThunk == 0) - return result; - - result.Add(ReadAsciiZ(image, RvaToFileOffset(image, nameRva, sizeOfHeaders, sectionOffset, sectionCount))); - descriptorOffset += 20; - } - } - - private static int RvaToFileOffset(byte[] image, uint rva, uint sizeOfHeaders, int sectionOffset, - ushort sectionCount) - { - if (rva < sizeOfHeaders) return checked((int)rva); - - for (var i = 0; i < sectionCount; i++) - { - var offset = checked(sectionOffset + i * 40); - Require(offset <= image.Length - 40, "The native bridge has a truncated section header."); - var virtualSize = ReadUInt32(image, offset + 8); - var virtualAddress = ReadUInt32(image, offset + 12); - var rawSize = ReadUInt32(image, offset + 16); - var rawOffset = ReadUInt32(image, offset + 20); - var sectionSize = Math.Max(virtualSize, rawSize); - if (rva < virtualAddress || rva >= (ulong)virtualAddress + sectionSize) continue; - - var fileOffset = (ulong)rawOffset + (rva - virtualAddress); - Require(fileOffset < (ulong)image.Length, "The native bridge has an import RVA outside its image."); - return checked((int)fileOffset); - } - - throw new InvalidDataException("The native bridge import RVA has no matching section."); - } - - private static string ReadAsciiZ(byte[] image, int offset) - { - Require((uint)offset < (uint)image.Length, "The native bridge import name is outside its image."); - var end = offset; - while (end < image.Length && image[end] != 0) end++; - Require(end < image.Length, "The native bridge has an unterminated import name."); - return Encoding.ASCII.GetString(image, offset, end - offset); - } - - private static ushort ReadUInt16(byte[] image, int offset) - { - Require(offset >= 0 && offset <= image.Length - sizeof(ushort), "The native bridge has a truncated integer."); - return BinaryPrimitives.ReadUInt16LittleEndian(image.AsSpan(offset)); - } - - private static uint ReadUInt32(byte[] image, int offset) - { - Require(offset >= 0 && offset <= image.Length - sizeof(uint), "The native bridge has a truncated integer."); - return BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(offset)); - } - - private static void Require(bool condition, string message) - { - if (!condition) throw new InvalidDataException(message); - } - - [StructLayout(LayoutKind.Sequential)] - private struct LuaBridgeContract - { - internal uint Magic; - internal uint ContractSize; - internal ulong SupportedOperations; - internal uint ExportTableSize; - internal ushort AbiMajor; - internal ushort AbiMinor; - internal byte PointerSize; - internal byte LuaIntegerSize; - internal byte SizeTSize; - internal byte Reserved; - } + private const uint ContractMagic = 0x4345534B; + private const ushort WindowsAmd64Machine = 0x8664; + private const ulong RequiredOperations = (1UL << 12) - 1; + private static readonly uint s_exportTableSize = 20u * (uint) IntPtr.Size; + + private static readonly string[] s_fixedExports = + [ + "cheatengine_sdk_lua_bridge_abi_version", + "cheatengine_sdk_lua_bridge_get_contract", + "cheatengine_sdk_lua_bridge_source_fingerprint", + "cheatengine_sdk_lua_protected" + ]; + + private static readonly string[] s_allowedImportedModules = ["KERNEL32.dll"]; + + [Fact] + public void Native_bridge_has_the_fixed_contract_and_no_Lua_import() + { + string path = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); + Assert.True(File.Exists(path), $"The native Lua bridge was not copied to '{path}'."); + Assert.Equal(WindowsAmd64Machine, ReadMachine(path)); + + IntPtr module = NativeLibrary.Load(path); + try + { + for (int i = 0; i < s_fixedExports.Length; i++) + { + Assert.True(NativeLibrary.TryGetExport(module, s_fixedExports[i], out _), s_fixedExports[i]); + } + + delegate* unmanaged[Cdecl] getContract = + (delegate* unmanaged[Cdecl]) NativeLibrary.GetExport( + module, + "cheatengine_sdk_lua_bridge_get_contract"); + LuaBridgeContract contract = default; + Assert.Equal(0, getContract(&contract, (nuint) Unsafe.SizeOf() - 1)); + Assert.Equal(1, getContract(&contract, (nuint) Unsafe.SizeOf())); + + Assert.Equal(ContractMagic, contract.Magic); + Assert.Equal((uint) Unsafe.SizeOf(), contract.ContractSize); + Assert.Equal(1, contract.AbiMajor); + Assert.True(contract.AbiMinor >= 1); + Assert.Equal((byte) IntPtr.Size, contract.PointerSize); + Assert.Equal((byte) sizeof(long), contract.LuaIntegerSize); + Assert.Equal((byte) sizeof(nuint), contract.SizeTSize); + Assert.Equal(s_exportTableSize, contract.ExportTableSize); + Assert.Equal(RequiredOperations, contract.SupportedOperations & RequiredOperations); + Assert.Equal(0, contract.Reserved); + } + finally + { + NativeLibrary.Free(module); + } + + List imports = ReadImportedModules(path); + Assert.Equal(s_allowedImportedModules, imports, StringComparer.OrdinalIgnoreCase); + Assert.False(HasDelayImports(path), "The Lua bridge must not delay-load a host or Lua module."); + + List exports = ReadExportedNames(path); + Assert.Equal(s_fixedExports, exports, StringComparer.Ordinal); + } + + private static ushort ReadMachine(string path) + { + byte[] image = File.ReadAllBytes(path); + Require(image.Length >= 0x40 && image[0] == (byte) 'M' && image[1] == (byte) 'Z', + "The native bridge has no DOS header."); + + int peOffset = checked((int) ReadUInt32(image, 0x3c)); + Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); + return ReadUInt16(image, peOffset + 4); + } + + private static bool HasDelayImports(string path) + { + byte[] image = File.ReadAllBytes(path); + Require(image.Length >= 0x40 && image[0] == (byte) 'M' && image[1] == (byte) 'Z', + "The native bridge has no DOS header."); + + int peOffset = checked((int) ReadUInt32(image, 0x3c)); + Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); + int optionalOffset = peOffset + 24; + Require(ReadUInt16(image, optionalOffset) == 0x20b, "The native bridge is not PE32+ (Windows x64)."); + uint directories = ReadUInt32(image, optionalOffset + 108); + Require(directories > 13, "The native bridge has no delay-import directory slot."); + + int delayImportDirectoryOffset = optionalOffset + 112 + 13 * 8; + return ReadUInt32(image, delayImportDirectoryOffset) != 0 || + ReadUInt32(image, delayImportDirectoryOffset + sizeof(uint)) != 0; + } + + private static List ReadExportedNames(string path) + { + byte[] image = File.ReadAllBytes(path); + Require(image.Length >= 0x40 && image[0] == (byte) 'M' && image[1] == (byte) 'Z', + "The native bridge has no DOS header."); + + int peOffset = checked((int) ReadUInt32(image, 0x3c)); + Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); + ushort sectionCount = ReadUInt16(image, peOffset + 6); + ushort optionalSize = ReadUInt16(image, peOffset + 20); + int optionalOffset = peOffset + 24; + Require(ReadUInt16(image, optionalOffset) == 0x20b, "The native bridge is not PE32+ (Windows x64)."); + Require(ReadUInt32(image, optionalOffset + 108) > 0, "The native bridge has no export-directory slot."); + + uint exportRva = ReadUInt32(image, optionalOffset + 112); + int sectionOffset = optionalOffset + optionalSize; + uint sizeOfHeaders = ReadUInt32(image, optionalOffset + 60); + List result = []; + if (exportRva == 0) + { + return result; + } + + int exportOffset = RvaToFileOffset(image, exportRva, sizeOfHeaders, sectionOffset, sectionCount); + Require(exportOffset <= image.Length - 40, "The native bridge has a truncated export directory."); + uint namesCount = ReadUInt32(image, exportOffset + 24); + uint namesRva = ReadUInt32(image, exportOffset + 32); + int namesOffset = RvaToFileOffset(image, namesRva, sizeOfHeaders, sectionOffset, sectionCount); + Require(namesCount <= (uint) ((image.Length - namesOffset) / sizeof(uint)), + "The native bridge has a truncated export-name table."); + + for (uint i = 0u; i < namesCount; i++) + { + uint nameRva = ReadUInt32(image, checked(namesOffset + (int) i * sizeof(uint))); + result.Add(ReadAsciiZ(image, RvaToFileOffset(image, nameRva, sizeOfHeaders, sectionOffset, sectionCount))); + } + + result.Sort(StringComparer.Ordinal); + return result; + } + + private static List ReadImportedModules(string path) + { + byte[] image = File.ReadAllBytes(path); + Require(image.Length >= 0x40 && image[0] == (byte) 'M' && image[1] == (byte) 'Z', + "The native bridge has no DOS header."); + + int peOffset = checked((int) ReadUInt32(image, 0x3c)); + Require(ReadUInt32(image, peOffset) == 0x00004550, "The native bridge has no PE header."); + ushort sectionCount = ReadUInt16(image, peOffset + 6); + ushort optionalSize = ReadUInt16(image, peOffset + 20); + int optionalOffset = peOffset + 24; + Require(ReadUInt16(image, optionalOffset) == 0x20b, "The native bridge is not PE32+ (Windows x64)."); + Require(ReadUInt32(image, optionalOffset + 108) > 1, "The native bridge has no import-directory slot."); + + int importDirectoryOffset = optionalOffset + 112 + 8; + uint importRva = ReadUInt32(image, importDirectoryOffset); + int sectionOffset = optionalOffset + optionalSize; + uint sizeOfHeaders = ReadUInt32(image, optionalOffset + 60); + List result = []; + if (importRva == 0) + { + return result; + } + + int descriptorOffset = RvaToFileOffset(image, importRva, sizeOfHeaders, sectionOffset, sectionCount); + while (true) + { + Require(descriptorOffset <= image.Length - 20, "The native bridge has a truncated import descriptor."); + uint originalFirstThunk = ReadUInt32(image, descriptorOffset); + uint timeDateStamp = ReadUInt32(image, descriptorOffset + 4); + uint forwarderChain = ReadUInt32(image, descriptorOffset + 8); + uint nameRva = ReadUInt32(image, descriptorOffset + 12); + uint firstThunk = ReadUInt32(image, descriptorOffset + 16); + if (originalFirstThunk == 0 && timeDateStamp == 0 && forwarderChain == 0 && nameRva == 0 && firstThunk == 0) + { + return result; + } + + result.Add(ReadAsciiZ(image, RvaToFileOffset(image, nameRva, sizeOfHeaders, sectionOffset, sectionCount))); + descriptorOffset += 20; + } + } + + private static int RvaToFileOffset(byte[] image, uint rva, uint sizeOfHeaders, int sectionOffset, + ushort sectionCount) + { + if (rva < sizeOfHeaders) + { + return checked((int) rva); + } + + for (int i = 0; i < sectionCount; i++) + { + int offset = checked(sectionOffset + i * 40); + Require(offset <= image.Length - 40, "The native bridge has a truncated section header."); + uint virtualSize = ReadUInt32(image, offset + 8); + uint virtualAddress = ReadUInt32(image, offset + 12); + uint rawSize = ReadUInt32(image, offset + 16); + uint rawOffset = ReadUInt32(image, offset + 20); + uint sectionSize = Math.Max(virtualSize, rawSize); + if (rva < virtualAddress || rva >= (ulong) virtualAddress + sectionSize) + { + continue; + } + + ulong fileOffset = (ulong) rawOffset + (rva - virtualAddress); + Require(fileOffset < (ulong) image.Length, "The native bridge has an import RVA outside its image."); + return checked((int) fileOffset); + } + + throw new InvalidDataException("The native bridge import RVA has no matching section."); + } + + private static string ReadAsciiZ(byte[] image, int offset) + { + Require((uint) offset < (uint) image.Length, "The native bridge import name is outside its image."); + int end = offset; + while (end < image.Length && image[end] != 0) + { + end++; + } + + Require(end < image.Length, "The native bridge has an unterminated import name."); + return Encoding.ASCII.GetString(image, offset, end - offset); + } + + private static ushort ReadUInt16(byte[] image, int offset) + { + Require(offset >= 0 && offset <= image.Length - sizeof(ushort), "The native bridge has a truncated integer."); + return BinaryPrimitives.ReadUInt16LittleEndian(image.AsSpan(offset)); + } + + private static uint ReadUInt32(byte[] image, int offset) + { + Require(offset >= 0 && offset <= image.Length - sizeof(uint), "The native bridge has a truncated integer."); + return BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(offset)); + } + + private static void Require(bool condition, string message) + { + if (!condition) + { + throw new InvalidDataException(message); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct LuaBridgeContract + { + internal uint Magic; + internal uint ContractSize; + internal ulong SupportedOperations; + internal uint ExportTableSize; + internal ushort AbiMajor; + internal ushort AbiMinor; + internal byte PointerSize; + internal byte LuaIntegerSize; + internal byte SizeTSize; + internal byte Reserved; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeLoadingPolicyTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeLoadingPolicyTests.cs index c2f45097..8bd14d4c 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeLoadingPolicyTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeLoadingPolicyTests.cs @@ -8,29 +8,30 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; /// Contract tests for deterministic loading of the packaged native Lua bridge. public sealed class LuaBridgeLoadingPolicyTests { - [Fact] - public void Bridge_is_loaded_only_from_the_interop_assembly_directory() - { - var assembly = typeof(LuaProtectedApi).Assembly; - var policy = assembly.GetCustomAttribute(); + [Fact] + public void Bridge_is_loaded_only_from_the_interop_assembly_directory() + { + Assembly assembly = typeof(LuaProtectedApi).Assembly; + DefaultDllImportSearchPathsAttribute? policy = + assembly.GetCustomAttribute(); - Assert.NotNull(policy); - Assert.Equal(DllImportSearchPath.AssemblyDirectory, policy!.Paths); + Assert.NotNull(policy); + Assert.Equal(DllImportSearchPath.AssemblyDirectory, policy!.Paths); - var bridgePath = Path.Combine(Path.GetDirectoryName(assembly.Location)!, "cheatengine-sdk-lua-bridge.dll"); - Assert.True(File.Exists(bridgePath), $"The native Lua bridge was not copied to '{bridgePath}'."); + string bridgePath = Path.Combine(Path.GetDirectoryName(assembly.Location)!, "cheatengine-sdk-lua-bridge.dll"); + Assert.True(File.Exists(bridgePath), $"The native Lua bridge was not copied to '{bridgePath}'."); - var module = NativeLibrary.Load( - "cheatengine-sdk-lua-bridge", - assembly, - DllImportSearchPath.AssemblyDirectory); - try - { - Assert.True(NativeLibrary.TryGetExport(module, "cheatengine_sdk_lua_protected", out _)); - } - finally - { - NativeLibrary.Free(module); - } - } + IntPtr module = NativeLibrary.Load( + "cheatengine-sdk-lua-bridge", + assembly, + DllImportSearchPath.AssemblyDirectory); + try + { + Assert.True(NativeLibrary.TryGetExport(module, "cheatengine_sdk_lua_protected", out _)); + } + finally + { + NativeLibrary.Free(module); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs index 93590d50..9935731d 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs @@ -14,258 +14,261 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; /// Managed preconditions that prevent an invalid call from reaching bridge setup before lua_pcallk. public sealed unsafe class LuaProtectedApiTests { - [Fact] - public void Contract_has_the_exact_managed_C11_layout() - { - Assert.Equal(LuaBridgeContract.Size, Unsafe.SizeOf()); - Assert.Equal(1u, LuaBridgeContract.ExpectedLegacyAbiVersion); - Assert.Equal(0, Marshal.OffsetOf(nameof(LuaBridgeContract.Magic)).ToInt32()); - Assert.Equal(4, Marshal.OffsetOf(nameof(LuaBridgeContract.ContractSize)).ToInt32()); - Assert.Equal(8, Marshal.OffsetOf(nameof(LuaBridgeContract.SupportedOperations)).ToInt32()); - Assert.Equal(16, Marshal.OffsetOf(nameof(LuaBridgeContract.ExportTableSize)).ToInt32()); - Assert.Equal(20, Marshal.OffsetOf(nameof(LuaBridgeContract.AbiMajor)).ToInt32()); - Assert.Equal(22, Marshal.OffsetOf(nameof(LuaBridgeContract.AbiMinor)).ToInt32()); - Assert.Equal(24, Marshal.OffsetOf(nameof(LuaBridgeContract.PointerSize)).ToInt32()); - Assert.Equal(25, Marshal.OffsetOf(nameof(LuaBridgeContract.LuaIntegerSize)).ToInt32()); - Assert.Equal(26, Marshal.OffsetOf(nameof(LuaBridgeContract.SizeTSize)).ToInt32()); - Assert.Equal(27, Marshal.OffsetOf(nameof(LuaBridgeContract.Reserved)).ToInt32()); - Assert.Equal(20 * IntPtr.Size, Unsafe.SizeOf()); - } - - [Fact] - public void Contract_requires_all_compatible_C11_fields() - { - var contract = CreateCompatibleContract(); - Assert.True(contract.IsCompatible()); - - contract.Magic = 0; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.ContractSize--; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.ExportTableSize--; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.AbiMajor++; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.AbiMinor = LuaBridgeContract.MinimumMinor - 1; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.SupportedOperations &= ~LuaProtectedOperationContract.RequiredBitmap; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.PointerSize--; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.LuaIntegerSize--; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.SizeTSize--; - Assert.False(contract.IsCompatible()); - contract = CreateCompatibleContract(); - contract.Reserved = 1; - Assert.False(contract.IsCompatible()); - } - - [Fact] - public void Contract_accepts_a_newer_minor_version_and_additive_operation_bits() - { - var contract = CreateCompatibleContract(); - - contract.AbiMinor = checked(LuaBridgeContract.MinimumMinor + 1); - contract.SupportedOperations |= 1UL << 63; - - Assert.True(contract.IsCompatible()); - } - - [Fact] - public void Protected_bridge_imports_are_explicit_cdecl_and_do_not_suppress_GC_transitions() - { - const BindingFlags Flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; - HashSet expected = - [ - "cheatengine_sdk_lua_protected", - "cheatengine_sdk_lua_bridge_get_contract", - "cheatengine_sdk_lua_bridge_abi_version", - ]; - var importCount = 0; - - foreach (var method in typeof(LuaProtectedApi).GetMethods(Flags)) - { - var libraryImport = method.GetCustomAttribute(); - if (libraryImport is null) continue; - - importCount++; - Assert.True(expected.Remove(method.Name), $"Unexpected LibraryImport method '{method.Name}'."); - Assert.Equal(method.Name, libraryImport.EntryPoint); - - var callConvention = method.GetCustomAttribute(); - Assert.NotNull(callConvention); - Assert.NotNull(callConvention.CallConvs); - Assert.Equal(typeof(CallConvCdecl), Assert.Single(callConvention.CallConvs)); - Assert.Null(method.GetCustomAttribute()); - } - - Assert.Equal(3, importCount); - Assert.Empty(expected); - } - - [Fact] - public void Protected_operation_numbers_are_explicit_and_contiguous() - { - Assert.Equal(0, (int)LuaProtectedOperation.PushBytes); - Assert.Equal(10, (int)LuaProtectedOperation.PushHostObject); - Assert.Equal(11, (int)LuaProtectedOperation.PushByteTable); - Assert.Equal(12, LuaProtectedOperationContract.Count); - Assert.Equal((1UL << LuaProtectedOperationContract.Count) - 1, LuaProtectedOperationContract.RequiredBitmap); - Assert.False(LuaProtectedOperationContract.IsDefined((LuaProtectedOperation)(-1))); - Assert.False( - LuaProtectedOperationContract.IsDefined((LuaProtectedOperation)LuaProtectedOperationContract.Count)); - } - - [Fact] - public void CreateTable_negative_capacities_are_rejected_before_native_binding() - { - Assert.Throws(() => - LuaProtectedApi.CreateTable(state: null, arrayCapacity: -1, recordCapacity: 0)); - Assert.Throws(() => - LuaProtectedApi.CreateTable(state: null, arrayCapacity: 0, recordCapacity: -1)); - } - - [Fact] - public void PushClosure_invalid_shape_is_rejected_before_native_binding() - { - Assert.Throws(() => - LuaProtectedApi.PushClosure(state: null, function: 0, upvalues: 0)); - Assert.Throws(() => - LuaProtectedApi.PushClosure(state: null, function: 1, upvalues: 256)); - } - - [Fact] - public void Protected_operations_reject_a_null_state_before_loading_the_bridge() - { - Assert.Throws(() => LuaProtectedApi.PushBytes(state: null, bytes: [])); - Assert.Throws(() => LuaProtectedApi.PushByteTable(state: null, bytes: [])); - Assert.Throws(() => LuaProtectedApi.NewUserdata(state: null, bytes: 1)); - Assert.Throws(() => - LuaProtectedApi.PushHostObject(state: null, hostObjectPusher: 1, nativeObject: 0)); - } - - [Fact] - public void Private_references_reject_the_zero_registry_key_before_native_binding() - { - var reference = 17; - Assert.Throws(() => - LuaProtectedApi.TryCreatePrivateRef(state: null, stableKey: 0, reference: out reference)); - Assert.Throws(() => - LuaProtectedApi.PushPrivateRef(state: null, stableKey: 0, reference: 1)); - Assert.Throws(() => - LuaProtectedApi.UnrefPrivate(state: null, stableKey: 0, reference: 1)); - Assert.Equal(17, reference); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Private_reference_round_trip_creates_and_pushes_the_original_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var pointer = state.L; - var stableKey = (nint)0x5A17; - const long expected = -9_876_543_210; - - lua_pushinteger(pointer, expected); - Assert.Equal(LUA_OK, LuaProtectedApi.TryCreatePrivateRef(pointer, stableKey, out var reference)); - Assert.True(reference > 0); // The SDK-private table owns its own luaL_ref free list. - Assert.Equal(0, lua_gettop(pointer)); - - Assert.Equal(LUA_OK, LuaProtectedApi.PushPrivateRef(pointer, stableKey, reference)); - Assert.Equal(1, lua_gettop(pointer)); - Assert.Equal(LUA_TNUMBER, lua_type(pointer, -1)); - Assert.Equal(expected, lua_tointeger(pointer, -1)); - - lua_settop(pointer, 0); - Assert.Equal(LUA_OK, LuaProtectedApi.UnrefPrivate(pointer, stableKey, reference)); - Assert.Equal(0, lua_gettop(pointer)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Push_private_reference_without_its_table_returns_an_error_and_preserves_the_caller_stack() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var pointer = state.L; - var stableKey = (nint)0x5A18; - const long sentinel = 71; - - lua_pushinteger(pointer, sentinel); - var callerTop = lua_gettop(pointer); - - Assert.Equal(LUA_ERRRUN, LuaProtectedApi.PushPrivateRef(pointer, stableKey, 1)); - Assert.Equal(callerTop + 1, lua_gettop(pointer)); - Assert.Equal(sentinel, lua_tointeger(pointer, 1)); - Assert.Contains("private reference table is unavailable", LuaTest.ReadString(pointer, -1), - StringComparison.Ordinal); - - lua_settop(pointer, callerTop); - Assert.Equal(callerTop, lua_gettop(pointer)); - Assert.Equal(sentinel, lua_tointeger(pointer, -1)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void Protected_operations_reject_missing_stack_inputs_before_bridge_setup() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var statePointer = (nint)state.L; - Assert.Equal(0, lua_gettop((lua_State*)statePointer)); - - Assert.Throws(() => LuaProtectedApi.RawSet((lua_State*)statePointer, 1)); - Assert.Throws(() => LuaProtectedApi.PushClosure((lua_State*)statePointer, 1, 1)); - - var reference = 17; - Assert.Throws(() => - LuaProtectedApi.TryCreatePrivateRef((lua_State*)statePointer, 1, out reference)); - Assert.Equal(17, reference); - Assert.Equal(0, lua_gettop((lua_State*)statePointer)); - } - - [Fact] - [Trait("Category", "NativeLua")] - public void RawSet_rejects_an_invalid_table_index_without_touching_the_stack() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var pointer = state.L; - var statePointer = (nint)pointer; - lua_pushinteger(pointer, 1); - lua_pushinteger(pointer, 2); - var top = lua_gettop(pointer); - - Assert.Throws(() => LuaProtectedApi.RawSet((lua_State*)statePointer, 0)); - - Assert.Equal(top, lua_gettop(pointer)); - Assert.Equal(2, lua_tointeger(pointer, -1)); - Assert.Equal(1, lua_tointeger(pointer, -2)); - } - - private static LuaBridgeContract CreateCompatibleContract() - { - return new LuaBridgeContract - { - Magic = LuaBridgeContract.ExpectedMagic, - ContractSize = (uint)Unsafe.SizeOf(), - SupportedOperations = LuaProtectedOperationContract.RequiredBitmap, - ExportTableSize = (uint)Unsafe.SizeOf(), - AbiMajor = LuaBridgeContract.ExpectedMajor, - AbiMinor = LuaBridgeContract.MinimumMinor, - PointerSize = (byte)IntPtr.Size, - LuaIntegerSize = sizeof(long), - SizeTSize = (byte)sizeof(nuint), - }; - } + [Fact] + public void Contract_has_the_exact_managed_C11_layout() + { + Assert.Equal(LuaBridgeContract.Size, Unsafe.SizeOf()); + Assert.Equal(1u, LuaBridgeContract.ExpectedLegacyAbiVersion); + Assert.Equal(0, Marshal.OffsetOf(nameof(LuaBridgeContract.Magic)).ToInt32()); + Assert.Equal(4, Marshal.OffsetOf(nameof(LuaBridgeContract.ContractSize)).ToInt32()); + Assert.Equal(8, Marshal.OffsetOf(nameof(LuaBridgeContract.SupportedOperations)).ToInt32()); + Assert.Equal(16, Marshal.OffsetOf(nameof(LuaBridgeContract.ExportTableSize)).ToInt32()); + Assert.Equal(20, Marshal.OffsetOf(nameof(LuaBridgeContract.AbiMajor)).ToInt32()); + Assert.Equal(22, Marshal.OffsetOf(nameof(LuaBridgeContract.AbiMinor)).ToInt32()); + Assert.Equal(24, Marshal.OffsetOf(nameof(LuaBridgeContract.PointerSize)).ToInt32()); + Assert.Equal(25, Marshal.OffsetOf(nameof(LuaBridgeContract.LuaIntegerSize)).ToInt32()); + Assert.Equal(26, Marshal.OffsetOf(nameof(LuaBridgeContract.SizeTSize)).ToInt32()); + Assert.Equal(27, Marshal.OffsetOf(nameof(LuaBridgeContract.Reserved)).ToInt32()); + Assert.Equal(20 * IntPtr.Size, Unsafe.SizeOf()); + } + + [Fact] + public void Contract_requires_all_compatible_C11_fields() + { + LuaBridgeContract contract = CreateCompatibleContract(); + Assert.True(contract.IsCompatible()); + + contract.Magic = 0; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.ContractSize--; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.ExportTableSize--; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.AbiMajor++; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.AbiMinor = LuaBridgeContract.MinimumMinor - 1; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.SupportedOperations &= ~LuaProtectedOperationContract.RequiredBitmap; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.PointerSize--; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.LuaIntegerSize--; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.SizeTSize--; + Assert.False(contract.IsCompatible()); + contract = CreateCompatibleContract(); + contract.Reserved = 1; + Assert.False(contract.IsCompatible()); + } + + [Fact] + public void Contract_accepts_a_newer_minor_version_and_additive_operation_bits() + { + LuaBridgeContract contract = CreateCompatibleContract(); + + contract.AbiMinor = checked(LuaBridgeContract.MinimumMinor + 1); + contract.SupportedOperations |= 1UL << 63; + + Assert.True(contract.IsCompatible()); + } + + [Fact] + public void Protected_bridge_imports_are_explicit_cdecl_and_do_not_suppress_GC_transitions() + { + const BindingFlags Flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + HashSet expected = + [ + "cheatengine_sdk_lua_protected", + "cheatengine_sdk_lua_bridge_get_contract", + "cheatengine_sdk_lua_bridge_abi_version" + ]; + int importCount = 0; + + foreach (MethodInfo method in typeof(LuaProtectedApi).GetMethods(Flags)) + { + LibraryImportAttribute? libraryImport = method.GetCustomAttribute(); + if (libraryImport is null) + { + continue; + } + + importCount++; + Assert.True(expected.Remove(method.Name), $"Unexpected LibraryImport method '{method.Name}'."); + Assert.Equal(method.Name, libraryImport.EntryPoint); + + UnmanagedCallConvAttribute? callConvention = method.GetCustomAttribute(); + Assert.NotNull(callConvention); + Assert.NotNull(callConvention.CallConvs); + Assert.Equal(typeof(CallConvCdecl), Assert.Single(callConvention.CallConvs)); + Assert.Null(method.GetCustomAttribute()); + } + + Assert.Equal(3, importCount); + Assert.Empty(expected); + } + + [Fact] + public void Protected_operation_numbers_are_explicit_and_contiguous() + { + Assert.Equal(0, (int) LuaProtectedOperation.PushBytes); + Assert.Equal(10, (int) LuaProtectedOperation.PushHostObject); + Assert.Equal(11, (int) LuaProtectedOperation.PushByteTable); + Assert.Equal(12, LuaProtectedOperationContract.Count); + Assert.Equal((1UL << LuaProtectedOperationContract.Count) - 1, LuaProtectedOperationContract.RequiredBitmap); + Assert.False(LuaProtectedOperationContract.IsDefined((LuaProtectedOperation) (-1))); + Assert.False( + LuaProtectedOperationContract.IsDefined((LuaProtectedOperation) LuaProtectedOperationContract.Count)); + } + + [Fact] + public void CreateTable_negative_capacities_are_rejected_before_native_binding() + { + Assert.Throws(() => + LuaProtectedApi.CreateTable(null, -1, 0)); + Assert.Throws(() => + LuaProtectedApi.CreateTable(null, 0, -1)); + } + + [Fact] + public void PushClosure_invalid_shape_is_rejected_before_native_binding() + { + Assert.Throws(() => + LuaProtectedApi.PushClosure(null, 0, 0)); + Assert.Throws(() => + LuaProtectedApi.PushClosure(null, 1, 256)); + } + + [Fact] + public void Protected_operations_reject_a_null_state_before_loading_the_bridge() + { + Assert.Throws(() => LuaProtectedApi.PushBytes(null, [])); + Assert.Throws(() => LuaProtectedApi.PushByteTable(null, [])); + Assert.Throws(() => LuaProtectedApi.NewUserdata(null, 1)); + Assert.Throws(() => + LuaProtectedApi.PushHostObject(null, 1, 0)); + } + + [Fact] + public void Private_references_reject_the_zero_registry_key_before_native_binding() + { + int reference = 17; + Assert.Throws(() => + LuaProtectedApi.TryCreatePrivateRef(null, 0, out reference)); + Assert.Throws(() => + LuaProtectedApi.PushPrivateRef(null, 0, 1)); + Assert.Throws(() => + LuaProtectedApi.UnrefPrivate(null, 0, 1)); + Assert.Equal(17, reference); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Private_reference_round_trip_creates_and_pushes_the_original_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* pointer = state.L; + IntPtr stableKey = 0x5A17; + const long expected = -9_876_543_210; + + lua_pushinteger(pointer, expected); + Assert.Equal(LUA_OK, LuaProtectedApi.TryCreatePrivateRef(pointer, stableKey, out int reference)); + Assert.True(reference > 0); // The SDK-private table owns its own luaL_ref free list. + Assert.Equal(0, lua_gettop(pointer)); + + Assert.Equal(LUA_OK, LuaProtectedApi.PushPrivateRef(pointer, stableKey, reference)); + Assert.Equal(1, lua_gettop(pointer)); + Assert.Equal(LUA_TNUMBER, lua_type(pointer, -1)); + Assert.Equal(expected, lua_tointeger(pointer, -1)); + + lua_settop(pointer, 0); + Assert.Equal(LUA_OK, LuaProtectedApi.UnrefPrivate(pointer, stableKey, reference)); + Assert.Equal(0, lua_gettop(pointer)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Push_private_reference_without_its_table_returns_an_error_and_preserves_the_caller_stack() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* pointer = state.L; + IntPtr stableKey = 0x5A18; + const long sentinel = 71; + + lua_pushinteger(pointer, sentinel); + int callerTop = lua_gettop(pointer); + + Assert.Equal(LUA_ERRRUN, LuaProtectedApi.PushPrivateRef(pointer, stableKey, 1)); + Assert.Equal(callerTop + 1, lua_gettop(pointer)); + Assert.Equal(sentinel, lua_tointeger(pointer, 1)); + Assert.Contains("private reference table is unavailable", LuaTest.ReadString(pointer, -1), + StringComparison.Ordinal); + + lua_settop(pointer, callerTop); + Assert.Equal(callerTop, lua_gettop(pointer)); + Assert.Equal(sentinel, lua_tointeger(pointer, -1)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Protected_operations_reject_missing_stack_inputs_before_bridge_setup() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + IntPtr statePointer = (nint) state.L; + Assert.Equal(0, lua_gettop((lua_State*) statePointer)); + + Assert.Throws(() => LuaProtectedApi.RawSet((lua_State*) statePointer, 1)); + Assert.Throws(() => LuaProtectedApi.PushClosure((lua_State*) statePointer, 1, 1)); + + int reference = 17; + Assert.Throws(() => + LuaProtectedApi.TryCreatePrivateRef((lua_State*) statePointer, 1, out reference)); + Assert.Equal(17, reference); + Assert.Equal(0, lua_gettop((lua_State*) statePointer)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void RawSet_rejects_an_invalid_table_index_without_touching_the_stack() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* pointer = state.L; + IntPtr statePointer = (nint) pointer; + lua_pushinteger(pointer, 1); + lua_pushinteger(pointer, 2); + int top = lua_gettop(pointer); + + Assert.Throws(() => LuaProtectedApi.RawSet((lua_State*) statePointer, 0)); + + Assert.Equal(top, lua_gettop(pointer)); + Assert.Equal(2, lua_tointeger(pointer, -1)); + Assert.Equal(1, lua_tointeger(pointer, -2)); + } + + private static LuaBridgeContract CreateCompatibleContract() + { + return new LuaBridgeContract + { + Magic = LuaBridgeContract.ExpectedMagic, + ContractSize = (uint) Unsafe.SizeOf(), + SupportedOperations = LuaProtectedOperationContract.RequiredBitmap, + ExportTableSize = (uint) Unsafe.SizeOf(), + AbiMajor = LuaBridgeContract.ExpectedMajor, + AbiMinor = LuaBridgeContract.MinimumMinor, + PointerSize = (byte) IntPtr.Size, + LuaIntegerSize = sizeof(long), + SizeTSize = (byte) sizeof(nuint) + }; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs index 3b28c0dd..567703ed 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs @@ -1,9 +1,11 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -11,270 +13,270 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class CallTests { - [Fact] - public void Pcallk_success_replaces_function_and_arguments_with_results() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - LuaTest.Run(L, "return function(a, b) return a + b, a * b end"u8, 1); - - lua_pushinteger(L, 6); - lua_pushinteger(L, 7); - var status = lua_pcallk(L, 2, 2, 0, 0, null); - - Assert.Equal(LUA_OK, status); - Assert.Equal(2, lua_gettop(L)); - Assert.Equal(13, lua_tointeger(L, 1)); - Assert.Equal(42, lua_tointeger(L, 2)); - } - - [Fact] - public void Pcallk_multret_keeps_every_result() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - Assert.Equal(LUA_OK, LuaTest.Load(L, "return 1, 2, 3, 4, 5"u8)); - Assert.Equal(LUA_OK, lua_pcallk(L, 0, LUA_MULTRET, 0, 0, null)); - - Assert.Equal(5, lua_gettop(L)); - } - - [Fact] - public void Pcallk_runtime_error_returns_errrun_and_exactly_the_message() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - - Assert.Equal(LUA_OK, LuaTest.Load(L, "local depth = ...\nerror('boom from lua')"u8)); - lua_pushinteger(L, 1); - var status = lua_pcallk(L, 1, 3, 0, 0, null); - - Assert.Equal(LUA_ERRRUN, status); - Assert.Equal(1, lua_gettop(L)); - Assert.Equal("test:2: boom from lua", LuaTest.ReadString(L, -1)); - } - - [Fact] - public void Pcallk_error_value_can_be_any_lua_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - - Assert.Equal(LUA_OK, LuaTest.Load(L, "error({ code = 17 })"u8)); - var status = lua_pcall(L, 0, 0, 0); - - Assert.Equal(LUA_ERRRUN, status); - Assert.True(lua_istable(L, -1)); - fixed (byte* code = "code"u8) - { - Assert.Equal(LUA_TNUMBER, lua_getfield(L, -1, code)); - Assert.Equal(17, lua_tointeger(L, -1)); - } - } - - [Fact] - public void Pcallk_message_handler_index_is_the_fourth_argument() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - LuaTest.Run(L, "return function(message) return 'handled: ' .. message end"u8, 1); - var handler = lua_gettop(L); - - Assert.Equal(LUA_OK, LuaTest.Load(L, "local t = nil\nreturn t.field"u8)); - var status = lua_pcallk(L, 0, 0, handler, 0, null); - - Assert.Equal(LUA_ERRRUN, status); - Assert.StartsWith("handled: test:2:", LuaTest.ReadString(L, -1), StringComparison.Ordinal); - Assert.Equal(handler + 1, lua_gettop(L)); - } - - [Fact] - public void Pcallk_reports_a_failing_finalizer_as_errgcmm_although_no_allocation_failed() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - - // What "Raises: memory" means in Lua 5.3: the loop allocates only through string.rep, that is through - // lua_pushlstring called by the native string library. One of those calls runs the collector step that - // finalizes the unreachable table, and the failing __gc unwinds from there to the protected call. - Assert.Equal(LUA_OK, - LuaTest.Load(L, - "setmetatable({}, { __gc = function() error('finalizer failed') end })\nfor i = 1, 1000000 do local s = string.rep('x', 64) end\nreturn 'finished'"u8)); - var status = lua_pcallk(L, 0, 1, 0, 0, null); - - Assert.Equal(LUA_ERRGCMM, status); - Assert.Equal(1, lua_gettop(L)); - Assert.Contains("finalizer failed", LuaTest.ReadString(L, -1), StringComparison.Ordinal); - } - - [Fact] - public void Load_reports_syntax_errors_without_running_anything() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - var status = LuaTest.Load(L, "return +"u8); - - Assert.Equal(LUA_ERRSYNTAX, status); - Assert.Equal(1, lua_gettop(L)); - Assert.StartsWith("test:1:", LuaTest.ReadString(L, -1), StringComparison.Ordinal); - } - - [Fact] - public void Loadbufferx_text_mode_refuses_a_binary_chunk() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - - var status = LuaTest.Load(state.L, LUA_SIGNATURE); - - Assert.Equal(LUA_ERRSYNTAX, status); - } - - [Fact] - public void Loadstring_and_dostring_take_nul_terminated_source() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* good = "return 40 + 2"u8) - fixed (byte* bad = "return nil + 1"u8) - { - Assert.Equal(LUA_OK, luaL_loadstring(L, good)); - lua_call(L, 0, 1); - Assert.Equal(42, lua_tointeger(L, -1)); - - Assert.Equal(0, luaL_dostring(L, good)); - Assert.Equal(42, lua_tointeger(L, -1)); - Assert.Equal(1, luaL_dostring(L, bad)); - Assert.Equal(LUA_TSTRING, lua_type(L, -1)); - } - } - - [Fact] - public void Loadfilex_missing_file_returns_errfile_with_a_message() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* path = "cheatengine-sdk-no-such-directory/no-such-file.lua"u8) - { - Assert.Equal(LUA_ERRFILE, luaL_loadfilex(L, path, null)); - Assert.Contains("no-such-file.lua", LuaTest.ReadString(L, -1), StringComparison.Ordinal); - Assert.Equal(LUA_ERRFILE, luaL_loadfile(L, path)); - Assert.Equal(1, luaL_dofile(L, path)); - } - } - - [Fact] - public void Load_pulls_the_chunk_through_a_managed_reader() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* first = "return 6 "u8) - fixed (byte* second = "* 7"u8) - fixed (byte* name = "=pieces"u8) - { - ReaderState pieces = new() { First = first, FirstSize = 9, Second = second, SecondSize = 3 }; - - Assert.Equal(LUA_OK, lua_load(L, &ReadPieces, &pieces, name, null)); - Assert.Equal(3, pieces.Calls); - } - - Assert.Equal(LUA_OK, lua_pcall(L, 0, 1, 0)); - Assert.Equal(42, lua_tointeger(L, -1)); - } - - [Fact] - public void Dump_streams_a_binary_chunk_that_loads_back() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - Assert.Equal(LUA_OK, LuaTest.Load(L, "return 'dumped'"u8)); - - WriterState written = default; - Assert.Equal(0, lua_dump(L, &AppendPiece, &written, 1)); - - Assert.False(written.Overflowed); - Assert.True(new ReadOnlySpan(written.Bytes, 4).SequenceEqual(LUA_SIGNATURE)); - fixed (byte* name = "=dump"u8) - fixed (byte* binaryOnly = "b"u8) - { - Assert.Equal(LUA_OK, luaL_loadbufferx(L, written.Bytes, written.Length, name, binaryOnly)); - } - - Assert.Equal(LUA_OK, lua_pcall(L, 0, 1, 0)); - Assert.Equal("dumped", LuaTest.ReadString(L, -1)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static byte* ReadPieces(lua_State* L, void* ud, nuint* size) - { - var pieces = (ReaderState*)ud; - pieces->Calls++; - switch (pieces->Calls) - { - case 1: - *size = pieces->FirstSize; - return pieces->First; - case 2: - *size = pieces->SecondSize; - return pieces->Second; - default: - *size = 0; - return null; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int AppendPiece(lua_State* L, void* p, nuint sz, void* ud) - { - var written = (WriterState*)ud; - if (sz > WriterState.Capacity - written->Length) - { - written->Overflowed = true; - return 1; - } - - Buffer.MemoryCopy(p, written->Bytes + written->Length, WriterState.Capacity - written->Length, sz); - written->Length += sz; - return 0; - } - - [StructLayout(LayoutKind.Sequential)] - private struct ReaderState - { - public byte* First; - public nuint FirstSize; - public byte* Second; - public nuint SecondSize; - public int Calls; - } - - [StructLayout(LayoutKind.Sequential)] - private struct WriterState - { - public const int Capacity = 4096; - - [SuppressMessage("Meziantou.Analyzer", "MA0189", - Justification = - "This fixed buffer is embedded in native callback state and its unmanaged layout is intentional.")] - public fixed byte Bytes[Capacity]; - - public nuint Length; - public bool Overflowed; - } + [Fact] + public void Pcallk_success_replaces_function_and_arguments_with_results() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaTest.Run(L, "return function(a, b) return a + b, a * b end"u8, 1); + + lua_pushinteger(L, 6); + lua_pushinteger(L, 7); + int status = lua_pcallk(L, 2, 2, 0, 0, null); + + Assert.Equal(LUA_OK, status); + Assert.Equal(2, lua_gettop(L)); + Assert.Equal(13, lua_tointeger(L, 1)); + Assert.Equal(42, lua_tointeger(L, 2)); + } + + [Fact] + public void Pcallk_multret_keeps_every_result() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + Assert.Equal(LUA_OK, LuaTest.Load(L, "return 1, 2, 3, 4, 5"u8)); + Assert.Equal(LUA_OK, lua_pcallk(L, 0, LUA_MULTRET, 0, 0, null)); + + Assert.Equal(5, lua_gettop(L)); + } + + [Fact] + public void Pcallk_runtime_error_returns_errrun_and_exactly_the_message() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + + Assert.Equal(LUA_OK, LuaTest.Load(L, "local depth = ...\nerror('boom from lua')"u8)); + lua_pushinteger(L, 1); + int status = lua_pcallk(L, 1, 3, 0, 0, null); + + Assert.Equal(LUA_ERRRUN, status); + Assert.Equal(1, lua_gettop(L)); + Assert.Equal("test:2: boom from lua", LuaTest.ReadString(L, -1)); + } + + [Fact] + public void Pcallk_error_value_can_be_any_lua_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + + Assert.Equal(LUA_OK, LuaTest.Load(L, "error({ code = 17 })"u8)); + int status = lua_pcall(L, 0, 0, 0); + + Assert.Equal(LUA_ERRRUN, status); + Assert.True(lua_istable(L, -1)); + fixed (byte* code = "code"u8) + { + Assert.Equal(LUA_TNUMBER, lua_getfield(L, -1, code)); + Assert.Equal(17, lua_tointeger(L, -1)); + } + } + + [Fact] + public void Pcallk_message_handler_index_is_the_fourth_argument() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + LuaTest.Run(L, "return function(message) return 'handled: ' .. message end"u8, 1); + int handler = lua_gettop(L); + + Assert.Equal(LUA_OK, LuaTest.Load(L, "local t = nil\nreturn t.field"u8)); + int status = lua_pcallk(L, 0, 0, handler, 0, null); + + Assert.Equal(LUA_ERRRUN, status); + Assert.StartsWith("handled: test:2:", LuaTest.ReadString(L, -1), StringComparison.Ordinal); + Assert.Equal(handler + 1, lua_gettop(L)); + } + + [Fact] + public void Pcallk_reports_a_failing_finalizer_as_errgcmm_although_no_allocation_failed() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + + // What "Raises: memory" means in Lua 5.3: the loop allocates only through string.rep, that is through + // lua_pushlstring called by the native string library. One of those calls runs the collector step that + // finalizes the unreachable table, and the failing __gc unwinds from there to the protected call. + Assert.Equal(LUA_OK, + LuaTest.Load(L, + "setmetatable({}, { __gc = function() error('finalizer failed') end })\nfor i = 1, 1000000 do local s = string.rep('x', 64) end\nreturn 'finished'"u8)); + int status = lua_pcallk(L, 0, 1, 0, 0, null); + + Assert.Equal(LUA_ERRGCMM, status); + Assert.Equal(1, lua_gettop(L)); + Assert.Contains("finalizer failed", LuaTest.ReadString(L, -1), StringComparison.Ordinal); + } + + [Fact] + public void Load_reports_syntax_errors_without_running_anything() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + int status = LuaTest.Load(L, "return +"u8); + + Assert.Equal(LUA_ERRSYNTAX, status); + Assert.Equal(1, lua_gettop(L)); + Assert.StartsWith("test:1:", LuaTest.ReadString(L, -1), StringComparison.Ordinal); + } + + [Fact] + public void Loadbufferx_text_mode_refuses_a_binary_chunk() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + + int status = LuaTest.Load(state.L, LUA_SIGNATURE); + + Assert.Equal(LUA_ERRSYNTAX, status); + } + + [Fact] + public void Loadstring_and_dostring_take_nul_terminated_source() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* good = "return 40 + 2"u8) + fixed (byte* bad = "return nil + 1"u8) + { + Assert.Equal(LUA_OK, luaL_loadstring(L, good)); + lua_call(L, 0, 1); + Assert.Equal(42, lua_tointeger(L, -1)); + + Assert.Equal(0, luaL_dostring(L, good)); + Assert.Equal(42, lua_tointeger(L, -1)); + Assert.Equal(1, luaL_dostring(L, bad)); + Assert.Equal(LUA_TSTRING, lua_type(L, -1)); + } + } + + [Fact] + public void Loadfilex_missing_file_returns_errfile_with_a_message() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* path = "cheatengine-sdk-no-such-directory/no-such-file.lua"u8) + { + Assert.Equal(LUA_ERRFILE, luaL_loadfilex(L, path, null)); + Assert.Contains("no-such-file.lua", LuaTest.ReadString(L, -1), StringComparison.Ordinal); + Assert.Equal(LUA_ERRFILE, luaL_loadfile(L, path)); + Assert.Equal(1, luaL_dofile(L, path)); + } + } + + [Fact] + public void Load_pulls_the_chunk_through_a_managed_reader() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* first = "return 6 "u8) + fixed (byte* second = "* 7"u8) + fixed (byte* name = "=pieces"u8) + { + ReaderState pieces = new() { First = first, FirstSize = 9, Second = second, SecondSize = 3 }; + + Assert.Equal(LUA_OK, lua_load(L, &ReadPieces, &pieces, name, null)); + Assert.Equal(3, pieces.Calls); + } + + Assert.Equal(LUA_OK, lua_pcall(L, 0, 1, 0)); + Assert.Equal(42, lua_tointeger(L, -1)); + } + + [Fact] + public void Dump_streams_a_binary_chunk_that_loads_back() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + Assert.Equal(LUA_OK, LuaTest.Load(L, "return 'dumped'"u8)); + + WriterState written = default; + Assert.Equal(0, lua_dump(L, &AppendPiece, &written, 1)); + + Assert.False(written.Overflowed); + Assert.True(new ReadOnlySpan(written.Bytes, 4).SequenceEqual(LUA_SIGNATURE)); + fixed (byte* name = "=dump"u8) + fixed (byte* binaryOnly = "b"u8) + { + Assert.Equal(LUA_OK, luaL_loadbufferx(L, written.Bytes, written.Length, name, binaryOnly)); + } + + Assert.Equal(LUA_OK, lua_pcall(L, 0, 1, 0)); + Assert.Equal("dumped", LuaTest.ReadString(L, -1)); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static byte* ReadPieces(lua_State* L, void* ud, nuint* size) + { + ReaderState* pieces = (ReaderState*) ud; + pieces->Calls++; + switch (pieces->Calls) + { + case 1: + *size = pieces->FirstSize; + return pieces->First; + case 2: + *size = pieces->SecondSize; + return pieces->Second; + default: + *size = 0; + return null; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int AppendPiece(lua_State* L, void* p, nuint sz, void* ud) + { + WriterState* written = (WriterState*) ud; + if (sz > WriterState.Capacity - written->Length) + { + written->Overflowed = true; + return 1; + } + + Buffer.MemoryCopy(p, written->Bytes + written->Length, WriterState.Capacity - written->Length, sz); + written->Length += sz; + return 0; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ReaderState + { + public byte* First; + public nuint FirstSize; + public byte* Second; + public nuint SecondSize; + public int Calls; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WriterState + { + public const int Capacity = 4096; + + [SuppressMessage("Meziantou.Analyzer", "MA0189", + Justification = + "This fixed buffer is embedded in native callback state and its unmanaged layout is intentional.")] + public fixed byte Bytes[Capacity]; + + public nuint Length; + public bool Overflowed; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs index 862839b5..e7048120 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs @@ -1,8 +1,10 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -14,298 +16,301 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class CallbackTests { - private static int s_hookCalls; - private static int s_hookEvent = -1; - - [Fact] - public void Managed_cfunction_registered_with_pushcclosure_is_called_from_a_lua_chunk() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "managedAdd"u8) - { - lua_pushcclosure(L, &Add, 0); - lua_setglobal(L, name); - } - - LuaTest.Run(L, "return managedAdd(40, 2), managedAdd(0.5, 0.25), managedAdd()"u8, 3); - - Assert.Equal(42, lua_tonumber(L, 1)); - Assert.Equal(0.75, lua_tonumber(L, 2)); - Assert.True(lua_isnil(L, 3)); - } - - [Fact] - public void Pushed_cfunction_is_recognised_and_gives_its_address_back() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - delegate* unmanaged[Cdecl] function = &Add; - - lua_pushcfunction(L, function); - - Assert.True(lua_isfunction(L, -1)); - Assert.Equal(1, lua_iscfunction(L, -1)); - Assert.Equal((nint)function, (nint)lua_tocfunction(L, -1)); - - LuaTest.Run(L, "return function() end"u8, 1); - Assert.Equal(0, lua_iscfunction(L, -1)); - Assert.True(lua_tocfunction(L, -1) is null); - } - - [Fact] - public void Closure_upvalues_are_reached_through_upvalueindex_and_stay_private_to_the_closure() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "nextTicket"u8) - { - lua_pushinteger(L, 100); - lua_pushcclosure(L, &Counter, 1); - lua_setglobal(L, name); - } - - LuaTest.Run(L, "return nextTicket(), nextTicket(), nextTicket()"u8, 3); - - Assert.Equal(101, lua_tointeger(L, 1)); - Assert.Equal(102, lua_tointeger(L, 2)); - Assert.Equal(103, lua_tointeger(L, 3)); - } - - [Fact] - public void Register_and_setfuncs_install_managed_functions() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* global = "add"u8) - fixed (byte* library = "lib"u8) - fixed (byte* first = "add"u8) - fixed (byte* second = "ticket"u8) - { - var functions = stackalloc luaL_Reg[3]; - functions[0] = new luaL_Reg { name = first, func = &Add }; - functions[1] = new luaL_Reg { name = second, func = &Counter }; - functions[2] = default; - - lua_register(L, global, &Add); - lua_newtable(L); - lua_pushinteger(L, 0); - luaL_setfuncs(L, functions, 1); - lua_setglobal(L, library); - } - - LuaTest.Run(L, "return add(1, 2), lib.add(3, 4), lib.ticket(), lib.ticket()"u8, 4); - - Assert.Equal(3, lua_tonumber(L, 1)); - Assert.Equal(7, lua_tonumber(L, 2)); - Assert.Equal(1, lua_tointeger(L, 3)); - Assert.Equal(2, lua_tointeger(L, 4)); - } - - [Fact] - public void Callback_receives_the_state_that_runs_it() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - - fixed (byte* name = "isMainThread"u8) - { - lua_register(L, name, &IsMainThread); - } - - LuaTest.Run(L, "return isMainThread(), coroutine.wrap(isMainThread)()"u8, 2); - - // Inside a coroutine the callback gets the coroutine's lua_State, not the one the chunk was started on. - Assert.Equal(1, lua_toboolean(L, 1)); - Assert.Equal(0, lua_toboolean(L, 2)); - } - - [Fact] - public void Getstack_and_getinfo_describe_the_lua_caller_of_a_callback() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "whereAmI"u8) - { - lua_register(L, name, &DescribeCaller); - } - - // Not "return whereAmI()": a tail call would be a different question about which frame is level 1. - LuaTest.Run(L, - "local function caller(a, b)\n local l, s, w, d, p = whereAmI()\n return l, s, w, d, p\nend\nreturn caller()"u8, - LUA_MULTRET); - - Assert.Equal(5, lua_gettop(L)); - Assert.Equal(2, lua_tointeger(L, 1)); - Assert.Equal("test", LuaTest.ReadString(L, 2)); - Assert.Equal("Lua", LuaTest.ReadString(L, 3)); - Assert.Equal(1, lua_tointeger(L, 4)); - Assert.Equal(2, lua_tointeger(L, 5)); - } - - [Fact] - public void Getinfo_with_the_function_on_the_stack_fills_the_source_fields() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - LuaTest.Run(L, "\n\nreturn function(a, b, c)\n return a\nend"u8, 1); - - lua_Debug record = default; - fixed (byte* what = ">Su"u8) - { - Assert.NotEqual(0, lua_getinfo(L, what, &record)); - } - - Assert.Equal(0, lua_gettop(L)); - Assert.Equal(3, record.linedefined); - Assert.Equal(5, record.lastlinedefined); - Assert.Equal(3, record.nparams); - Assert.Equal(0, record.isvararg); - Assert.Equal("=test", LuaTest.ReadCString(record.source)); - Assert.Equal("test", LuaTest.ReadCString(record.short_src)); - } - - [Fact] - public void Native_debug_record_fits_the_managed_struct() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "recordFits"u8) - { - lua_register(L, name, &DebugRecordStaysInBounds); - } - - LuaTest.Run(L, "return recordFits()"u8, 3); - - // A library built with LUA_IDSIZE above 64 would place i_ci (and the tail of short_src) beyond 128 bytes; one - // built with 56 or less would write i_ci below offset 120. Values from 57 to 64 share the managed layout, so - // this proves that the record fits, not that the native value is exactly 60. - Assert.Equal(1, lua_toboolean(L, 1)); - Assert.Equal(1, lua_toboolean(L, 2)); - Assert.Equal("[C]", LuaTest.ReadString(L, 3)); - } - - [Fact] - public void Count_hook_runs_a_managed_function_inside_the_interpreter() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - delegate* unmanaged[Cdecl] hook = &CountHook; - - lua_sethook(L, hook, LUA_MASKCOUNT, 10); - Assert.Equal((nint)hook, (nint)lua_gethook(L)); - Assert.Equal(LUA_MASKCOUNT, lua_gethookmask(L)); - Assert.Equal(10, lua_gethookcount(L)); - - LuaTest.Run(L, "local n = 0 for i = 1, 1000 do n = n + i end return n"u8, 1); - lua_sethook(L, null, 0, 0); - - Assert.Equal(500500, lua_tointeger(L, -1)); - Assert.True(Volatile.Read(ref s_hookCalls) > 10); - Assert.Equal(LUA_HOOKCOUNT, Volatile.Read(ref s_hookEvent)); - Assert.True(lua_gethook(L) is null); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Add(lua_State* L) - { - // Argument validation by inspection: a managed callback must not use luaL_check*, which raises. - int firstIsNumber; - int secondIsNumber; - var first = lua_tonumberx(L, 1, &firstIsNumber); - var second = lua_tonumberx(L, 2, &secondIsNumber); - if (firstIsNumber == 0 || secondIsNumber == 0) - { - lua_pushnil(L); - return 1; - } - - lua_pushnumber(L, first + second); - return 1; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Counter(lua_State* L) - { - var next = lua_tointegerx(L, lua_upvalueindex(1), null) + 1; - lua_pushinteger(L, next); - lua_copy(L, -1, lua_upvalueindex(1)); - return 1; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int IsMainThread(lua_State* L) - { - var isMain = lua_pushthread(L); - lua_settop(L, -2); - lua_pushboolean(L, isMain); - return 1; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int DescribeCaller(lua_State* L) - { - lua_Debug record = default; - fixed (byte* what = "Slu"u8) - { - // Level 0 is this C function, level 1 the Lua function that called it. - if (lua_getstack(L, 1, &record) == 0 || lua_getinfo(L, what, &record) == 0) return 0; - } - - lua_pushinteger(L, record.currentline); - _ = lua_pushstring(L, record.short_src); - _ = lua_pushstring(L, record.what); - lua_pushinteger(L, record.linedefined); - lua_pushinteger(L, record.nparams); - return 5; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int DebugRecordStaysInBounds(lua_State* L) - { - const int Guarded = 256; - const byte Canary = 0xCC; - var buffer = stackalloc byte[Guarded]; - new Span(buffer, Guarded).Fill(Canary); - var record = (lua_Debug*)buffer; - - int found; - fixed (byte* what = "nSltu"u8) - { - // Level 0 is this function: getstack writes the private i_ci field, getinfo everything else. - found = lua_getstack(L, 0, record) != 0 && lua_getinfo(L, what, record) != 0 ? 1 : 0; - } - - var tailUntouched = - new ReadOnlySpan(buffer + sizeof(lua_Debug), Guarded - sizeof(lua_Debug)).IndexOfAnyExcept(Canary) < - 0; - var callInfoWritten = - record->i_ci is not null && (nuint)record->i_ci != unchecked((nuint)0xCCCC_CCCC_CCCC_CCCC); - - lua_pushboolean(L, found != 0 && tailUntouched ? 1 : 0); - lua_pushboolean(L, callInfoWritten ? 1 : 0); - _ = lua_pushstring(L, record->short_src); - return 3; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void CountHook(lua_State* L, lua_Debug* ar) - { - Volatile.Write(ref s_hookEvent, ar->@event); - Interlocked.Increment(ref s_hookCalls); - } + private static int s_hookCalls; + private static int s_hookEvent = -1; + + [Fact] + public void Managed_cfunction_registered_with_pushcclosure_is_called_from_a_lua_chunk() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "managedAdd"u8) + { + lua_pushcclosure(L, &Add, 0); + lua_setglobal(L, name); + } + + LuaTest.Run(L, "return managedAdd(40, 2), managedAdd(0.5, 0.25), managedAdd()"u8, 3); + + Assert.Equal(42, lua_tonumber(L, 1)); + Assert.Equal(0.75, lua_tonumber(L, 2)); + Assert.True(lua_isnil(L, 3)); + } + + [Fact] + public void Pushed_cfunction_is_recognised_and_gives_its_address_back() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + delegate* unmanaged[Cdecl] function = &Add; + + lua_pushcfunction(L, function); + + Assert.True(lua_isfunction(L, -1)); + Assert.Equal(1, lua_iscfunction(L, -1)); + Assert.Equal((nint) function, (nint) lua_tocfunction(L, -1)); + + LuaTest.Run(L, "return function() end"u8, 1); + Assert.Equal(0, lua_iscfunction(L, -1)); + Assert.True(lua_tocfunction(L, -1) is null); + } + + [Fact] + public void Closure_upvalues_are_reached_through_upvalueindex_and_stay_private_to_the_closure() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "nextTicket"u8) + { + lua_pushinteger(L, 100); + lua_pushcclosure(L, &Counter, 1); + lua_setglobal(L, name); + } + + LuaTest.Run(L, "return nextTicket(), nextTicket(), nextTicket()"u8, 3); + + Assert.Equal(101, lua_tointeger(L, 1)); + Assert.Equal(102, lua_tointeger(L, 2)); + Assert.Equal(103, lua_tointeger(L, 3)); + } + + [Fact] + public void Register_and_setfuncs_install_managed_functions() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* global = "add"u8) + fixed (byte* library = "lib"u8) + fixed (byte* first = "add"u8) + fixed (byte* second = "ticket"u8) + { + luaL_Reg* functions = stackalloc luaL_Reg[3]; + functions[0] = new luaL_Reg { name = first, func = &Add }; + functions[1] = new luaL_Reg { name = second, func = &Counter }; + functions[2] = default; + + lua_register(L, global, &Add); + lua_newtable(L); + lua_pushinteger(L, 0); + luaL_setfuncs(L, functions, 1); + lua_setglobal(L, library); + } + + LuaTest.Run(L, "return add(1, 2), lib.add(3, 4), lib.ticket(), lib.ticket()"u8, 4); + + Assert.Equal(3, lua_tonumber(L, 1)); + Assert.Equal(7, lua_tonumber(L, 2)); + Assert.Equal(1, lua_tointeger(L, 3)); + Assert.Equal(2, lua_tointeger(L, 4)); + } + + [Fact] + public void Callback_receives_the_state_that_runs_it() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + + fixed (byte* name = "isMainThread"u8) + { + lua_register(L, name, &IsMainThread); + } + + LuaTest.Run(L, "return isMainThread(), coroutine.wrap(isMainThread)()"u8, 2); + + // Inside a coroutine the callback gets the coroutine's lua_State, not the one the chunk was started on. + Assert.Equal(1, lua_toboolean(L, 1)); + Assert.Equal(0, lua_toboolean(L, 2)); + } + + [Fact] + public void Getstack_and_getinfo_describe_the_lua_caller_of_a_callback() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "whereAmI"u8) + { + lua_register(L, name, &DescribeCaller); + } + + // Not "return whereAmI()": a tail call would be a different question about which frame is level 1. + LuaTest.Run(L, + "local function caller(a, b)\n local l, s, w, d, p = whereAmI()\n return l, s, w, d, p\nend\nreturn caller()"u8, + LUA_MULTRET); + + Assert.Equal(5, lua_gettop(L)); + Assert.Equal(2, lua_tointeger(L, 1)); + Assert.Equal("test", LuaTest.ReadString(L, 2)); + Assert.Equal("Lua", LuaTest.ReadString(L, 3)); + Assert.Equal(1, lua_tointeger(L, 4)); + Assert.Equal(2, lua_tointeger(L, 5)); + } + + [Fact] + public void Getinfo_with_the_function_on_the_stack_fills_the_source_fields() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaTest.Run(L, "\n\nreturn function(a, b, c)\n return a\nend"u8, 1); + + lua_Debug record = default; + fixed (byte* what = ">Su"u8) + { + Assert.NotEqual(0, lua_getinfo(L, what, &record)); + } + + Assert.Equal(0, lua_gettop(L)); + Assert.Equal(3, record.linedefined); + Assert.Equal(5, record.lastlinedefined); + Assert.Equal(3, record.nparams); + Assert.Equal(0, record.isvararg); + Assert.Equal("=test", LuaTest.ReadCString(record.source)); + Assert.Equal("test", LuaTest.ReadCString(record.short_src)); + } + + [Fact] + public void Native_debug_record_fits_the_managed_struct() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "recordFits"u8) + { + lua_register(L, name, &DebugRecordStaysInBounds); + } + + LuaTest.Run(L, "return recordFits()"u8, 3); + + // A library built with LUA_IDSIZE above 64 would place i_ci (and the tail of short_src) beyond 128 bytes; one + // built with 56 or less would write i_ci below offset 120. Values from 57 to 64 share the managed layout, so + // this proves that the record fits, not that the native value is exactly 60. + Assert.Equal(1, lua_toboolean(L, 1)); + Assert.Equal(1, lua_toboolean(L, 2)); + Assert.Equal("[C]", LuaTest.ReadString(L, 3)); + } + + [Fact] + public void Count_hook_runs_a_managed_function_inside_the_interpreter() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + delegate* unmanaged[Cdecl] hook = &CountHook; + + lua_sethook(L, hook, LUA_MASKCOUNT, 10); + Assert.Equal((nint) hook, (nint) lua_gethook(L)); + Assert.Equal(LUA_MASKCOUNT, lua_gethookmask(L)); + Assert.Equal(10, lua_gethookcount(L)); + + LuaTest.Run(L, "local n = 0 for i = 1, 1000 do n = n + i end return n"u8, 1); + lua_sethook(L, null, 0, 0); + + Assert.Equal(500500, lua_tointeger(L, -1)); + Assert.True(Volatile.Read(ref s_hookCalls) > 10); + Assert.Equal(LUA_HOOKCOUNT, Volatile.Read(ref s_hookEvent)); + Assert.True(lua_gethook(L) is null); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Add(lua_State* L) + { + // Argument validation by inspection: a managed callback must not use luaL_check*, which raises. + int firstIsNumber; + int secondIsNumber; + Double first = lua_tonumberx(L, 1, &firstIsNumber); + Double second = lua_tonumberx(L, 2, &secondIsNumber); + if (firstIsNumber == 0 || secondIsNumber == 0) + { + lua_pushnil(L); + return 1; + } + + lua_pushnumber(L, first + second); + return 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Counter(lua_State* L) + { + long next = lua_tointegerx(L, lua_upvalueindex(1), null) + 1; + lua_pushinteger(L, next); + lua_copy(L, -1, lua_upvalueindex(1)); + return 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int IsMainThread(lua_State* L) + { + int isMain = lua_pushthread(L); + lua_settop(L, -2); + lua_pushboolean(L, isMain); + return 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int DescribeCaller(lua_State* L) + { + lua_Debug record = default; + fixed (byte* what = "Slu"u8) + { + // Level 0 is this C function, level 1 the Lua function that called it. + if (lua_getstack(L, 1, &record) == 0 || lua_getinfo(L, what, &record) == 0) + { + return 0; + } + } + + lua_pushinteger(L, record.currentline); + _ = lua_pushstring(L, record.short_src); + _ = lua_pushstring(L, record.what); + lua_pushinteger(L, record.linedefined); + lua_pushinteger(L, record.nparams); + return 5; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int DebugRecordStaysInBounds(lua_State* L) + { + const int Guarded = 256; + const byte Canary = 0xCC; + byte* buffer = stackalloc byte[Guarded]; + new Span(buffer, Guarded).Fill(Canary); + lua_Debug* record = (lua_Debug*) buffer; + + int found; + fixed (byte* what = "nSltu"u8) + { + // Level 0 is this function: getstack writes the private i_ci field, getinfo everything else. + found = lua_getstack(L, 0, record) != 0 && lua_getinfo(L, what, record) != 0 ? 1 : 0; + } + + bool tailUntouched = + new ReadOnlySpan(buffer + sizeof(lua_Debug), Guarded - sizeof(lua_Debug)).IndexOfAnyExcept(Canary) < + 0; + bool callInfoWritten = + record->i_ci is not null && (nuint) record->i_ci != unchecked((nuint) 0xCCCC_CCCC_CCCC_CCCC); + + lua_pushboolean(L, found != 0 && tailUntouched ? 1 : 0); + lua_pushboolean(L, callInfoWritten ? 1 : 0); + _ = lua_pushstring(L, record->short_src); + return 3; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void CountHook(lua_State* L, lua_Debug* ar) + { + Volatile.Write(ref s_hookEvent, ar->@event); + Interlocked.Increment(ref s_hookCalls); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CoroutineTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CoroutineTests.cs index 2bce0963..1c1b732c 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CoroutineTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CoroutineTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,52 +9,52 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class CoroutineTests { - [Fact] - public void Resume_runs_to_the_yield_then_to_the_end() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - var coroutine = lua_newthread(L); - Assert.Equal(LUA_OK, LuaTest.Load(coroutine, "local received = coroutine.yield(10)\nreturn received + 1"u8)); - - var first = lua_resume(coroutine, L, 0); - - Assert.Equal(LUA_YIELD, first); - Assert.Equal(LUA_YIELD, lua_status(coroutine)); - Assert.Equal(10, lua_tointeger(coroutine, -1)); - - lua_pop(coroutine, 1); - lua_pushinteger(coroutine, 41); - var second = lua_resume(coroutine, L, 1); - - Assert.Equal(LUA_OK, second); - Assert.Equal(LUA_OK, lua_status(coroutine)); - Assert.Equal(42, lua_tointeger(coroutine, -1)); - } - - [Fact] - public void Resume_returns_the_error_status_instead_of_raising() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - var coroutine = lua_newthread(L); - Assert.Equal(LUA_OK, LuaTest.Load(coroutine, "error('inside coroutine')"u8)); - - var status = lua_resume(coroutine, L, 0); - - Assert.Equal(LUA_ERRRUN, status); - Assert.Equal("test:1: inside coroutine", LuaTest.ReadString(coroutine, -1)); - Assert.Equal(LUA_ERRRUN, lua_status(coroutine)); - } - - [Fact] - public void Main_thread_is_not_yieldable() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - - Assert.Equal(0, lua_isyieldable(state.L)); - } + [Fact] + public void Resume_runs_to_the_yield_then_to_the_end() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + lua_State* coroutine = lua_newthread(L); + Assert.Equal(LUA_OK, LuaTest.Load(coroutine, "local received = coroutine.yield(10)\nreturn received + 1"u8)); + + int first = lua_resume(coroutine, L, 0); + + Assert.Equal(LUA_YIELD, first); + Assert.Equal(LUA_YIELD, lua_status(coroutine)); + Assert.Equal(10, lua_tointeger(coroutine, -1)); + + lua_pop(coroutine, 1); + lua_pushinteger(coroutine, 41); + int second = lua_resume(coroutine, L, 1); + + Assert.Equal(LUA_OK, second); + Assert.Equal(LUA_OK, lua_status(coroutine)); + Assert.Equal(42, lua_tointeger(coroutine, -1)); + } + + [Fact] + public void Resume_returns_the_error_status_instead_of_raising() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + lua_State* coroutine = lua_newthread(L); + Assert.Equal(LUA_OK, LuaTest.Load(coroutine, "error('inside coroutine')"u8)); + + int status = lua_resume(coroutine, L, 0); + + Assert.Equal(LUA_ERRRUN, status); + Assert.Equal("test:1: inside coroutine", LuaTest.ReadString(coroutine, -1)); + Assert.Equal(LUA_ERRRUN, lua_status(coroutine)); + } + + [Fact] + public void Main_thread_is_not_yieldable() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + + Assert.Equal(0, lua_isyieldable(state.L)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/DebugTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/DebugTests.cs index 1c5e050b..1191e6d1 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/DebugTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/DebugTests.cs @@ -1,8 +1,10 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -15,128 +17,134 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class DebugTests { - [Fact] - public void Getupvalue_and_setupvalue_name_read_and_replace_an_upvalue_of_a_lua_closure() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - // The nil keeps the closure off index 1: (funcindex 2, n 1) and (funcindex 1, n 2) are different questions. - lua_pushnil(L); - LuaTest.Run(L, "local counter = 5\nreturn function() counter = counter + 1 return counter end"u8, 1); - - var readName = lua_getupvalue(L, 2, 1); - Assert.Equal("counter", LuaTest.ReadCString(readName)); - Assert.Equal(3, lua_gettop(L)); - Assert.Equal(5, lua_tointeger(L, -1)); - lua_pop(L, 1); - - Assert.True(lua_getupvalue(L, 2, 2) is null); - Assert.Equal(2, lua_gettop(L)); - - lua_pushinteger(L, 40); - var writtenName = lua_setupvalue(L, 2, 1); - Assert.Equal("counter", LuaTest.ReadCString(writtenName)); - Assert.Equal(2, lua_gettop(L)); - - // Out of range: null, and the value stays on the stack. - lua_pushinteger(L, 0); - Assert.True(lua_setupvalue(L, 2, 2) is null); - Assert.Equal(3, lua_gettop(L)); - lua_pop(L, 1); - - lua_pushvalue(L, 2); - Assert.Equal(LUA_OK, lua_pcall(L, 0, 1, 0)); - Assert.Equal(41, lua_tointeger(L, -1)); - } - - [Fact] - public void Upvalueid_and_upvaluejoin_make_two_closures_share_an_upvalue() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - LuaTest.Run(L, - "local a, b, c = 1, 2, 3\nlocal function first() return a, b end\nlocal function second() return c end\nreturn first, second"u8, - 2); - - var ownedBySecond = lua_upvalueid(L, 2, 1); - Assert.True(ownedBySecond is not null); - Assert.True(lua_upvalueid(L, 1, 1) != ownedBySecond); - Assert.True(lua_upvalueid(L, 1, 2) != ownedBySecond); - - // Upvalue 2 of the first closure (b) now refers to upvalue 1 of the second (c). Any permutation of - // (1, 2, 2, 1) that is not the declared order leaves "first" returning 1, 2. - lua_upvaluejoin(L, 1, 2, 2, 1); - - Assert.True(lua_upvalueid(L, 1, 2) == ownedBySecond); - Assert.True(lua_upvalueid(L, 1, 1) != ownedBySecond); - lua_pushvalue(L, 1); - Assert.Equal(LUA_OK, lua_pcall(L, 0, 2, 0)); - Assert.Equal(1, lua_tointeger(L, -2)); - Assert.Equal(3, lua_tointeger(L, -1)); - } - - [Fact] - public void Getlocal_and_setlocal_reach_the_locals_of_the_lua_caller_of_a_callback() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "touchLocals"u8) - { - lua_register(L, name, &TouchCallerLocals); - } - - // "value", "readName" and "writtenName" only become locals after the call: during it the caller has three. - LuaTest.Run(L, - "local function caller(first, second)\n local third = 30\n local value, readName, writtenName = touchLocals()\n return value, readName, writtenName, third\nend\nreturn caller(10, 20)"u8, - LUA_MULTRET); - - Assert.Equal(4, lua_gettop(L)); - Assert.Equal(20, lua_tointeger(L, 1)); - Assert.Equal("second", LuaTest.ReadString(L, 2)); - Assert.Equal("third", LuaTest.ReadString(L, 3)); - Assert.Equal(99, lua_tointeger(L, 4)); - } - - [Fact] - public void Getlocal_without_an_activation_record_names_the_parameters_of_the_function_on_top() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - LuaTest.Run(L, "return function(alpha, beta) local gamma = alpha end"u8, 1); - - Assert.Equal("alpha", LuaTest.ReadCString(lua_getlocal(L, null, 1))); - Assert.Equal("beta", LuaTest.ReadCString(lua_getlocal(L, null, 2))); - Assert.True(lua_getlocal(L, null, 3) is null); - - // This form only names: nothing is pushed, the function stays where it was. - Assert.Equal(1, lua_gettop(L)); - Assert.True(lua_isfunction(L, -1)); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int TouchCallerLocals(lua_State* L) - { - lua_Debug record = default; - - // Level 0 is this C function, level 1 the Lua function that called it. - if (lua_getstack(L, 1, &record) == 0) return 0; - - // Pushes the value of local 2 ("second"). - var readName = lua_getlocal(L, &record, 2); - if (readName is null) return 0; - - // Pops 99 into local 3 ("third"). A null name reaches the chunk as nil. - lua_pushinteger(L, 99); - var writtenName = lua_setlocal(L, &record, 3); - - _ = lua_pushstring(L, readName); - _ = lua_pushstring(L, writtenName); - return 3; - } + [Fact] + public void Getupvalue_and_setupvalue_name_read_and_replace_an_upvalue_of_a_lua_closure() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + // The nil keeps the closure off index 1: (funcindex 2, n 1) and (funcindex 1, n 2) are different questions. + lua_pushnil(L); + LuaTest.Run(L, "local counter = 5\nreturn function() counter = counter + 1 return counter end"u8, 1); + + byte* readName = lua_getupvalue(L, 2, 1); + Assert.Equal("counter", LuaTest.ReadCString(readName)); + Assert.Equal(3, lua_gettop(L)); + Assert.Equal(5, lua_tointeger(L, -1)); + lua_pop(L, 1); + + Assert.True(lua_getupvalue(L, 2, 2) is null); + Assert.Equal(2, lua_gettop(L)); + + lua_pushinteger(L, 40); + byte* writtenName = lua_setupvalue(L, 2, 1); + Assert.Equal("counter", LuaTest.ReadCString(writtenName)); + Assert.Equal(2, lua_gettop(L)); + + // Out of range: null, and the value stays on the stack. + lua_pushinteger(L, 0); + Assert.True(lua_setupvalue(L, 2, 2) is null); + Assert.Equal(3, lua_gettop(L)); + lua_pop(L, 1); + + lua_pushvalue(L, 2); + Assert.Equal(LUA_OK, lua_pcall(L, 0, 1, 0)); + Assert.Equal(41, lua_tointeger(L, -1)); + } + + [Fact] + public void Upvalueid_and_upvaluejoin_make_two_closures_share_an_upvalue() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaTest.Run(L, + "local a, b, c = 1, 2, 3\nlocal function first() return a, b end\nlocal function second() return c end\nreturn first, second"u8, + 2); + + void* ownedBySecond = lua_upvalueid(L, 2, 1); + Assert.True(ownedBySecond is not null); + Assert.True(lua_upvalueid(L, 1, 1) != ownedBySecond); + Assert.True(lua_upvalueid(L, 1, 2) != ownedBySecond); + + // Upvalue 2 of the first closure (b) now refers to upvalue 1 of the second (c). Any permutation of + // (1, 2, 2, 1) that is not the declared order leaves "first" returning 1, 2. + lua_upvaluejoin(L, 1, 2, 2, 1); + + Assert.True(lua_upvalueid(L, 1, 2) == ownedBySecond); + Assert.True(lua_upvalueid(L, 1, 1) != ownedBySecond); + lua_pushvalue(L, 1); + Assert.Equal(LUA_OK, lua_pcall(L, 0, 2, 0)); + Assert.Equal(1, lua_tointeger(L, -2)); + Assert.Equal(3, lua_tointeger(L, -1)); + } + + [Fact] + public void Getlocal_and_setlocal_reach_the_locals_of_the_lua_caller_of_a_callback() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "touchLocals"u8) + { + lua_register(L, name, &TouchCallerLocals); + } + + // "value", "readName" and "writtenName" only become locals after the call: during it the caller has three. + LuaTest.Run(L, + "local function caller(first, second)\n local third = 30\n local value, readName, writtenName = touchLocals()\n return value, readName, writtenName, third\nend\nreturn caller(10, 20)"u8, + LUA_MULTRET); + + Assert.Equal(4, lua_gettop(L)); + Assert.Equal(20, lua_tointeger(L, 1)); + Assert.Equal("second", LuaTest.ReadString(L, 2)); + Assert.Equal("third", LuaTest.ReadString(L, 3)); + Assert.Equal(99, lua_tointeger(L, 4)); + } + + [Fact] + public void Getlocal_without_an_activation_record_names_the_parameters_of_the_function_on_top() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaTest.Run(L, "return function(alpha, beta) local gamma = alpha end"u8, 1); + + Assert.Equal("alpha", LuaTest.ReadCString(lua_getlocal(L, null, 1))); + Assert.Equal("beta", LuaTest.ReadCString(lua_getlocal(L, null, 2))); + Assert.True(lua_getlocal(L, null, 3) is null); + + // This form only names: nothing is pushed, the function stays where it was. + Assert.Equal(1, lua_gettop(L)); + Assert.True(lua_isfunction(L, -1)); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int TouchCallerLocals(lua_State* L) + { + lua_Debug record = default; + + // Level 0 is this C function, level 1 the Lua function that called it. + if (lua_getstack(L, 1, &record) == 0) + { + return 0; + } + + // Pushes the value of local 2 ("second"). + byte* readName = lua_getlocal(L, &record, 2); + if (readName is null) + { + return 0; + } + + // Pops 99 into local 3 ("third"). A null name reaches the chunk as nil. + lua_pushinteger(L, 99); + byte* writtenName = lua_setlocal(L, &record, 3); + + _ = lua_pushstring(L, readName); + _ = lua_pushstring(L, writtenName); + return 3; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/LibraryTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/LibraryTests.cs index 5a22b8e8..dd0c9fad 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/LibraryTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/LibraryTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,92 +9,93 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class LibraryTests { - [Fact] - public void Bare_state_has_no_libraries_and_openlibs_adds_them() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "string"u8) - { - Assert.Equal(LUA_TNIL, lua_getglobal(L, name)); - luaL_openlibs(L); - Assert.Equal(LUA_TTABLE, lua_getglobal(L, name)); - } - } - - [Fact] - public void Requiref_opens_one_library_into_a_sandboxed_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* baseName = "_G"u8) - fixed (byte* stringName = LUA_STRLIBNAME) - fixed (byte* osName = LUA_OSLIBNAME) - { - luaL_requiref(L, baseName, luaopen_base, 1); - luaL_requiref(L, stringName, luaopen_string, 1); - lua_pop(L, 2); - - LuaTest.Run(L, "return string.rep('ab', 3), type(print), os"u8, 3); - - Assert.Equal("ababab", LuaTest.ReadString(L, 1)); - Assert.Equal("function", LuaTest.ReadString(L, 2)); - Assert.True(lua_isnil(L, 3)); - Assert.Equal(LUA_TNIL, lua_getglobal(L, osName)); - } - } - - [Fact] - public void Every_opener_is_a_distinct_function_of_the_module() - { - LuaTest.RequireNativeLua(); - - nint[] openers = - [ - (nint)luaopen_base, (nint)luaopen_coroutine, (nint)luaopen_table, (nint)luaopen_io, (nint)luaopen_os, - (nint)luaopen_string, (nint)luaopen_utf8, (nint)luaopen_math, (nint)luaopen_debug, (nint)luaopen_package - ]; - - Assert.DoesNotContain(0, openers); - Assert.Equal(openers.Length, openers.Distinct().Count()); - } - - [Fact] - public void Traceback_describes_the_stack_with_the_message_first() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* message = "while testing"u8) - { - luaL_traceback(L, L, message, 0); - } - - var traceback = LuaTest.ReadString(L, -1); - Assert.NotNull(traceback); - Assert.StartsWith("while testing\nstack traceback:", traceback, StringComparison.Ordinal); - } - - [Fact] - public void Callmeta_runs_a_metamethod_by_name() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - LuaTest.Run(L, "return setmetatable({}, { __describe = function(self) return 'described' end })"u8, 1); - - fixed (byte* present = "__describe"u8) - fixed (byte* absent = "__missing"u8) - { - Assert.Equal(0, luaL_callmeta(L, 1, absent)); - Assert.Equal(1, lua_gettop(L)); - Assert.Equal(1, luaL_callmeta(L, 1, present)); - Assert.Equal("described", LuaTest.ReadString(L, -1)); - } - } + [Fact] + public void Bare_state_has_no_libraries_and_openlibs_adds_them() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "string"u8) + { + Assert.Equal(LUA_TNIL, lua_getglobal(L, name)); + luaL_openlibs(L); + Assert.Equal(LUA_TTABLE, lua_getglobal(L, name)); + } + } + + [Fact] + public void Requiref_opens_one_library_into_a_sandboxed_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* baseName = "_G"u8) + fixed (byte* stringName = LUA_STRLIBNAME) + fixed (byte* osName = LUA_OSLIBNAME) + { + luaL_requiref(L, baseName, luaopen_base, 1); + luaL_requiref(L, stringName, luaopen_string, 1); + lua_pop(L, 2); + + LuaTest.Run(L, "return string.rep('ab', 3), type(print), os"u8, 3); + + Assert.Equal("ababab", LuaTest.ReadString(L, 1)); + Assert.Equal("function", LuaTest.ReadString(L, 2)); + Assert.True(lua_isnil(L, 3)); + Assert.Equal(LUA_TNIL, lua_getglobal(L, osName)); + } + } + + [Fact] + public void Every_opener_is_a_distinct_function_of_the_module() + { + LuaTest.RequireNativeLua(); + + nint[] openers = + [ + (nint) luaopen_base, (nint) luaopen_coroutine, (nint) luaopen_table, (nint) luaopen_io, (nint) luaopen_os, + (nint) luaopen_string, (nint) luaopen_utf8, (nint) luaopen_math, (nint) luaopen_debug, + (nint) luaopen_package + ]; + + Assert.DoesNotContain(0, openers); + Assert.Equal(openers.Length, openers.Distinct().Count()); + } + + [Fact] + public void Traceback_describes_the_stack_with_the_message_first() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* message = "while testing"u8) + { + luaL_traceback(L, L, message, 0); + } + + string? traceback = LuaTest.ReadString(L, -1); + Assert.NotNull(traceback); + Assert.StartsWith("while testing\nstack traceback:", traceback, StringComparison.Ordinal); + } + + [Fact] + public void Callmeta_runs_a_metamethod_by_name() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + LuaTest.Run(L, "return setmetatable({}, { __describe = function(self) return 'described' end })"u8, 1); + + fixed (byte* present = "__describe"u8) + fixed (byte* absent = "__missing"u8) + { + Assert.Equal(0, luaL_callmeta(L, 1, absent)); + Assert.Equal(1, lua_gettop(L)); + Assert.Equal(1, luaL_callmeta(L, 1, present)); + Assert.Equal("described", LuaTest.ReadString(L, -1)); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/OperatorTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/OperatorTests.cs index 5c214c7d..3fe1cd33 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/OperatorTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/OperatorTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,108 +9,108 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class OperatorTests { - [Fact] - public void Arith_applies_binary_and_unary_operators() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushinteger(L, 40); - lua_pushinteger(L, 2); - lua_arith(L, LUA_OPADD); - Assert.Equal(1, lua_gettop(L)); - Assert.Equal(42, lua_tointeger(L, -1)); - - lua_arith(L, LUA_OPUNM); - Assert.Equal(1, lua_gettop(L)); - Assert.Equal(-42, lua_tointeger(L, -1)); - - lua_pushinteger(L, 5); - lua_arith(L, LUA_OPIDIV); - Assert.Equal(-9, lua_tointeger(L, -1)); - } - - [Fact] - public void Compare_and_rawequal_follow_lua_semantics() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushinteger(L, 1); - lua_pushnumber(L, 1.0); - lua_pushinteger(L, 2); - - Assert.Equal(1, lua_compare(L, 1, 2, LUA_OPEQ)); - Assert.Equal(1, lua_rawequal(L, 1, 2)); - Assert.Equal(1, lua_compare(L, 1, 3, LUA_OPLT)); - Assert.Equal(1, lua_compare(L, 1, 2, LUA_OPLE)); - Assert.Equal(0, lua_compare(L, 3, 1, LUA_OPLE)); - Assert.Equal(0, lua_rawequal(L, 1, 3)); - Assert.Equal(0, lua_rawequal(L, 1, 9)); - } - - [Fact] - public void Concat_joins_strings_and_numbers() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushliteral(L, "vt"u8); - lua_pushliteral(L, "Dword="u8); - lua_pushinteger(L, 2); - lua_concat(L, 3); - - Assert.Equal(1, lua_gettop(L)); - Assert.Equal("vtDword=2", LuaTest.ReadString(L, -1)); - } - - [Fact] - public void Len_and_luaL_len_report_the_sequence_length() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - LuaTest.Run(L, "return { 'a', 'b', 'c' }"u8, 1); - - lua_len(L, 1); - - Assert.Equal(3, lua_tointeger(L, -1)); - Assert.Equal(3, luaL_len(L, 1)); - Assert.Equal((nuint)3, lua_rawlen(L, 1)); - } - - [Fact] - public void Stringtonumber_pushes_on_success_only() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* numeral = "0x10"u8) - fixed (byte* garbage = "10 apples"u8) - { - Assert.Equal((nuint)5, lua_stringtonumber(L, numeral)); - Assert.Equal(16, lua_tointeger(L, -1)); - Assert.Equal((nuint)0, lua_stringtonumber(L, garbage)); - Assert.Equal(1, lua_gettop(L)); - } - } - - [Fact] - public void Gc_reports_memory_and_collects() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - Assert.Equal(1, lua_gc(L, LUA_GCISRUNNING, 0)); - Assert.True(lua_gc(L, LUA_GCCOUNT, 0) > 0); - Assert.InRange(lua_gc(L, LUA_GCCOUNTB, 0), 0, 1023); - Assert.Equal(0, lua_gc(L, LUA_GCCOLLECT, 0)); - Assert.Equal(0, lua_gc(L, LUA_GCSTOP, 0)); - Assert.Equal(0, lua_gc(L, LUA_GCISRUNNING, 0)); - } + [Fact] + public void Arith_applies_binary_and_unary_operators() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushinteger(L, 40); + lua_pushinteger(L, 2); + lua_arith(L, LUA_OPADD); + Assert.Equal(1, lua_gettop(L)); + Assert.Equal(42, lua_tointeger(L, -1)); + + lua_arith(L, LUA_OPUNM); + Assert.Equal(1, lua_gettop(L)); + Assert.Equal(-42, lua_tointeger(L, -1)); + + lua_pushinteger(L, 5); + lua_arith(L, LUA_OPIDIV); + Assert.Equal(-9, lua_tointeger(L, -1)); + } + + [Fact] + public void Compare_and_rawequal_follow_lua_semantics() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushinteger(L, 1); + lua_pushnumber(L, 1.0); + lua_pushinteger(L, 2); + + Assert.Equal(1, lua_compare(L, 1, 2, LUA_OPEQ)); + Assert.Equal(1, lua_rawequal(L, 1, 2)); + Assert.Equal(1, lua_compare(L, 1, 3, LUA_OPLT)); + Assert.Equal(1, lua_compare(L, 1, 2, LUA_OPLE)); + Assert.Equal(0, lua_compare(L, 3, 1, LUA_OPLE)); + Assert.Equal(0, lua_rawequal(L, 1, 3)); + Assert.Equal(0, lua_rawequal(L, 1, 9)); + } + + [Fact] + public void Concat_joins_strings_and_numbers() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushliteral(L, "vt"u8); + lua_pushliteral(L, "Dword="u8); + lua_pushinteger(L, 2); + lua_concat(L, 3); + + Assert.Equal(1, lua_gettop(L)); + Assert.Equal("vtDword=2", LuaTest.ReadString(L, -1)); + } + + [Fact] + public void Len_and_luaL_len_report_the_sequence_length() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaTest.Run(L, "return { 'a', 'b', 'c' }"u8, 1); + + lua_len(L, 1); + + Assert.Equal(3, lua_tointeger(L, -1)); + Assert.Equal(3, luaL_len(L, 1)); + Assert.Equal((nuint) 3, lua_rawlen(L, 1)); + } + + [Fact] + public void Stringtonumber_pushes_on_success_only() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* numeral = "0x10"u8) + fixed (byte* garbage = "10 apples"u8) + { + Assert.Equal((nuint) 5, lua_stringtonumber(L, numeral)); + Assert.Equal(16, lua_tointeger(L, -1)); + Assert.Equal((nuint) 0, lua_stringtonumber(L, garbage)); + Assert.Equal(1, lua_gettop(L)); + } + } + + [Fact] + public void Gc_reports_memory_and_collects() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + Assert.Equal(1, lua_gc(L, LUA_GCISRUNNING, 0)); + Assert.True(lua_gc(L, LUA_GCCOUNT, 0) > 0); + Assert.InRange(lua_gc(L, LUA_GCCOUNTB, 0), 0, 1023); + Assert.Equal(0, lua_gc(L, LUA_GCCOLLECT, 0)); + Assert.Equal(0, lua_gc(L, LUA_GCSTOP, 0)); + Assert.Equal(0, lua_gc(L, LUA_GCISRUNNING, 0)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ReferenceTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ReferenceTests.cs index 0205ce8a..b1f1669c 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ReferenceTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ReferenceTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,73 +9,73 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class ReferenceTests { - [Fact] - public void Ref_pops_the_value_and_rawgeti_brings_it_back() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_newtable(L); - var identity = lua_topointer(L, -1); - var reference = luaL_ref(L, LUA_REGISTRYINDEX); - - Assert.Equal(0, lua_gettop(L)); - Assert.True(reference > LUA_RIDX_LAST); - Assert.Equal(LUA_TTABLE, lua_rawgeti(L, LUA_REGISTRYINDEX, reference)); - Assert.True(lua_topointer(L, -1) == identity); - - luaL_unref(L, LUA_REGISTRYINDEX, reference); - Assert.Equal(1, lua_gettop(L)); - } - - [Fact] - public void Ref_of_nil_is_refnil_and_stores_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushnil(L); - var reference = luaL_ref(L, LUA_REGISTRYINDEX); - - Assert.Equal(LUA_REFNIL, reference); - Assert.Equal(0, lua_gettop(L)); - } - - [Fact] - public void Unref_ignores_the_marker_values_and_frees_the_slot_for_reuse() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - luaL_unref(L, LUA_REGISTRYINDEX, LUA_NOREF); - luaL_unref(L, LUA_REGISTRYINDEX, LUA_REFNIL); - - lua_pushinteger(L, 1); - var first = luaL_ref(L, LUA_REGISTRYINDEX); - luaL_unref(L, LUA_REGISTRYINDEX, first); - lua_pushinteger(L, 2); - var second = luaL_ref(L, LUA_REGISTRYINDEX); - - Assert.Equal(first, second); - Assert.Equal(LUA_TNUMBER, lua_rawgeti(L, LUA_REGISTRYINDEX, second)); - Assert.Equal(2, lua_tointeger(L, -1)); - } - - [Fact] - public void Reference_is_shared_by_the_threads_of_one_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - var thread = lua_newthread(L); - - lua_pushliteral(L, "shared"u8); - var reference = luaL_ref(L, LUA_REGISTRYINDEX); - - Assert.Equal(LUA_TSTRING, lua_rawgeti(thread, LUA_REGISTRYINDEX, reference)); - Assert.Equal("shared", LuaTest.ReadString(thread, -1)); - } + [Fact] + public void Ref_pops_the_value_and_rawgeti_brings_it_back() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_newtable(L); + void* identity = lua_topointer(L, -1); + int reference = luaL_ref(L, LUA_REGISTRYINDEX); + + Assert.Equal(0, lua_gettop(L)); + Assert.True(reference > LUA_RIDX_LAST); + Assert.Equal(LUA_TTABLE, lua_rawgeti(L, LUA_REGISTRYINDEX, reference)); + Assert.True(lua_topointer(L, -1) == identity); + + luaL_unref(L, LUA_REGISTRYINDEX, reference); + Assert.Equal(1, lua_gettop(L)); + } + + [Fact] + public void Ref_of_nil_is_refnil_and_stores_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushnil(L); + int reference = luaL_ref(L, LUA_REGISTRYINDEX); + + Assert.Equal(LUA_REFNIL, reference); + Assert.Equal(0, lua_gettop(L)); + } + + [Fact] + public void Unref_ignores_the_marker_values_and_frees_the_slot_for_reuse() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + luaL_unref(L, LUA_REGISTRYINDEX, LUA_NOREF); + luaL_unref(L, LUA_REGISTRYINDEX, LUA_REFNIL); + + lua_pushinteger(L, 1); + int first = luaL_ref(L, LUA_REGISTRYINDEX); + luaL_unref(L, LUA_REGISTRYINDEX, first); + lua_pushinteger(L, 2); + int second = luaL_ref(L, LUA_REGISTRYINDEX); + + Assert.Equal(first, second); + Assert.Equal(LUA_TNUMBER, lua_rawgeti(L, LUA_REGISTRYINDEX, second)); + Assert.Equal(2, lua_tointeger(L, -1)); + } + + [Fact] + public void Reference_is_shared_by_the_threads_of_one_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + lua_State* thread = lua_newthread(L); + + lua_pushliteral(L, "shared"u8); + int reference = luaL_ref(L, LUA_REGISTRYINDEX); + + Assert.Equal(LUA_TSTRING, lua_rawgeti(thread, LUA_REGISTRYINDEX, reference)); + Assert.Equal("shared", LuaTest.ReadString(thread, -1)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ScalarTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ScalarTests.cs index a993e9d9..42ae796b 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ScalarTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/ScalarTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,167 +9,167 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class ScalarTests { - [Fact] - public void Nil_round_trips() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushnil(L); - - Assert.Equal(LUA_TNIL, lua_type(L, -1)); - Assert.True(lua_isnil(L, -1)); - Assert.True(lua_isnoneornil(L, -1)); - Assert.False(lua_isnone(L, -1)); - Assert.Equal(0, lua_toboolean(L, -1)); - Assert.Equal(LUA_TNONE, lua_type(L, 2)); - Assert.True(lua_isnone(L, 2)); - } - - [Theory] - [InlineData(0, 0)] - [InlineData(1, 1)] - [InlineData(-5, 1)] - public void Boolean_round_trips_as_c_truth_value(int pushed, int expected) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushboolean(L, pushed); - - Assert.Equal(LUA_TBOOLEAN, lua_type(L, -1)); - Assert.True(lua_isboolean(L, -1)); - Assert.Equal(expected, lua_toboolean(L, -1)); - } - - [Theory] - [InlineData(0L)] - [InlineData(42L)] - [InlineData(-1L)] - [InlineData(0x7FFF_FFFF_FFFFL)] - [InlineData(long.MaxValue)] - [InlineData(long.MinValue)] - public void Integer_round_trips_with_all_64_bits(long value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushinteger(L, value); - - int isNumber; - Assert.Equal(LUA_TNUMBER, lua_type(L, -1)); - Assert.Equal(1, lua_isinteger(L, -1)); - Assert.Equal(1, lua_isnumber(L, -1)); - Assert.Equal(value, lua_tointegerx(L, -1, &isNumber)); - Assert.Equal(1, isNumber); - Assert.Equal(value, lua_tointeger(L, -1)); - } - - [Theory] - [InlineData(0.0)] - [InlineData(0.1)] - [InlineData(-1234.5678)] - [InlineData(1e300)] - [InlineData(double.PositiveInfinity)] - public void Number_round_trips_bit_exact(double value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushnumber(L, value); - - int isNumber; - Assert.Equal(LUA_TNUMBER, lua_type(L, -1)); - Assert.Equal(0, lua_isinteger(L, -1)); - Assert.Equal(value, lua_tonumberx(L, -1, &isNumber)); - Assert.Equal(1, isNumber); - Assert.Equal(value, lua_tonumber(L, -1)); - } - - [Fact] - public void Float_with_integral_value_converts_to_integer_but_fraction_does_not() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - int isNumber; - - lua_pushnumber(L, 3.0); - Assert.Equal(3, lua_tointegerx(L, -1, &isNumber)); - Assert.Equal(1, isNumber); - - lua_pushnumber(L, 3.5); - Assert.Equal(0, lua_tointegerx(L, -1, &isNumber)); - Assert.Equal(0, isNumber); - } - - [Fact] - public void Conversion_flag_reports_non_numbers() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - var isNumber = 1; - - lua_pushboolean(L, 1); - - Assert.Equal(0, lua_tointegerx(L, -1, &isNumber)); - Assert.Equal(0, isNumber); - isNumber = 1; - Assert.Equal(0.0, lua_tonumberx(L, -1, &isNumber)); - Assert.Equal(0, isNumber); - Assert.Equal(0, lua_isnumber(L, -1)); - } - - [Fact] - public void Light_userdata_round_trips_the_pointer() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - var pointer = (void*)unchecked((nint)0x7FFE_1234_5678_9AB0); - - lua_pushlightuserdata(L, pointer); - - Assert.Equal(LUA_TLIGHTUSERDATA, lua_type(L, -1)); - Assert.True(lua_islightuserdata(L, -1)); - Assert.Equal(1, lua_isuserdata(L, -1)); - Assert.True(lua_touserdata(L, -1) == pointer); - Assert.Equal((nuint)0, lua_rawlen(L, -1)); - } - - [Theory] - [InlineData(LUA_TNIL, "nil")] - [InlineData(LUA_TBOOLEAN, "boolean")] - [InlineData(LUA_TLIGHTUSERDATA, "userdata")] - [InlineData(LUA_TNUMBER, "number")] - [InlineData(LUA_TSTRING, "string")] - [InlineData(LUA_TTABLE, "table")] - [InlineData(LUA_TFUNCTION, "function")] - [InlineData(LUA_TUSERDATA, "userdata")] - [InlineData(LUA_TTHREAD, "thread")] - [InlineData(LUA_TNONE, "no value")] - public void Typename_names_every_tag(int tag, string expected) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - - Assert.Equal(expected, LuaTest.ReadCString(lua_typename(state.L, tag))); - } - - [Fact] - public void LuaL_typename_names_the_value_at_an_index() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_newtable(L); - - Assert.Equal("table", LuaTest.ReadCString(luaL_typename(L, -1))); - } + [Fact] + public void Nil_round_trips() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushnil(L); + + Assert.Equal(LUA_TNIL, lua_type(L, -1)); + Assert.True(lua_isnil(L, -1)); + Assert.True(lua_isnoneornil(L, -1)); + Assert.False(lua_isnone(L, -1)); + Assert.Equal(0, lua_toboolean(L, -1)); + Assert.Equal(LUA_TNONE, lua_type(L, 2)); + Assert.True(lua_isnone(L, 2)); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + [InlineData(-5, 1)] + public void Boolean_round_trips_as_c_truth_value(int pushed, int expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushboolean(L, pushed); + + Assert.Equal(LUA_TBOOLEAN, lua_type(L, -1)); + Assert.True(lua_isboolean(L, -1)); + Assert.Equal(expected, lua_toboolean(L, -1)); + } + + [Theory] + [InlineData(0L)] + [InlineData(42L)] + [InlineData(-1L)] + [InlineData(0x7FFF_FFFF_FFFFL)] + [InlineData(long.MaxValue)] + [InlineData(long.MinValue)] + public void Integer_round_trips_with_all_64_bits(long value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushinteger(L, value); + + int isNumber; + Assert.Equal(LUA_TNUMBER, lua_type(L, -1)); + Assert.Equal(1, lua_isinteger(L, -1)); + Assert.Equal(1, lua_isnumber(L, -1)); + Assert.Equal(value, lua_tointegerx(L, -1, &isNumber)); + Assert.Equal(1, isNumber); + Assert.Equal(value, lua_tointeger(L, -1)); + } + + [Theory] + [InlineData(0.0)] + [InlineData(0.1)] + [InlineData(-1234.5678)] + [InlineData(1e300)] + [InlineData(double.PositiveInfinity)] + public void Number_round_trips_bit_exact(double value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushnumber(L, value); + + int isNumber; + Assert.Equal(LUA_TNUMBER, lua_type(L, -1)); + Assert.Equal(0, lua_isinteger(L, -1)); + Assert.Equal(value, lua_tonumberx(L, -1, &isNumber)); + Assert.Equal(1, isNumber); + Assert.Equal(value, lua_tonumber(L, -1)); + } + + [Fact] + public void Float_with_integral_value_converts_to_integer_but_fraction_does_not() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + int isNumber; + + lua_pushnumber(L, 3.0); + Assert.Equal(3, lua_tointegerx(L, -1, &isNumber)); + Assert.Equal(1, isNumber); + + lua_pushnumber(L, 3.5); + Assert.Equal(0, lua_tointegerx(L, -1, &isNumber)); + Assert.Equal(0, isNumber); + } + + [Fact] + public void Conversion_flag_reports_non_numbers() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + int isNumber = 1; + + lua_pushboolean(L, 1); + + Assert.Equal(0, lua_tointegerx(L, -1, &isNumber)); + Assert.Equal(0, isNumber); + isNumber = 1; + Assert.Equal(0.0, lua_tonumberx(L, -1, &isNumber)); + Assert.Equal(0, isNumber); + Assert.Equal(0, lua_isnumber(L, -1)); + } + + [Fact] + public void Light_userdata_round_trips_the_pointer() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + void* pointer = (void*) unchecked((nint) 0x7FFE_1234_5678_9AB0); + + lua_pushlightuserdata(L, pointer); + + Assert.Equal(LUA_TLIGHTUSERDATA, lua_type(L, -1)); + Assert.True(lua_islightuserdata(L, -1)); + Assert.Equal(1, lua_isuserdata(L, -1)); + Assert.True(lua_touserdata(L, -1) == pointer); + Assert.Equal((nuint) 0, lua_rawlen(L, -1)); + } + + [Theory] + [InlineData(LUA_TNIL, "nil")] + [InlineData(LUA_TBOOLEAN, "boolean")] + [InlineData(LUA_TLIGHTUSERDATA, "userdata")] + [InlineData(LUA_TNUMBER, "number")] + [InlineData(LUA_TSTRING, "string")] + [InlineData(LUA_TTABLE, "table")] + [InlineData(LUA_TFUNCTION, "function")] + [InlineData(LUA_TUSERDATA, "userdata")] + [InlineData(LUA_TTHREAD, "thread")] + [InlineData(LUA_TNONE, "no value")] + public void Typename_names_every_tag(int tag, string expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + + Assert.Equal(expected, LuaTest.ReadCString(lua_typename(state.L, tag))); + } + + [Fact] + public void LuaL_typename_names_the_value_at_an_index() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_newtable(L); + + Assert.Equal("table", LuaTest.ReadCString(luaL_typename(L, -1))); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StackTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StackTests.cs index 60b5ab32..63c21b7d 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StackTests.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -8,123 +9,132 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class StackTests { - [Fact] - public void Settop_grows_with_nils_and_shrinks() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_settop(L, 3); - Assert.Equal(3, lua_gettop(L)); - Assert.True(lua_isnil(L, 3)); - - lua_settop(L, -2); - Assert.Equal(2, lua_gettop(L)); - - lua_settop(L, 0); - Assert.Equal(0, lua_gettop(L)); - } - - [Fact] - public void Pop_drops_the_top_elements() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - PushIntegers(L, 1, 2, 3); - - lua_pop(L, 2); - - Assert.Equal([1], ReadIntegers(L)); - } - - [Fact] - public void Absindex_makes_negative_indices_stable_and_keeps_pseudo_indices() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - PushIntegers(L, 10, 20, 30); - - Assert.Equal(3, lua_absindex(L, -1)); - Assert.Equal(1, lua_absindex(L, -3)); - Assert.Equal(2, lua_absindex(L, 2)); - Assert.Equal(LUA_REGISTRYINDEX, lua_absindex(L, LUA_REGISTRYINDEX)); - } - - [Fact] - public void Pushvalue_and_copy_duplicate_without_moving() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - PushIntegers(L, 1, 2, 3); - - lua_pushvalue(L, 1); - lua_copy(L, 2, 3); - - Assert.Equal([1, 2, 2, 1], ReadIntegers(L)); - } - - [Fact] - public void Rotate_moves_a_segment_both_ways() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - PushIntegers(L, 1, 2, 3, 4); - - lua_rotate(L, 2, 1); - Assert.Equal([1, 4, 2, 3], ReadIntegers(L)); - - lua_rotate(L, 2, -1); - Assert.Equal([1, 2, 3, 4], ReadIntegers(L)); - } - - [Fact] - public void Insert_remove_and_replace_behave_like_the_c_macros() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - PushIntegers(L, 1, 2, 3, 4); - - lua_insert(L, 1); - Assert.Equal([4, 1, 2, 3], ReadIntegers(L)); - - lua_remove(L, 2); - Assert.Equal([4, 2, 3], ReadIntegers(L)); - - lua_replace(L, 1); - Assert.Equal([3, 2], ReadIntegers(L)); - } - - [Fact] - public void Checkstack_grants_room_and_refuses_beyond_the_limit() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - Assert.NotEqual(0, lua_checkstack(L, 1000)); - for (var i = 0; i < 1000; i++) lua_pushinteger(L, i); - - Assert.Equal(1000, lua_gettop(L)); - Assert.Equal(999, lua_tointeger(L, -1)); - Assert.Equal(0, lua_checkstack(L, LUAI_MAXSTACK)); - } - - private static void PushIntegers(lua_State* L, params ReadOnlySpan values) - { - foreach (var value in values) lua_pushinteger(L, value); - } - - private static long[] ReadIntegers(lua_State* L) - { - var values = new long[lua_gettop(L)]; - for (var i = 0; i < values.Length; i++) values[i] = lua_tointeger(L, i + 1); - - return values; - } + [Fact] + public void Settop_grows_with_nils_and_shrinks() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_settop(L, 3); + Assert.Equal(3, lua_gettop(L)); + Assert.True(lua_isnil(L, 3)); + + lua_settop(L, -2); + Assert.Equal(2, lua_gettop(L)); + + lua_settop(L, 0); + Assert.Equal(0, lua_gettop(L)); + } + + [Fact] + public void Pop_drops_the_top_elements() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + PushIntegers(L, 1, 2, 3); + + lua_pop(L, 2); + + Assert.Equal([1], ReadIntegers(L)); + } + + [Fact] + public void Absindex_makes_negative_indices_stable_and_keeps_pseudo_indices() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + PushIntegers(L, 10, 20, 30); + + Assert.Equal(3, lua_absindex(L, -1)); + Assert.Equal(1, lua_absindex(L, -3)); + Assert.Equal(2, lua_absindex(L, 2)); + Assert.Equal(LUA_REGISTRYINDEX, lua_absindex(L, LUA_REGISTRYINDEX)); + } + + [Fact] + public void Pushvalue_and_copy_duplicate_without_moving() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + PushIntegers(L, 1, 2, 3); + + lua_pushvalue(L, 1); + lua_copy(L, 2, 3); + + Assert.Equal([1, 2, 2, 1], ReadIntegers(L)); + } + + [Fact] + public void Rotate_moves_a_segment_both_ways() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + PushIntegers(L, 1, 2, 3, 4); + + lua_rotate(L, 2, 1); + Assert.Equal([1, 4, 2, 3], ReadIntegers(L)); + + lua_rotate(L, 2, -1); + Assert.Equal([1, 2, 3, 4], ReadIntegers(L)); + } + + [Fact] + public void Insert_remove_and_replace_behave_like_the_c_macros() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + PushIntegers(L, 1, 2, 3, 4); + + lua_insert(L, 1); + Assert.Equal([4, 1, 2, 3], ReadIntegers(L)); + + lua_remove(L, 2); + Assert.Equal([4, 2, 3], ReadIntegers(L)); + + lua_replace(L, 1); + Assert.Equal([3, 2], ReadIntegers(L)); + } + + [Fact] + public void Checkstack_grants_room_and_refuses_beyond_the_limit() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + Assert.NotEqual(0, lua_checkstack(L, 1000)); + for (int i = 0; i < 1000; i++) + { + lua_pushinteger(L, i); + } + + Assert.Equal(1000, lua_gettop(L)); + Assert.Equal(999, lua_tointeger(L, -1)); + Assert.Equal(0, lua_checkstack(L, LUAI_MAXSTACK)); + } + + private static void PushIntegers(lua_State* L, params ReadOnlySpan values) + { + foreach (long value in values) + { + lua_pushinteger(L, value); + } + } + + private static long[] ReadIntegers(lua_State* L) + { + long[] values = new long[lua_gettop(L)]; + for (int i = 0; i < values.Length; i++) + { + values[i] = lua_tointeger(L, i + 1); + } + + return values; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StateTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StateTests.cs index e3126171..4416fa68 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StateTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StateTests.cs @@ -1,8 +1,10 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -10,170 +12,170 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class StateTests { - [Fact] - public void NewState_and_close_round_trip() - { - LuaTest.RequireNativeLua(); - - var L = luaL_newstate(); - Assert.True(L is not null); - try - { - Assert.Equal(0, lua_gettop(L)); - Assert.Equal(LUA_OK, lua_status(L)); - } - finally - { - lua_close(L); - } - } - - [Fact] - public void Version_is_5_3_for_a_state_and_for_the_library() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - - Assert.Equal(LUA_VERSION_NUM, *lua_version(state.L)); - Assert.Equal(LUA_VERSION_NUM, *lua_version(null)); - } - - [Fact] - public void Registry_index_of_the_library_matches_the_managed_constant() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - // With a different LUAI_MAXSTACK in the native build, -1001000 would not be the registry (or not even valid). - Assert.Equal(LUA_TTABLE, lua_type(L, LUA_REGISTRYINDEX)); - Assert.Equal(LUA_TTHREAD, lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_MAINTHREAD)); - Assert.True(lua_tothread(L, -1) == L); - Assert.Equal(LUA_TTABLE, lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS)); - Assert.Equal(LUA_TTABLE, lua_pushglobaltable(L)); - Assert.Equal(1, lua_rawequal(L, -1, -2)); - } - - [Fact] - public void NewThread_shares_globals_and_moves_values_with_xmove() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - var thread = lua_newthread(L); - - Assert.True(thread is not null && thread != L); - Assert.True(lua_isthread(L, -1)); - Assert.True(lua_tothread(L, -1) == thread); - Assert.Equal(1, lua_pushthread(L)); - Assert.Equal(0, lua_pushthread(thread)); - lua_pop(L, 1); - lua_pop(thread, 1); - - lua_pushinteger(L, 7); - lua_pushinteger(L, 8); - lua_xmove(L, thread, 2); - - Assert.Equal(1, lua_gettop(L)); - Assert.Equal(2, lua_gettop(thread)); - Assert.Equal(8, lua_tointeger(thread, -1)); - } - - [Fact] - public void NewState_uses_the_managed_allocator_it_is_given() - { - LuaTest.RequireNativeLua(); - long allocations = 0; - - var L = lua_newstate(&CountingAllocator, &allocations); - Assert.True(L is not null); - try - { - void* userData; - Assert.True(lua_getallocf(L, &userData) is not null); - Assert.True(userData == &allocations); - - var before = allocations; - lua_createtable(L, 16, 16); - Assert.True(allocations > before); - } - finally - { - lua_close(L); - } - } - - [Fact] - public void Setallocf_replaces_the_allocator_that_getallocf_reports() - { - LuaTest.RequireNativeLua(); - long first = 0; - long second = 0; - - var L = lua_newstate(&CountingAllocator, &first); - Assert.True(L is not null); - try - { - // Same function, other opaque pointer: the new allocator can free what the old one handed out. - var allocator = lua_getallocf(L, null); - lua_setallocf(L, allocator, &second); - - void* userData; - Assert.Equal((nint)allocator, (nint)lua_getallocf(L, &userData)); - Assert.True(userData == &second); - - var firstBefore = first; - var secondBefore = second; - lua_createtable(L, 16, 16); - Assert.Equal(firstBefore, first); - Assert.True(second > secondBefore); - } - finally - { - lua_close(L); - } - } - - [Fact] - public void Atpanic_returns_the_previous_function() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - delegate* unmanaged[Cdecl] mine = &Panic; - - var installedByAuxlib = lua_atpanic(state.L, mine); - var previous = lua_atpanic(state.L, installedByAuxlib); - - Assert.True(installedByAuxlib is not null); - Assert.Equal((nint)mine, (nint)previous); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void* CountingAllocator(void* ud, void* ptr, nuint osize, nuint nsize) - { - if (nsize == 0) - { - NativeMemory.Free(ptr); - return null; - } - - try - { - var block = NativeMemory.Realloc(ptr, nsize); - (*(long*)ud)++; - return block; - } - catch (OutOfMemoryException) - { - // The allocator contract: report failure as null, never unwind into Lua. - return null; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Panic(lua_State* L) - { - return 0; - } + [Fact] + public void NewState_and_close_round_trip() + { + LuaTest.RequireNativeLua(); + + lua_State* L = luaL_newstate(); + Assert.True(L is not null); + try + { + Assert.Equal(0, lua_gettop(L)); + Assert.Equal(LUA_OK, lua_status(L)); + } + finally + { + lua_close(L); + } + } + + [Fact] + public void Version_is_5_3_for_a_state_and_for_the_library() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + + Assert.Equal(LUA_VERSION_NUM, *lua_version(state.L)); + Assert.Equal(LUA_VERSION_NUM, *lua_version(null)); + } + + [Fact] + public void Registry_index_of_the_library_matches_the_managed_constant() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + // With a different LUAI_MAXSTACK in the native build, -1001000 would not be the registry (or not even valid). + Assert.Equal(LUA_TTABLE, lua_type(L, LUA_REGISTRYINDEX)); + Assert.Equal(LUA_TTHREAD, lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_MAINTHREAD)); + Assert.True(lua_tothread(L, -1) == L); + Assert.Equal(LUA_TTABLE, lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS)); + Assert.Equal(LUA_TTABLE, lua_pushglobaltable(L)); + Assert.Equal(1, lua_rawequal(L, -1, -2)); + } + + [Fact] + public void NewThread_shares_globals_and_moves_values_with_xmove() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_State* thread = lua_newthread(L); + + Assert.True(thread is not null && thread != L); + Assert.True(lua_isthread(L, -1)); + Assert.True(lua_tothread(L, -1) == thread); + Assert.Equal(1, lua_pushthread(L)); + Assert.Equal(0, lua_pushthread(thread)); + lua_pop(L, 1); + lua_pop(thread, 1); + + lua_pushinteger(L, 7); + lua_pushinteger(L, 8); + lua_xmove(L, thread, 2); + + Assert.Equal(1, lua_gettop(L)); + Assert.Equal(2, lua_gettop(thread)); + Assert.Equal(8, lua_tointeger(thread, -1)); + } + + [Fact] + public void NewState_uses_the_managed_allocator_it_is_given() + { + LuaTest.RequireNativeLua(); + long allocations = 0; + + lua_State* L = lua_newstate(&CountingAllocator, &allocations); + Assert.True(L is not null); + try + { + void* userData; + Assert.True(lua_getallocf(L, &userData) is not null); + Assert.True(userData == &allocations); + + long before = allocations; + lua_createtable(L, 16, 16); + Assert.True(allocations > before); + } + finally + { + lua_close(L); + } + } + + [Fact] + public void Setallocf_replaces_the_allocator_that_getallocf_reports() + { + LuaTest.RequireNativeLua(); + long first = 0; + long second = 0; + + lua_State* L = lua_newstate(&CountingAllocator, &first); + Assert.True(L is not null); + try + { + // Same function, other opaque pointer: the new allocator can free what the old one handed out. + delegate* unmanaged[Cdecl] allocator = lua_getallocf(L, null); + lua_setallocf(L, allocator, &second); + + void* userData; + Assert.Equal((nint) allocator, (nint) lua_getallocf(L, &userData)); + Assert.True(userData == &second); + + long firstBefore = first; + long secondBefore = second; + lua_createtable(L, 16, 16); + Assert.Equal(firstBefore, first); + Assert.True(second > secondBefore); + } + finally + { + lua_close(L); + } + } + + [Fact] + public void Atpanic_returns_the_previous_function() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + delegate* unmanaged[Cdecl] mine = &Panic; + + delegate* unmanaged[Cdecl] installedByAuxlib = lua_atpanic(state.L, mine); + delegate* unmanaged[Cdecl] previous = lua_atpanic(state.L, installedByAuxlib); + + Assert.True(installedByAuxlib is not null); + Assert.Equal((nint) mine, (nint) previous); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void* CountingAllocator(void* ud, void* ptr, nuint osize, nuint nsize) + { + if (nsize == 0) + { + NativeMemory.Free(ptr); + return null; + } + + try + { + void* block = NativeMemory.Realloc(ptr, nsize); + (*(long*) ud)++; + return block; + } + catch (OutOfMemoryException) + { + // The allocator contract: report failure as null, never unwind into Lua. + return null; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Panic(lua_State* L) + { + return 0; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StringTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StringTests.cs index 4771e82c..c0619b13 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StringTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/StringTests.cs @@ -1,6 +1,9 @@ using System.Text; + using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -8,140 +11,140 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class StringTests { - // Latin-1, an arrow and two CJK characters, then a NUL in the middle, then a 4-byte code point: - // 1-, 2-, 3- and 4-byte UTF-8 sequences. - private const string Tricky = "h\u00E9llo \u2192 \u4E16\u754C\0tail \U0001F600"; - - [Fact] - public void Pushlstring_round_trips_embedded_nul_and_non_ascii_utf8() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - var utf8 = Encoding.UTF8.GetBytes(Tricky); - - fixed (byte* bytes = utf8) - { - var interned = lua_pushlstring(L, bytes, (nuint)utf8.Length); - Assert.True(interned is not null && interned != bytes); - } - - nuint length; - var read = lua_tolstring(L, -1, &length); - - Assert.Equal(LUA_TSTRING, lua_type(L, -1)); - Assert.Equal((nuint)utf8.Length, length); - Assert.Equal((nuint)utf8.Length, lua_rawlen(L, -1)); - Assert.True(new ReadOnlySpan(read, (int)length).SequenceEqual(utf8)); - Assert.Equal(0, read[length]); - Assert.Equal(Tricky, LuaTest.ReadString(L, -1)); - } - - [Fact] - public void Lua_code_sees_the_same_bytes() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = state.L; - var utf8 = Encoding.UTF8.GetBytes(Tricky); - - LuaTest.Run(L, "return function(s) return #s, s:byte(2), s:byte(3), utf8.len(s) end"u8, 1); - fixed (byte* bytes = utf8) - { - _ = lua_pushlstring(L, bytes, (nuint)utf8.Length); - } - - Assert.Equal(LUA_OK, lua_pcall(L, 1, 4, 0)); - Assert.Equal(utf8.Length, lua_tointeger(L, -4)); - Assert.Equal(0xC3, lua_tointeger(L, -3)); - Assert.Equal(0xA9, lua_tointeger(L, -2)); - Assert.Equal(Tricky.EnumerateRunes().Count(), lua_tointeger(L, -1)); - } - - [Fact] - public void Pushstring_stops_at_the_first_nul_and_maps_null_to_nil() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* text = "abc\0def"u8) - { - _ = lua_pushstring(L, text); - } - - Assert.True(lua_pushstring(L, null) is null); - - Assert.True(lua_isnil(L, -1)); - Assert.Equal("abc", LuaTest.ReadString(L, -2)); - Assert.Equal("abc", LuaTest.ReadCString(lua_tostring(L, -2))); - } - - [Fact] - public void Pushliteral_takes_a_utf8_literal_including_the_empty_one() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushliteral(L, "soExactValue"u8); - lua_pushliteral(L, ""u8); - lua_pushliteral(L, default); - - Assert.Equal("soExactValue", LuaTest.ReadString(L, -3)); - Assert.Equal(string.Empty, LuaTest.ReadString(L, -2)); - Assert.Equal(string.Empty, LuaTest.ReadString(L, -1)); - Assert.Equal(LUA_TSTRING, lua_type(L, -1)); - } - - [Fact] - public void Tolstring_converts_a_number_in_place_and_returns_null_for_other_types() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushinteger(L, 1234); - lua_newtable(L); - - Assert.Equal(1, lua_isstring(L, 1)); - Assert.Equal("1234", LuaTest.ReadString(L, 1)); - Assert.Equal(LUA_TSTRING, lua_type(L, 1)); - Assert.True(lua_tolstring(L, 2, null) is null); - Assert.Equal(0, lua_isstring(L, 2)); - } - - [Fact] - public void Numeric_string_converts_without_changing_the_slot() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - int isNumber; - - lua_pushliteral(L, "12.5"u8); - - Assert.Equal(1, lua_isnumber(L, -1)); - Assert.Equal(12.5, lua_tonumberx(L, -1, &isNumber)); - Assert.Equal(1, isNumber); - Assert.Equal(0, lua_isinteger(L, -1)); - Assert.Equal(LUA_TSTRING, lua_type(L, -1)); - } - - [Fact] - public void LuaL_tolstring_pushes_a_printable_copy_and_leaves_the_value_alone() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_pushboolean(L, 1); - nuint length; - var text = luaL_tolstring(L, 1, &length); - - Assert.Equal(2, lua_gettop(L)); - Assert.Equal((nuint)4, length); - Assert.Equal("true", LuaTest.ReadCString(text)); - Assert.Equal(LUA_TBOOLEAN, lua_type(L, 1)); - } + // Latin-1, an arrow and two CJK characters, then a NUL in the middle, then a 4-byte code point: + // 1-, 2-, 3- and 4-byte UTF-8 sequences. + private const string Tricky = "h\u00E9llo \u2192 \u4E16\u754C\0tail \U0001F600"; + + [Fact] + public void Pushlstring_round_trips_embedded_nul_and_non_ascii_utf8() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + byte[] utf8 = Encoding.UTF8.GetBytes(Tricky); + + fixed (byte* bytes = utf8) + { + byte* interned = lua_pushlstring(L, bytes, (nuint) utf8.Length); + Assert.True(interned is not null && interned != bytes); + } + + nuint length; + byte* read = lua_tolstring(L, -1, &length); + + Assert.Equal(LUA_TSTRING, lua_type(L, -1)); + Assert.Equal((nuint) utf8.Length, length); + Assert.Equal((nuint) utf8.Length, lua_rawlen(L, -1)); + Assert.True(new ReadOnlySpan(read, (int) length).SequenceEqual(utf8)); + Assert.Equal(0, read[length]); + Assert.Equal(Tricky, LuaTest.ReadString(L, -1)); + } + + [Fact] + public void Lua_code_sees_the_same_bytes() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + lua_State* L = state.L; + byte[] utf8 = Encoding.UTF8.GetBytes(Tricky); + + LuaTest.Run(L, "return function(s) return #s, s:byte(2), s:byte(3), utf8.len(s) end"u8, 1); + fixed (byte* bytes = utf8) + { + _ = lua_pushlstring(L, bytes, (nuint) utf8.Length); + } + + Assert.Equal(LUA_OK, lua_pcall(L, 1, 4, 0)); + Assert.Equal(utf8.Length, lua_tointeger(L, -4)); + Assert.Equal(0xC3, lua_tointeger(L, -3)); + Assert.Equal(0xA9, lua_tointeger(L, -2)); + Assert.Equal(Tricky.EnumerateRunes().Count(), lua_tointeger(L, -1)); + } + + [Fact] + public void Pushstring_stops_at_the_first_nul_and_maps_null_to_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* text = "abc\0def"u8) + { + _ = lua_pushstring(L, text); + } + + Assert.True(lua_pushstring(L, null) is null); + + Assert.True(lua_isnil(L, -1)); + Assert.Equal("abc", LuaTest.ReadString(L, -2)); + Assert.Equal("abc", LuaTest.ReadCString(lua_tostring(L, -2))); + } + + [Fact] + public void Pushliteral_takes_a_utf8_literal_including_the_empty_one() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushliteral(L, "soExactValue"u8); + lua_pushliteral(L, ""u8); + lua_pushliteral(L, default); + + Assert.Equal("soExactValue", LuaTest.ReadString(L, -3)); + Assert.Equal(string.Empty, LuaTest.ReadString(L, -2)); + Assert.Equal(string.Empty, LuaTest.ReadString(L, -1)); + Assert.Equal(LUA_TSTRING, lua_type(L, -1)); + } + + [Fact] + public void Tolstring_converts_a_number_in_place_and_returns_null_for_other_types() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushinteger(L, 1234); + lua_newtable(L); + + Assert.Equal(1, lua_isstring(L, 1)); + Assert.Equal("1234", LuaTest.ReadString(L, 1)); + Assert.Equal(LUA_TSTRING, lua_type(L, 1)); + Assert.True(lua_tolstring(L, 2, null) is null); + Assert.Equal(0, lua_isstring(L, 2)); + } + + [Fact] + public void Numeric_string_converts_without_changing_the_slot() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + int isNumber; + + lua_pushliteral(L, "12.5"u8); + + Assert.Equal(1, lua_isnumber(L, -1)); + Assert.Equal(12.5, lua_tonumberx(L, -1, &isNumber)); + Assert.Equal(1, isNumber); + Assert.Equal(0, lua_isinteger(L, -1)); + Assert.Equal(LUA_TSTRING, lua_type(L, -1)); + } + + [Fact] + public void LuaL_tolstring_pushes_a_printable_copy_and_leaves_the_value_alone() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_pushboolean(L, 1); + nuint length; + byte* text = luaL_tolstring(L, 1, &length); + + Assert.Equal(2, lua_gettop(L)); + Assert.Equal((nuint) 4, length); + Assert.Equal("true", LuaTest.ReadCString(text)); + Assert.Equal(LUA_TBOOLEAN, lua_type(L, 1)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/TableTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/TableTests.cs index 019cfa5e..0cff3bc6 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/TableTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/TableTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,178 +9,178 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class TableTests { - [Fact] - public void Setfield_and_getfield_round_trip_and_report_the_type() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_createtable(L, 0, 2); - fixed (byte* key = "answer"u8) - fixed (byte* absent = "absent"u8) - { - lua_pushinteger(L, 42); - lua_setfield(L, 1, key); - - Assert.Equal(1, lua_gettop(L)); - Assert.Equal(LUA_TNUMBER, lua_getfield(L, 1, key)); - Assert.Equal(42, lua_tointeger(L, -1)); - Assert.Equal(LUA_TNIL, lua_getfield(L, 1, absent)); - } - } - - [Fact] - public void Settable_and_gettable_use_the_key_on_the_stack() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_newtable(L); - lua_pushnumber(L, 1.5); - lua_pushliteral(L, "value"u8); - lua_settable(L, 1); - - lua_pushnumber(L, 1.5); - Assert.Equal(LUA_TSTRING, lua_gettable(L, 1)); - Assert.Equal("value", LuaTest.ReadString(L, -1)); - Assert.Equal(2, lua_gettop(L)); - } - - [Fact] - public void Seti_geti_and_raw_variants_agree_on_a_plain_table() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_createtable(L, 3, 0); - lua_pushinteger(L, 100); - lua_seti(L, 1, 1); - lua_pushinteger(L, 200); - lua_rawseti(L, 1, 2); - lua_pushinteger(L, 3); - lua_pushinteger(L, 300); - lua_rawset(L, 1); - - Assert.Equal((nuint)3, lua_rawlen(L, 1)); - Assert.Equal(LUA_TNUMBER, lua_rawgeti(L, 1, 1)); - Assert.Equal(LUA_TNUMBER, lua_geti(L, 1, 2)); - lua_pushinteger(L, 3); - Assert.Equal(LUA_TNUMBER, lua_rawget(L, 1)); - Assert.Equal(300, lua_tointeger(L, -1)); - Assert.Equal(200, lua_tointeger(L, -2)); - Assert.Equal(100, lua_tointeger(L, -3)); - } - - [Fact] - public void Rawsetp_and_rawgetp_key_by_pointer_identity() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - var anchor = 0; - - lua_newtable(L); - lua_pushliteral(L, "by pointer"u8); - lua_rawsetp(L, 1, &anchor); - - Assert.Equal(LUA_TSTRING, lua_rawgetp(L, 1, &anchor)); - Assert.Equal(LUA_TNIL, lua_rawgetp(L, 1, (byte*)&anchor + 1)); - } - - [Fact] - public void Setglobal_and_getglobal_round_trip() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - fixed (byte* name = "cheatEngineSdkTestGlobal"u8) - { - lua_pushinteger(L, 7); - lua_setglobal(L, name); - - Assert.Equal(0, lua_gettop(L)); - Assert.Equal(LUA_TNUMBER, lua_getglobal(L, name)); - Assert.Equal(7, lua_tointeger(L, -1)); - } - } - - [Fact] - public void Next_visits_every_pair_once_and_leaves_the_stack_balanced() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - LuaTest.Run(L, "return { 10, 20, 30, x = 40, y = 50 }"u8, 1); - var table = lua_absindex(L, -1); - var top = lua_gettop(L); - - long valueSum = 0; - var integerKeys = 0; - var stringKeys = 0; - lua_pushnil(L); - while (lua_next(L, table) != 0) - { - // Key at -2, value at -1. The key type is tested, never converted: tolstring would rewrite the slot. - valueSum += lua_tointeger(L, -1); - integerKeys += lua_isinteger(L, -2); - stringKeys += lua_type(L, -2) == LUA_TSTRING ? 1 : 0; - lua_pop(L, 1); - } - - Assert.Equal(150, valueSum); - Assert.Equal(3, integerKeys); - Assert.Equal(2, stringKeys); - Assert.Equal(top, lua_gettop(L)); - } - - [Fact] - public void Metatable_round_trips_and_drives_getfield() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_newtable(L); - Assert.Equal(0, lua_getmetatable(L, 1)); - Assert.Equal(1, lua_gettop(L)); - - LuaTest.Run(L, "return { __index = function(t, k) return k .. '!' end }"u8, 1); - Assert.Equal(1, lua_setmetatable(L, 1)); - - Assert.Equal(1, lua_getmetatable(L, 1)); - Assert.True(lua_istable(L, -1)); - lua_pop(L, 1); - fixed (byte* key = "hello"u8) - fixed (byte* index = "__index"u8) - { - Assert.Equal(LUA_TSTRING, lua_getfield(L, 1, key)); - Assert.Equal("hello!", LuaTest.ReadString(L, -1)); - Assert.Equal(LUA_TNIL, lua_rawgetp(L, 1, key)); - Assert.Equal(LUA_TFUNCTION, luaL_getmetafield(L, 1, index)); - Assert.True(lua_isfunction(L, -1)); - } - } - - [Fact] - public void Getsubtable_creates_once_then_finds() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; - - lua_newtable(L); - fixed (byte* name = "cache"u8) - { - Assert.Equal(0, luaL_getsubtable(L, 1, name)); - var created = lua_topointer(L, -1); - lua_pop(L, 1); - - Assert.Equal(1, luaL_getsubtable(L, 1, name)); - Assert.True(created is not null && lua_topointer(L, -1) == created); - } - } + [Fact] + public void Setfield_and_getfield_round_trip_and_report_the_type() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_createtable(L, 0, 2); + fixed (byte* key = "answer"u8) + fixed (byte* absent = "absent"u8) + { + lua_pushinteger(L, 42); + lua_setfield(L, 1, key); + + Assert.Equal(1, lua_gettop(L)); + Assert.Equal(LUA_TNUMBER, lua_getfield(L, 1, key)); + Assert.Equal(42, lua_tointeger(L, -1)); + Assert.Equal(LUA_TNIL, lua_getfield(L, 1, absent)); + } + } + + [Fact] + public void Settable_and_gettable_use_the_key_on_the_stack() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_newtable(L); + lua_pushnumber(L, 1.5); + lua_pushliteral(L, "value"u8); + lua_settable(L, 1); + + lua_pushnumber(L, 1.5); + Assert.Equal(LUA_TSTRING, lua_gettable(L, 1)); + Assert.Equal("value", LuaTest.ReadString(L, -1)); + Assert.Equal(2, lua_gettop(L)); + } + + [Fact] + public void Seti_geti_and_raw_variants_agree_on_a_plain_table() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_createtable(L, 3, 0); + lua_pushinteger(L, 100); + lua_seti(L, 1, 1); + lua_pushinteger(L, 200); + lua_rawseti(L, 1, 2); + lua_pushinteger(L, 3); + lua_pushinteger(L, 300); + lua_rawset(L, 1); + + Assert.Equal((nuint) 3, lua_rawlen(L, 1)); + Assert.Equal(LUA_TNUMBER, lua_rawgeti(L, 1, 1)); + Assert.Equal(LUA_TNUMBER, lua_geti(L, 1, 2)); + lua_pushinteger(L, 3); + Assert.Equal(LUA_TNUMBER, lua_rawget(L, 1)); + Assert.Equal(300, lua_tointeger(L, -1)); + Assert.Equal(200, lua_tointeger(L, -2)); + Assert.Equal(100, lua_tointeger(L, -3)); + } + + [Fact] + public void Rawsetp_and_rawgetp_key_by_pointer_identity() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + int anchor = 0; + + lua_newtable(L); + lua_pushliteral(L, "by pointer"u8); + lua_rawsetp(L, 1, &anchor); + + Assert.Equal(LUA_TSTRING, lua_rawgetp(L, 1, &anchor)); + Assert.Equal(LUA_TNIL, lua_rawgetp(L, 1, (byte*) &anchor + 1)); + } + + [Fact] + public void Setglobal_and_getglobal_round_trip() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + fixed (byte* name = "cheatEngineSdkTestGlobal"u8) + { + lua_pushinteger(L, 7); + lua_setglobal(L, name); + + Assert.Equal(0, lua_gettop(L)); + Assert.Equal(LUA_TNUMBER, lua_getglobal(L, name)); + Assert.Equal(7, lua_tointeger(L, -1)); + } + } + + [Fact] + public void Next_visits_every_pair_once_and_leaves_the_stack_balanced() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaTest.Run(L, "return { 10, 20, 30, x = 40, y = 50 }"u8, 1); + int table = lua_absindex(L, -1); + int top = lua_gettop(L); + + long valueSum = 0; + int integerKeys = 0; + int stringKeys = 0; + lua_pushnil(L); + while (lua_next(L, table) != 0) + { + // Key at -2, value at -1. The key type is tested, never converted: tolstring would rewrite the slot. + valueSum += lua_tointeger(L, -1); + integerKeys += lua_isinteger(L, -2); + stringKeys += lua_type(L, -2) == LUA_TSTRING ? 1 : 0; + lua_pop(L, 1); + } + + Assert.Equal(150, valueSum); + Assert.Equal(3, integerKeys); + Assert.Equal(2, stringKeys); + Assert.Equal(top, lua_gettop(L)); + } + + [Fact] + public void Metatable_round_trips_and_drives_getfield() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_newtable(L); + Assert.Equal(0, lua_getmetatable(L, 1)); + Assert.Equal(1, lua_gettop(L)); + + LuaTest.Run(L, "return { __index = function(t, k) return k .. '!' end }"u8, 1); + Assert.Equal(1, lua_setmetatable(L, 1)); + + Assert.Equal(1, lua_getmetatable(L, 1)); + Assert.True(lua_istable(L, -1)); + lua_pop(L, 1); + fixed (byte* key = "hello"u8) + fixed (byte* index = "__index"u8) + { + Assert.Equal(LUA_TSTRING, lua_getfield(L, 1, key)); + Assert.Equal("hello!", LuaTest.ReadString(L, -1)); + Assert.Equal(LUA_TNIL, lua_rawgetp(L, 1, key)); + Assert.Equal(LUA_TFUNCTION, luaL_getmetafield(L, 1, index)); + Assert.True(lua_isfunction(L, -1)); + } + } + + [Fact] + public void Getsubtable_creates_once_then_finds() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + + lua_newtable(L); + fixed (byte* name = "cache"u8) + { + Assert.Equal(0, luaL_getsubtable(L, 1, name)); + void* created = lua_topointer(L, -1); + lua_pop(L, 1); + + Assert.Equal(1, luaL_getsubtable(L, 1, name)); + Assert.True(created is not null && lua_topointer(L, -1) == created); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/UserdataTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/UserdataTests.cs index d0a50d55..9abe6160 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/UserdataTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/UserdataTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; @@ -7,65 +9,65 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.RoundTrips; [Trait("Category", "NativeLua")] public sealed unsafe class UserdataTests { - [Fact] - public void Newuserdata_returns_the_block_that_touserdata_reports() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; + [Fact] + public void Newuserdata_returns_the_block_that_touserdata_reports() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; - var block = lua_newuserdata(L, (nuint)sizeof(nint)); - *(nint*)block = 0x1234_5678; + void* block = lua_newuserdata(L, (nuint) sizeof(nint)); + *(nint*) block = 0x1234_5678; - Assert.Equal(LUA_TUSERDATA, lua_type(L, -1)); - Assert.Equal(1, lua_isuserdata(L, -1)); - Assert.False(lua_islightuserdata(L, -1)); - Assert.True(lua_touserdata(L, -1) == block); - Assert.Equal((nuint)sizeof(nint), lua_rawlen(L, -1)); + Assert.Equal(LUA_TUSERDATA, lua_type(L, -1)); + Assert.Equal(1, lua_isuserdata(L, -1)); + Assert.False(lua_islightuserdata(L, -1)); + Assert.True(lua_touserdata(L, -1) == block); + Assert.Equal((nuint) sizeof(nint), lua_rawlen(L, -1)); - // The shape of a Cheat Engine object: a full userdata whose first pointer-sized field is the native object. - Assert.Equal(0x1234_5678, *(nint*)lua_touserdata(L, -1)); - } + // The shape of a Cheat Engine object: a full userdata whose first pointer-sized field is the native object. + Assert.Equal(0x1234_5678, *(nint*) lua_touserdata(L, -1)); + } - [Fact] - public void Named_metatable_identifies_a_userdata_without_raising() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; + [Fact] + public void Named_metatable_identifies_a_userdata_without_raising() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; - fixed (byte* typeName = "CheatEngine.SDK.Tests.Handle"u8) - fixed (byte* otherName = "CheatEngine.SDK.Tests.Other"u8) - { - Assert.Equal(1, luaL_newmetatable(L, typeName)); - Assert.Equal(0, luaL_newmetatable(L, typeName)); - lua_pop(L, 2); + fixed (byte* typeName = "CheatEngine.SDK.Tests.Handle"u8) + fixed (byte* otherName = "CheatEngine.SDK.Tests.Other"u8) + { + Assert.Equal(1, luaL_newmetatable(L, typeName)); + Assert.Equal(0, luaL_newmetatable(L, typeName)); + lua_pop(L, 2); - var block = lua_newuserdata(L, 8); - Assert.True(luaL_testudata(L, -1, typeName) is null); + void* block = lua_newuserdata(L, 8); + Assert.True(luaL_testudata(L, -1, typeName) is null); - luaL_setmetatable(L, typeName); + luaL_setmetatable(L, typeName); - Assert.True(luaL_testudata(L, -1, typeName) == block); - Assert.True(luaL_testudata(L, -1, otherName) is null); - Assert.Equal(LUA_TTABLE, luaL_getmetatable(L, typeName)); - Assert.Equal(LUA_TNIL, luaL_getmetatable(L, otherName)); - } - } + Assert.True(luaL_testudata(L, -1, typeName) == block); + Assert.True(luaL_testudata(L, -1, otherName) is null); + Assert.Equal(LUA_TTABLE, luaL_getmetatable(L, typeName)); + Assert.Equal(LUA_TNIL, luaL_getmetatable(L, otherName)); + } + } - [Fact] - public void Uservalue_round_trips() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = state.L; + [Fact] + public void Uservalue_round_trips() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; - _ = lua_newuserdata(L, 1); - lua_pushliteral(L, "attached"u8); - lua_setuservalue(L, 1); + _ = lua_newuserdata(L, 1); + lua_pushliteral(L, "attached"u8); + lua_setuservalue(L, 1); - Assert.Equal(1, lua_gettop(L)); - Assert.Equal(LUA_TSTRING, lua_getuservalue(L, 1)); - Assert.Equal("attached", LuaTest.ReadString(L, -1)); - } + Assert.Equal(1, lua_gettop(L)); + Assert.Equal(LUA_TSTRING, lua_getuservalue(L, 1)); + Assert.Equal("attached", LuaTest.ReadString(L, -1)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/LuaApiSignatureTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/LuaApiSignatureTests.cs index ec491080..e72511bd 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/LuaApiSignatureTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/LuaApiSignatureTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Tests.Support; using CheatEngine.SDK.Lua.Interop.Types; @@ -14,242 +15,259 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Signatures; /// public sealed class LuaApiSignatureTests { - private const BindingFlags AnyInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - private const BindingFlags PublicStatic = BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly; - - private static FieldInfo[] TableFields => typeof(LuaApi.Table).GetFields(AnyInstance); - - [Fact] - public void Table_has_a_slot_for_every_required_function() - { - string[] required = - [ - "luaL_newstate", "lua_close", "lua_newthread", - "lua_gettop", "lua_settop", "lua_pushvalue", "lua_rotate", "lua_copy", "lua_checkstack", "lua_absindex", - "lua_type", "lua_isnumber", "lua_isstring", "lua_iscfunction", "lua_isinteger", "lua_isuserdata", - "lua_tonumberx", "lua_tointegerx", "lua_toboolean", "lua_tolstring", "lua_rawlen", "lua_touserdata", - "lua_topointer", - "lua_pushnil", "lua_pushnumber", "lua_pushinteger", "lua_pushlstring", "lua_pushstring", "lua_pushcclosure", - "lua_pushboolean", "lua_pushlightuserdata", - "lua_getglobal", "lua_setglobal", "lua_gettable", "lua_settable", "lua_getfield", "lua_setfield", - "lua_geti", - "lua_seti", "lua_rawget", "lua_rawset", "lua_rawgeti", "lua_rawseti", "lua_createtable", "lua_newuserdata", - "lua_getmetatable", "lua_setmetatable", "lua_next", - "lua_callk", "lua_pcallk", "luaL_loadbufferx", "luaL_loadstring", "luaL_ref", "luaL_unref", "lua_error" - ]; - - HashSet slots = new(TableFields.Select(static field => field.Name), StringComparer.Ordinal); - - Assert.DoesNotContain(required, name => !slots.Contains(name)); - } - - [Fact] - public void Table_slots_are_unmanaged_cdecl_function_pointers() - { - Assert.NotEmpty(TableFields); - foreach (var field in TableFields) - { - Assert.True(field.FieldType.IsUnmanagedFunctionPointer, field.Name); - var conventions = field.GetModifiedFieldType().GetFunctionPointerCallingConventions(); - Assert.True(conventions is [var convention] && convention == typeof(CallConvCdecl), field.Name); - } - } - - [Fact] - public void Public_forwarders_repeat_the_slot_signature_exactly() - { - foreach (var field in TableFields) - { - var members = typeof(LuaApi).GetMember(field.Name, PublicStatic); - var forwarder = Assert.Single(members); - - if (forwarder is PropertyInfo property) - { - Assert.True(property.PropertyType == field.FieldType, field.Name); - continue; - } - - var method = Assert.IsType(forwarder, false); - Type[] parameters = [.. method.GetParameters().Select(static parameter => parameter.ParameterType)]; - Assert.True(method.ReturnType == field.FieldType.GetFunctionPointerReturnType(), field.Name); - Assert.True(parameters.SequenceEqual(field.FieldType.GetFunctionPointerParameterTypes()), field.Name); - } - } - - [Fact] - public void Public_forwarders_load_their_own_slot_and_pass_the_parameters_in_declared_order() - { - // The type comparison above cannot see a forwarder wired to a sibling slot of the same type (lua_getlocal and - // lua_setlocal, lua_rawget and lua_rawset, ...) or two same-typed arguments in the wrong order. The IL can: - // a forwarder loads exactly one slot, its own, and its arguments are loaded as 0, 1, 2, ... - foreach (var field in TableFields) - { - var forwarder = Assert.Single(typeof(LuaApi).GetMember(field.Name, PublicStatic)); - var method = forwarder is PropertyInfo property ? property.GetMethod : forwarder as MethodInfo; - Assert.NotNull(method); - - List slots = []; - List arguments = []; - var indirectCalls = 0; - var otherCalls = 0; - foreach (var (code, operand) in IlReader.Read(method)) - if (code.OperandType == OperandType.InlineField) - { - var loaded = method.Module.ResolveField(operand); - if (loaded?.DeclaringType == typeof(LuaApi.Table)) slots.Add(loaded.Name); - } - else if (ArgumentIndex(code, operand) is int index) - { - arguments.Add(index); - } - else if (code == OpCodes.Calli) - { - indirectCalls++; - } - else if (code.FlowControl == FlowControl.Call) - { - otherCalls++; - } - - Assert.True(slots is [var slot] && string.Equals(slot, field.Name, StringComparison.Ordinal), field.Name); - Assert.True(arguments.SequenceEqual(Enumerable.Range(0, method.GetParameters().Length)), field.Name); - Assert.True(indirectCalls == (forwarder is PropertyInfo ? 0 : 1), field.Name); - Assert.True(otherCalls == 0, field.Name); - } - } - - [Fact] - public void Forwarders_and_macros_are_aggressively_inlined_except_the_two_cold_macros() - { - // "A forwarder costs nothing over a raw calli" holds only while the JIT inlines it. Without the attribute that - // is a per-call-site profitability guess (lua_pushliteral, the macro of the "name"u8 hot path, once lacked it). - string[] binding = [nameof(LuaApi.Initialize), nameof(LuaApi.TryInitialize), nameof(LuaApi.GetMissingExports)]; - string[] cold = [nameof(LuaApi.luaL_dofile), nameof(LuaApi.luaL_dostring)]; - - MethodInfo[] methods = - [ - .. typeof(LuaApi).GetMethods(PublicStatic) - .Where(method => !method.IsSpecialName && !binding.Contains(method.Name, StringComparer.Ordinal)) - ]; - string[] notInlined = - [ - .. methods - .Where(static method => - !method.MethodImplementationFlags.HasFlag(MethodImplAttributes.AggressiveInlining)) - .Select(static method => method.Name) - .Order(StringComparer.Ordinal) - ]; - - Assert.True(methods.Length > TableFields.Length, "The macros are missing from the reflected method set."); - Assert.Equal(cold, notInlined); - } - - [Fact] - public void Pcallk_takes_six_parameters_with_a_pointer_sized_context() - { - var parameters = SlotParameters("lua_pcallk"); - - Assert.Equal(6, parameters.Length); - Assert.Equal(typeof(lua_State*), parameters[0]); - Assert.Equal([typeof(int), typeof(int), typeof(int), typeof(nint)], parameters[1..5]); - Assert.True(parameters[5].IsUnmanagedFunctionPointer); - Assert.Equal(typeof(int), SlotReturn("lua_pcallk")); - } - - [Fact] - public void Callk_takes_five_parameters_with_a_pointer_sized_context() - { - var parameters = SlotParameters("lua_callk"); - - Assert.Equal(5, parameters.Length); - Assert.Equal(typeof(nint), parameters[3]); - Assert.True(parameters[4].IsUnmanagedFunctionPointer); - Assert.Equal(typeof(void), SlotReturn("lua_callk")); - } - - [Fact] - public void Size_t_is_pointer_sized_unsigned() - { - Assert.Equal(typeof(nuint), SlotReturn("lua_rawlen")); - Assert.Equal(typeof(nuint), SlotReturn("lua_stringtonumber")); - Assert.Equal(typeof(nuint*), SlotParameters("lua_tolstring")[2]); - Assert.Equal(typeof(nuint*), SlotParameters("luaL_tolstring")[2]); - Assert.Equal(typeof(nuint), SlotParameters("lua_pushlstring")[2]); - Assert.Equal(typeof(nuint), SlotParameters("lua_newuserdata")[1]); - Assert.Equal(typeof(nuint), SlotParameters("luaL_loadbufferx")[2]); - } - - [Fact] - public void Lua_integer_and_number_are_64_bit() - { - Assert.Equal(typeof(long), SlotParameters("lua_pushinteger")[1]); - Assert.Equal(typeof(long), SlotReturn("lua_tointegerx")); - Assert.Equal(typeof(long), SlotParameters("lua_rawgeti")[2]); - Assert.Equal(typeof(long), SlotParameters("lua_rawseti")[2]); - Assert.Equal(typeof(long), SlotParameters("lua_geti")[2]); - Assert.Equal(typeof(long), SlotParameters("lua_seti")[2]); - Assert.Equal(typeof(long), SlotReturn("luaL_len")); - Assert.Equal(typeof(double), SlotParameters("lua_pushnumber")[1]); - Assert.Equal(typeof(double), SlotReturn("lua_tonumberx")); - } - - [Theory] - [InlineData("lua_isnumber")] - [InlineData("lua_isstring")] - [InlineData("lua_iscfunction")] - [InlineData("lua_isinteger")] - [InlineData("lua_isuserdata")] - [InlineData("lua_toboolean")] - [InlineData("lua_checkstack")] - [InlineData("lua_rawequal")] - [InlineData("lua_compare")] - [InlineData("lua_next")] - [InlineData("lua_getmetatable")] - [InlineData("lua_isyieldable")] - public void Native_predicates_return_c_int_not_bool(string name) - { - Assert.Equal(typeof(int), SlotReturn(name)); - } - - [Fact] - public void No_slot_uses_a_non_blittable_type() - { - foreach (var field in TableFields) - { - var signature = field.FieldType; - Type[] all = [signature.GetFunctionPointerReturnType(), .. signature.GetFunctionPointerParameterTypes()]; - Assert.DoesNotContain(all, - static type => type == typeof(bool) || type == typeof(char) || type == typeof(string) || type.IsByRef); - } - } - - private static int? ArgumentIndex(OpCode code, int operand) - { - if (code == OpCodes.Ldarg_0) return 0; - - if (code == OpCodes.Ldarg_1) return 1; - - if (code == OpCodes.Ldarg_2) return 2; - - if (code == OpCodes.Ldarg_3) return 3; - - return code == OpCodes.Ldarg_S || code == OpCodes.Ldarg ? operand : null; - } - - private static Type[] SlotParameters(string name) - { - return Slot(name).FieldType.GetFunctionPointerParameterTypes(); - } - - private static Type SlotReturn(string name) - { - return Slot(name).FieldType.GetFunctionPointerReturnType(); - } - - private static FieldInfo Slot(string name) - { - var field = typeof(LuaApi.Table).GetField(name, AnyInstance); - Assert.NotNull(field); - return field; - } + private const BindingFlags AnyInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private const BindingFlags PublicStatic = BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly; + + private static FieldInfo[] TableFields => typeof(LuaApi.Table).GetFields(AnyInstance); + + [Fact] + public void Table_has_a_slot_for_every_required_function() + { + string[] required = + [ + "luaL_newstate", "lua_close", "lua_newthread", + "lua_gettop", "lua_settop", "lua_pushvalue", "lua_rotate", "lua_copy", "lua_checkstack", "lua_absindex", + "lua_type", "lua_isnumber", "lua_isstring", "lua_iscfunction", "lua_isinteger", "lua_isuserdata", + "lua_tonumberx", "lua_tointegerx", "lua_toboolean", "lua_tolstring", "lua_rawlen", "lua_touserdata", + "lua_topointer", + "lua_pushnil", "lua_pushnumber", "lua_pushinteger", "lua_pushlstring", "lua_pushstring", "lua_pushcclosure", + "lua_pushboolean", "lua_pushlightuserdata", + "lua_getglobal", "lua_setglobal", "lua_gettable", "lua_settable", "lua_getfield", "lua_setfield", + "lua_geti", + "lua_seti", "lua_rawget", "lua_rawset", "lua_rawgeti", "lua_rawseti", "lua_createtable", "lua_newuserdata", + "lua_getmetatable", "lua_setmetatable", "lua_next", + "lua_callk", "lua_pcallk", "luaL_loadbufferx", "luaL_loadstring", "luaL_ref", "luaL_unref", "lua_error" + ]; + + HashSet slots = new(TableFields.Select(static field => field.Name), StringComparer.Ordinal); + + Assert.DoesNotContain(required, name => !slots.Contains(name)); + } + + [Fact] + public void Table_slots_are_unmanaged_cdecl_function_pointers() + { + Assert.NotEmpty(TableFields); + foreach (FieldInfo field in TableFields) + { + Assert.True(field.FieldType.IsUnmanagedFunctionPointer, field.Name); + Type[] conventions = field.GetModifiedFieldType().GetFunctionPointerCallingConventions(); + Assert.True(conventions is [var convention] && convention == typeof(CallConvCdecl), field.Name); + } + } + + [Fact] + public void Public_forwarders_repeat_the_slot_signature_exactly() + { + foreach (FieldInfo field in TableFields) + { + MemberInfo[] members = typeof(LuaApi).GetMember(field.Name, PublicStatic); + MemberInfo forwarder = Assert.Single(members); + + if (forwarder is PropertyInfo property) + { + Assert.True(property.PropertyType == field.FieldType, field.Name); + continue; + } + + MethodInfo method = Assert.IsType(forwarder, false); + Type[] parameters = [.. method.GetParameters().Select(static parameter => parameter.ParameterType)]; + Assert.True(method.ReturnType == field.FieldType.GetFunctionPointerReturnType(), field.Name); + Assert.True(parameters.SequenceEqual(field.FieldType.GetFunctionPointerParameterTypes()), field.Name); + } + } + + [Fact] + public void Public_forwarders_load_their_own_slot_and_pass_the_parameters_in_declared_order() + { + // The type comparison above cannot see a forwarder wired to a sibling slot of the same type (lua_getlocal and + // lua_setlocal, lua_rawget and lua_rawset, ...) or two same-typed arguments in the wrong order. The IL can: + // a forwarder loads exactly one slot, its own, and its arguments are loaded as 0, 1, 2, ... + foreach (FieldInfo field in TableFields) + { + MemberInfo forwarder = Assert.Single(typeof(LuaApi).GetMember(field.Name, PublicStatic)); + MethodInfo? method = forwarder is PropertyInfo property ? property.GetMethod : forwarder as MethodInfo; + Assert.NotNull(method); + + List slots = []; + List arguments = []; + int indirectCalls = 0; + int otherCalls = 0; + foreach ((OpCode code, int operand) in IlReader.Read(method)) + { + if (code.OperandType == OperandType.InlineField) + { + FieldInfo? loaded = method.Module.ResolveField(operand); + if (loaded?.DeclaringType == typeof(LuaApi.Table)) + { + slots.Add(loaded.Name); + } + } + else if (ArgumentIndex(code, operand) is int index) + { + arguments.Add(index); + } + else if (code == OpCodes.Calli) + { + indirectCalls++; + } + else if (code.FlowControl == FlowControl.Call) + { + otherCalls++; + } + } + + Assert.True(slots is [var slot] && string.Equals(slot, field.Name, StringComparison.Ordinal), field.Name); + Assert.True(arguments.SequenceEqual(Enumerable.Range(0, method.GetParameters().Length)), field.Name); + Assert.True(indirectCalls == (forwarder is PropertyInfo ? 0 : 1), field.Name); + Assert.True(otherCalls == 0, field.Name); + } + } + + [Fact] + public void Forwarders_and_macros_are_aggressively_inlined_except_the_two_cold_macros() + { + // "A forwarder costs nothing over a raw calli" holds only while the JIT inlines it. Without the attribute that + // is a per-call-site profitability guess (lua_pushliteral, the macro of the "name"u8 hot path, once lacked it). + string[] binding = [nameof(LuaApi.Initialize), nameof(LuaApi.TryInitialize), nameof(LuaApi.GetMissingExports)]; + string[] cold = [nameof(LuaApi.luaL_dofile), nameof(LuaApi.luaL_dostring)]; + + MethodInfo[] methods = + [ + .. typeof(LuaApi).GetMethods(PublicStatic) + .Where(method => !method.IsSpecialName && !binding.Contains(method.Name, StringComparer.Ordinal)) + ]; + string[] notInlined = + [ + .. methods + .Where(static method => + !method.MethodImplementationFlags.HasFlag(MethodImplAttributes.AggressiveInlining)) + .Select(static method => method.Name) + .Order(StringComparer.Ordinal) + ]; + + Assert.True(methods.Length > TableFields.Length, "The macros are missing from the reflected method set."); + Assert.Equal(cold, notInlined); + } + + [Fact] + public void Pcallk_takes_six_parameters_with_a_pointer_sized_context() + { + Type[] parameters = SlotParameters("lua_pcallk"); + + Assert.Equal(6, parameters.Length); + Assert.Equal(typeof(lua_State*), parameters[0]); + Assert.Equal([typeof(int), typeof(int), typeof(int), typeof(nint)], parameters[1..5]); + Assert.True(parameters[5].IsUnmanagedFunctionPointer); + Assert.Equal(typeof(int), SlotReturn("lua_pcallk")); + } + + [Fact] + public void Callk_takes_five_parameters_with_a_pointer_sized_context() + { + Type[] parameters = SlotParameters("lua_callk"); + + Assert.Equal(5, parameters.Length); + Assert.Equal(typeof(nint), parameters[3]); + Assert.True(parameters[4].IsUnmanagedFunctionPointer); + Assert.Equal(typeof(void), SlotReturn("lua_callk")); + } + + [Fact] + public void Size_t_is_pointer_sized_unsigned() + { + Assert.Equal(typeof(nuint), SlotReturn("lua_rawlen")); + Assert.Equal(typeof(nuint), SlotReturn("lua_stringtonumber")); + Assert.Equal(typeof(nuint*), SlotParameters("lua_tolstring")[2]); + Assert.Equal(typeof(nuint*), SlotParameters("luaL_tolstring")[2]); + Assert.Equal(typeof(nuint), SlotParameters("lua_pushlstring")[2]); + Assert.Equal(typeof(nuint), SlotParameters("lua_newuserdata")[1]); + Assert.Equal(typeof(nuint), SlotParameters("luaL_loadbufferx")[2]); + } + + [Fact] + public void Lua_integer_and_number_are_64_bit() + { + Assert.Equal(typeof(long), SlotParameters("lua_pushinteger")[1]); + Assert.Equal(typeof(long), SlotReturn("lua_tointegerx")); + Assert.Equal(typeof(long), SlotParameters("lua_rawgeti")[2]); + Assert.Equal(typeof(long), SlotParameters("lua_rawseti")[2]); + Assert.Equal(typeof(long), SlotParameters("lua_geti")[2]); + Assert.Equal(typeof(long), SlotParameters("lua_seti")[2]); + Assert.Equal(typeof(long), SlotReturn("luaL_len")); + Assert.Equal(typeof(double), SlotParameters("lua_pushnumber")[1]); + Assert.Equal(typeof(double), SlotReturn("lua_tonumberx")); + } + + [Theory] + [InlineData("lua_isnumber")] + [InlineData("lua_isstring")] + [InlineData("lua_iscfunction")] + [InlineData("lua_isinteger")] + [InlineData("lua_isuserdata")] + [InlineData("lua_toboolean")] + [InlineData("lua_checkstack")] + [InlineData("lua_rawequal")] + [InlineData("lua_compare")] + [InlineData("lua_next")] + [InlineData("lua_getmetatable")] + [InlineData("lua_isyieldable")] + public void Native_predicates_return_c_int_not_bool(string name) + { + Assert.Equal(typeof(int), SlotReturn(name)); + } + + [Fact] + public void No_slot_uses_a_non_blittable_type() + { + foreach (FieldInfo field in TableFields) + { + Type signature = field.FieldType; + Type[] all = [signature.GetFunctionPointerReturnType(), .. signature.GetFunctionPointerParameterTypes()]; + Assert.DoesNotContain(all, + static type => type == typeof(bool) || type == typeof(char) || type == typeof(string) || type.IsByRef); + } + } + + private static int? ArgumentIndex(OpCode code, int operand) + { + if (code == OpCodes.Ldarg_0) + { + return 0; + } + + if (code == OpCodes.Ldarg_1) + { + return 1; + } + + if (code == OpCodes.Ldarg_2) + { + return 2; + } + + if (code == OpCodes.Ldarg_3) + { + return 3; + } + + return code == OpCodes.Ldarg_S || code == OpCodes.Ldarg ? operand : null; + } + + private static Type[] SlotParameters(string name) + { + return Slot(name).FieldType.GetFunctionPointerParameterTypes(); + } + + private static Type SlotReturn(string name) + { + return Slot(name).FieldType.GetFunctionPointerReturnType(); + } + + private static FieldInfo Slot(string name) + { + FieldInfo? field = typeof(LuaApi.Table).GetField(name, AnyInstance); + Assert.NotNull(field); + return field; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/NativeStructLayoutTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/NativeStructLayoutTests.cs index 0dfd40bd..e234b2d9 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/NativeStructLayoutTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Signatures/NativeStructLayoutTests.cs @@ -6,45 +6,45 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Signatures; /// DLL-free: layouts of the two structs that cross the boundary, for a 64-bit process. public sealed unsafe class NativeStructLayoutTests { - [Fact] - public void Lua_debug_matches_the_64_bit_c_layout() - { - Assert.SkipUnless(Environment.Is64BitProcess, "The expected offsets are those of a 64-bit build."); + [Fact] + public void Lua_debug_matches_the_64_bit_c_layout() + { + Assert.SkipUnless(Environment.Is64BitProcess, "The expected offsets are those of a 64-bit build."); - lua_Debug record; - var start = (byte*)&record; + lua_Debug record; + byte* start = (byte*) &record; - Assert.Equal(128, sizeof(lua_Debug)); - Assert.Equal(0, (int)((byte*)&record.@event - start)); - Assert.Equal(8, (int)((byte*)&record.name - start)); - Assert.Equal(16, (int)((byte*)&record.namewhat - start)); - Assert.Equal(24, (int)((byte*)&record.what - start)); - Assert.Equal(32, (int)((byte*)&record.source - start)); - Assert.Equal(40, (int)((byte*)&record.currentline - start)); - Assert.Equal(44, (int)((byte*)&record.linedefined - start)); - Assert.Equal(48, (int)((byte*)&record.lastlinedefined - start)); - Assert.Equal(52, (int)(&record.nups - start)); - Assert.Equal(53, (int)(&record.nparams - start)); - Assert.Equal(54, (int)((byte*)&record.isvararg - start)); - Assert.Equal(55, (int)((byte*)&record.istailcall - start)); - Assert.Equal(56, (int)(record.short_src - start)); - Assert.Equal(120, (int)((byte*)&record.i_ci - start)); - } + Assert.Equal(128, sizeof(lua_Debug)); + Assert.Equal(0, (int) ((byte*) &record.@event - start)); + Assert.Equal(8, (int) ((byte*) &record.name - start)); + Assert.Equal(16, (int) ((byte*) &record.namewhat - start)); + Assert.Equal(24, (int) ((byte*) &record.what - start)); + Assert.Equal(32, (int) ((byte*) &record.source - start)); + Assert.Equal(40, (int) ((byte*) &record.currentline - start)); + Assert.Equal(44, (int) ((byte*) &record.linedefined - start)); + Assert.Equal(48, (int) ((byte*) &record.lastlinedefined - start)); + Assert.Equal(52, (int) (&record.nups - start)); + Assert.Equal(53, (int) (&record.nparams - start)); + Assert.Equal(54, (int) ((byte*) &record.isvararg - start)); + Assert.Equal(55, (int) ((byte*) &record.istailcall - start)); + Assert.Equal(56, (int) (record.short_src - start)); + Assert.Equal(120, (int) ((byte*) &record.i_ci - start)); + } - [Fact] - public void Lua_debug_short_src_has_lua_idsize_bytes() - { - Assert.Equal(60, LuaApi.LUA_IDSIZE); - } + [Fact] + public void Lua_debug_short_src_has_lua_idsize_bytes() + { + Assert.Equal(60, LuaApi.LUA_IDSIZE); + } - [Fact] - public void LuaL_reg_is_two_pointers() - { - luaL_Reg entry; - var start = (byte*)&entry; + [Fact] + public void LuaL_reg_is_two_pointers() + { + luaL_Reg entry; + byte* start = (byte*) &entry; - Assert.Equal(2 * sizeof(nint), sizeof(luaL_Reg)); - Assert.Equal(0, (int)((byte*)&entry.name - start)); - Assert.Equal(sizeof(nint), (int)((byte*)&entry.func - start)); - } + Assert.Equal(2 * sizeof(nint), sizeof(luaL_Reg)); + Assert.Equal(0, (int) ((byte*) &entry.name - start)); + Assert.Equal(sizeof(nint), (int) ((byte*) &entry.func - start)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs index 8d1be158..30bca320 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs @@ -11,63 +11,72 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Support; /// internal static class IlReader { - private const byte TwoBytePrefix = 0xFE; + private const byte TwoBytePrefix = 0xFE; - private static readonly Dictionary s_opCodes = BuildTable(); + private static readonly Dictionary s_opCodes = BuildTable(); - /// Decodes the body of ; operands wider than 32 bits are reported as 0. - public static List<(OpCode Code, int Operand)> Read(MethodBase method) - { - var il = method.GetMethodBody()?.GetILAsByteArray() ?? - throw new InvalidOperationException(method.Name + " has no IL body."); - List<(OpCode Code, int Operand)> instructions = []; + /// Decodes the body of ; operands wider than 32 bits are reported as 0. + public static List<(OpCode Code, int Operand)> Read(MethodBase method) + { + byte[] il = method.GetMethodBody()?.GetILAsByteArray() ?? + throw new InvalidOperationException(method.Name + " has no IL body."); + List<(OpCode Code, int Operand)> instructions = []; - var offset = 0; - while (offset < il.Length) - { - ushort value = il[offset++]; - if (value == TwoBytePrefix) value = (ushort)((TwoBytePrefix << 8) | il[offset++]); + int offset = 0; + while (offset < il.Length) + { + ushort value = il[offset++]; + if (value == TwoBytePrefix) + { + value = (ushort) ((TwoBytePrefix << 8) | il[offset++]); + } - if (!s_opCodes.TryGetValue(value, out var code)) - throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture, - $"{method.Name}: unknown opcode 0x{value:X} at offset {offset}.")); + if (!s_opCodes.TryGetValue(value, out OpCode code)) + { + throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture, + $"{method.Name}: unknown opcode 0x{value:X} at offset {offset}.")); + } - var size = OperandSize(code.OperandType, il, offset); - var operand = size switch - { - 1 => il[offset], - 2 => BitConverter.ToUInt16(il, offset), - 4 => BitConverter.ToInt32(il, offset), - _ => 0 - }; + int size = OperandSize(code.OperandType, il, offset); + int operand = size switch + { + 1 => il[offset], + 2 => BitConverter.ToUInt16(il, offset), + 4 => BitConverter.ToInt32(il, offset), + _ => 0 + }; - instructions.Add((code, operand)); - offset += size; - } + instructions.Add((code, operand)); + offset += size; + } - return instructions; - } + return instructions; + } - private static int OperandSize(OperandType type, byte[] il, int offset) - { - return type switch - { - OperandType.InlineNone => 0, - OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, - OperandType.InlineVar => 2, - OperandType.InlineI8 or OperandType.InlineR => 8, - OperandType.InlineSwitch => 4 + 4 * BitConverter.ToInt32(il, offset), - _ => 4 - }; - } + private static int OperandSize(OperandType type, byte[] il, int offset) + { + return type switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + 4 * BitConverter.ToInt32(il, offset), + _ => 4 + }; + } - private static Dictionary BuildTable() - { - Dictionary table = []; - foreach (var field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) - if (field.GetValue(null) is OpCode code) - table[unchecked((ushort)code.Value)] = code; + private static Dictionary BuildTable() + { + Dictionary table = []; + foreach (FieldInfo field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.GetValue(null) is OpCode code) + { + table[unchecked((ushort) code.Value)] = code; + } + } - return table; - } + return table; + } } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/LuaTest.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/LuaTest.cs index a39ba048..84fdc1ed 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/LuaTest.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/LuaTest.cs @@ -1,7 +1,9 @@ using System.Runtime.InteropServices; using System.Text; + using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Interop.Tests.Support; @@ -9,45 +11,51 @@ namespace CheatEngine.SDK.Lua.Interop.Tests.Support; /// What the NativeLua tests share: the skip guard, chunk loading and string reading. internal static unsafe class LuaTest { - /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. - public static void RequireNativeLua() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - } - - /// Compiles a text chunk named "=test"; returns the load status, with the function or the message pushed. - public static int Load(lua_State* L, ReadOnlySpan source) - { - fixed (byte* text = source) - fixed (byte* name = "=test"u8) - fixed (byte* mode = "t"u8) - { - return luaL_loadbufferx(L, text, (nuint)source.Length, name, mode); - } - } - - /// Compiles and runs a chunk under lua_pcallk, failing the test with the Lua message on any error. - public static void Run(lua_State* L, ReadOnlySpan source, int nresults = 0) - { - if (Load(L, source) != LUA_OK) Assert.Fail("The chunk did not compile: " + ReadString(L, -1)); - - if (lua_pcallk(L, 0, nresults, 0, 0, null) != LUA_OK) Assert.Fail("The chunk raised: " + ReadString(L, -1)); - } - - /// - /// Decodes the string at as UTF-8, NULs included; null when the value is not a string or - /// a number. - /// - public static string? ReadString(lua_State* L, int idx) - { - nuint length; - var bytes = lua_tolstring(L, idx, &length); - return bytes is null ? null : Encoding.UTF8.GetString(bytes, checked((int)length)); - } - - /// Decodes a NUL-terminated C string owned by the Lua library. - public static string? ReadCString(byte* text) - { - return text is null ? null : Encoding.UTF8.GetString(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(text)); - } + /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. + public static void RequireNativeLua() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + } + + /// Compiles a text chunk named "=test"; returns the load status, with the function or the message pushed. + public static int Load(lua_State* L, ReadOnlySpan source) + { + fixed (byte* text = source) + fixed (byte* name = "=test"u8) + fixed (byte* mode = "t"u8) + { + return luaL_loadbufferx(L, text, (nuint) source.Length, name, mode); + } + } + + /// Compiles and runs a chunk under lua_pcallk, failing the test with the Lua message on any error. + public static void Run(lua_State* L, ReadOnlySpan source, int nresults = 0) + { + if (Load(L, source) != LUA_OK) + { + Assert.Fail("The chunk did not compile: " + ReadString(L, -1)); + } + + if (lua_pcallk(L, 0, nresults, 0, 0, null) != LUA_OK) + { + Assert.Fail("The chunk raised: " + ReadString(L, -1)); + } + } + + /// + /// Decodes the string at as UTF-8, NULs included; null when the value is not a string or + /// a number. + /// + public static string? ReadString(lua_State* L, int idx) + { + nuint length; + byte* bytes = lua_tolstring(L, idx, &length); + return bytes is null ? null : Encoding.UTF8.GetString(bytes, checked((int) length)); + } + + /// Decodes a NUL-terminated C string owned by the Lua library. + public static string? ReadCString(byte* text) + { + return text is null ? null : Encoding.UTF8.GetString(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(text)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs index 3998f959..c2325fb5 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs @@ -1,5 +1,7 @@ using System.Text; + using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.References; @@ -18,258 +20,354 @@ namespace CheatEngine.SDK.Lua.Tests.Allocation; [Trait("Category", "NativeLua")] public sealed class ZeroAllocationTests { - private static readonly LuaRef s_add = new(); - - [Fact] - public void Push_and_read_of_each_scalar_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - long sink = 0; - - AllocationGate.AssertZero(() => - { - var top = L.Top; - Int32Marshaller.Push(L, 42); - Int64Marshaller.Push(L, long.MinValue); - DoubleMarshaller.Push(L, 2.5); - SingleMarshaller.Push(L, 1.5f); - BooleanMarshaller.Push(L, true); - AddressMarshaller.Push(L, unchecked((nuint)0xFFFF_FFFF_FFFF_FFF0UL)); - Utf8Marshaller.Push(L, "text"u8); - L.PushNil(); - L.PushLightUserdata(0x10); - - if (!Int32Marshaller.TryRead(L, top + 1, out var i) || i != 42) Fail(); - if (!Int64Marshaller.TryRead(L, top + 2, out var l) || l != long.MinValue) Fail(); - // Exact on purpose: the round trip through the Lua stack must not change a single bit, - // so the bit patterns are compared. - if (!DoubleMarshaller.TryRead(L, top + 3, out var d) || - BitConverter.DoubleToInt64Bits(d) != BitConverter.DoubleToInt64Bits(2.5)) Fail(); - if (!SingleMarshaller.TryRead(L, top + 4, out var f) || - BitConverter.SingleToInt32Bits(f) != BitConverter.SingleToInt32Bits(1.5f)) Fail(); - if (!BooleanMarshaller.TryRead(L, top + 5, out var b) || !b) Fail(); - if (!AddressMarshaller.TryRead(L, top + 6, out var a) || - a != unchecked((nuint)0xFFFF_FFFF_FFFF_FFF0UL)) Fail(); - if (!Utf8Marshaller.TryRead(L, top + 7, out var s) || !s.SequenceEqual("text"u8)) Fail(); - if (L.TypeOf(top + 8) != LuaType.Nil || L.ToUserdata(top + 9) != 0x10) Fail(); - sink += i + l; - L.SetTop(top); - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Non_ascii_utf8_payload_at_the_benchmark_upper_bound_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - var payload = new byte[1024]; - - for (var index = 0; index < payload.Length; index += 4) - { - payload[index] = 0xF0; - payload[index + 1] = 0x9F; - payload[index + 2] = 0xA7; - payload[index + 3] = 0xAA; - } - - long sink = 0; - AllocationGate.AssertZero(() => - { - var top = L.Top; - Utf8Marshaller.Push(L, payload); - if (!Utf8Marshaller.TryRead(L, -1, out var read) || !read.SequenceEqual(payload)) Fail(); - sink += read.Length; - L.SetTop(top); - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Utf16_string_push_allocates_nothing_through_the_stack_buffer() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var L = LuaTest.View(state); - // 171 characters: the encoder's worst case (3 bytes each, plus one) exceeds the 512-byte buffer, so Encode - // takes its exact-count branch; with 11 two-byte characters the text is 182 bytes and still fits. - var text = new string('a', 160) + new string('\u00E9', 11); - var expected = Encoding.UTF8.GetBytes(text); - Assert.Equal(171, text.Length); - Assert.True(Encoding.UTF8.GetMaxByteCount(text.Length) > Utf8Scratch.StackBufferSize); - Assert.True(expected.Length <= Utf8Scratch.StackBufferSize); - long sink = 0; - - AllocationGate.AssertZero(() => - { - var top = L.Top; - StringMarshaller.Push(L, text); - if (!Utf8Marshaller.TryRead(L, -1, out var read) || !read.SequenceEqual(expected)) Fail(); - sink += read.Length; - L.SetTop(top); - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Utf16_string_push_above_the_stack_buffer_allocates_nothing_once_the_pool_is_warm() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - var text = new string('\u20AC', 200); // 600 bytes: rented from the pool and returned after the push - var expected = Encoding.UTF8.GetBytes(text); - Assert.True(expected.Length > Utf8Scratch.StackBufferSize); - long sink = 0; - - AllocationGate.AssertZero(() => - { - var top = L.Top; - L.PushString(text); - if (!Utf8Marshaller.TryRead(L, -1, out var read) || !read.SequenceEqual(expected)) Fail(); - sink += read.Length; - L.SetTop(top); - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Protected_global_call_with_two_arguments_and_one_result_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, "function add(a, b) return a + b end"u8); - long sink = 0; - - AllocationGate.AssertZero(() => - { - var top = L.Top; - if (!LuaGlobalFunctions.TryPush(L, s_add, "add"u8)) Fail(); - Int64Marshaller.Push(L, 40); - Int64Marshaller.Push(L, 2); - if (!L.TryCall(2, 1).IsOk) Fail(); - if (!Int64Marshaller.TryRead(L, -1, out var result) || result != 42) Fail(); - sink += result; - L.SetTop(top); - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Protected_call_of_a_function_on_the_stack_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - LuaTest.Run(L, "return function(a, b) return a * b end"u8, 1); - long sink = 0; - - AllocationGate.AssertZero(() => - { - L.PushValue(1); - Int64Marshaller.Push(L, 6); - Int64Marshaller.Push(L, 7); - if (!L.TryCall(2, 1).IsOk) Fail(); - if (!Int64Marshaller.TryRead(L, -1, out var result) || result != 42) Fail(); - sink += result; - L.Pop(1); - }); - - Assert.NotEqual(0, sink); - Assert.Equal(1, L.Top); - } - - [Fact] - public void Protected_field_access_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, "return setmetatable({}, { __index = function(_, k) return #k end })"u8, 1); - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!L.TryGetField(1, "abcd"u8).IsOk) Fail(); - if (!Int64Marshaller.TryRead(L, -1, out var result) || result != 4) Fail(); - sink += result; - L.Pop(1); - }); - - Assert.NotEqual(0, sink); - } - - [Fact] - public void Callback_round_trip_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - Assert.True(L.TryPushFunction(Thunks.Add).IsOk); - Assert.True(L.TrySetGlobal("add"u8).IsOk); - // The loop lives in Lua: one protected call runs many callbacks, which is the legitimate use of a callback. - LuaTest.Run(L, "return function(n) local s = 0 for i = 1, n do s = s + add(i, count()) end return s end"u8, 1); - long sink = 0; - - AllocationGate.AssertZero( - () => - { - L.PushValue(1); - Int64Marshaller.Push(L, 100); - if (!L.TryCall(1, 1).IsOk) Fail(); - if (!Int64Marshaller.TryRead(L, -1, out var result)) Fail(); - sink += result; - L.Pop(1); - }, - 200); - - Assert.True(counter.Value > 200 * 100); - Assert.NotEqual(0, sink); - callback.Release(L); - } - - [Fact] - public void Failure_path_of_a_protected_call_allocates_nothing_until_the_error_is_read() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, "return function() error('expected') end"u8, 1); - var failures = 0; - - AllocationGate.AssertZero(() => - { - var top = L.Top; - L.PushValue(1); - var status = L.TryCall(0, 0); - if (status.IsOk) Fail(); - failures += LuaCallSupport.Fail(L, top) ? 0 : 1; - }); - - Assert.True(failures > 0); - Assert.Equal(1, L.Top); - } - - private static void Fail() - { - throw new InvalidOperationException("The round trip produced a wrong value."); - } + private static readonly LuaRef s_add = new(); + + [Fact] + public void Push_and_read_of_each_scalar_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + long sink = 0; + + AllocationGate.AssertZero(() => sink += PushAndReadEachScalar(L)); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + private static long PushAndReadEachScalar(LuaState state) + { + int top = state.Top; + PushEachScalar(state); + long result = ReadEachScalar(state, top); + state.SetTop(top); + return result; + } + + private static void PushEachScalar(LuaState state) + { + Int32Marshaller.Push(state, 42); + Int64Marshaller.Push(state, long.MinValue); + DoubleMarshaller.Push(state, 2.5); + SingleMarshaller.Push(state, 1.5f); + BooleanMarshaller.Push(state, true); + AddressMarshaller.Push(state, unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL)); + Utf8Marshaller.Push(state, "text"u8); + state.PushNil(); + state.PushLightUserdata(0x10); + } + + private static long ReadEachScalar(LuaState state, int top) + { + if (!Int32Marshaller.TryRead(state, top + 1, out int i) || i != 42) + { + Fail(); + } + + if (!Int64Marshaller.TryRead(state, top + 2, out long l) || l != long.MinValue) + { + Fail(); + } + + // Exact on purpose: the round trip through the Lua stack must not change a single bit, + // so the bit patterns are compared. + if (!DoubleMarshaller.TryRead(state, top + 3, out double d) || + BitConverter.DoubleToInt64Bits(d) != BitConverter.DoubleToInt64Bits(2.5)) + { + Fail(); + } + + if (!SingleMarshaller.TryRead(state, top + 4, out float f) || + BitConverter.SingleToInt32Bits(f) != BitConverter.SingleToInt32Bits(1.5f)) + { + Fail(); + } + + if (!BooleanMarshaller.TryRead(state, top + 5, out bool b) || !b) + { + Fail(); + } + + if (!AddressMarshaller.TryRead(state, top + 6, out UIntPtr a) || + a != unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL)) + { + Fail(); + } + + if (!Utf8Marshaller.TryRead(state, top + 7, out ReadOnlySpan s) || !s.SequenceEqual("text"u8)) + { + Fail(); + } + + if (state.TypeOf(top + 8) != LuaType.Nil || state.ToUserdata(top + 9) != 0x10) + { + Fail(); + } + + return i + l; + } + + [Fact] + public void Non_ascii_utf8_payload_at_the_benchmark_upper_bound_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + byte[] payload = new byte[1024]; + + for (int index = 0; index < payload.Length; index += 4) + { + payload[index] = 0xF0; + payload[index + 1] = 0x9F; + payload[index + 2] = 0xA7; + payload[index + 3] = 0xAA; + } + + long sink = 0; + AllocationGate.AssertZero(() => + { + int top = L.Top; + Utf8Marshaller.Push(L, payload); + if (!Utf8Marshaller.TryRead(L, -1, out ReadOnlySpan read) || !read.SequenceEqual(payload)) + { + Fail(); + } + + sink += read.Length; + L.SetTop(top); + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Utf16_string_push_allocates_nothing_through_the_stack_buffer() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + // 171 characters: the encoder's worst case (3 bytes each, plus one) exceeds the 512-byte buffer, so Encode + // takes its exact-count branch; with 11 two-byte characters the text is 182 bytes and still fits. + string text = new string('a', 160) + new string('\u00E9', 11); + byte[] expected = Encoding.UTF8.GetBytes(text); + Assert.Equal(171, text.Length); + Assert.True(Encoding.UTF8.GetMaxByteCount(text.Length) > Utf8Scratch.StackBufferSize); + Assert.True(expected.Length <= Utf8Scratch.StackBufferSize); + long sink = 0; + + AllocationGate.AssertZero(() => + { + int top = L.Top; + StringMarshaller.Push(L, text); + if (!Utf8Marshaller.TryRead(L, -1, out ReadOnlySpan read) || !read.SequenceEqual(expected)) + { + Fail(); + } + + sink += read.Length; + L.SetTop(top); + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Utf16_string_push_above_the_stack_buffer_allocates_nothing_once_the_pool_is_warm() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + string text = new('\u20AC', 200); // 600 bytes: rented from the pool and returned after the push + byte[] expected = Encoding.UTF8.GetBytes(text); + Assert.True(expected.Length > Utf8Scratch.StackBufferSize); + long sink = 0; + + AllocationGate.AssertZero(() => + { + int top = L.Top; + L.PushString(text); + if (!Utf8Marshaller.TryRead(L, -1, out ReadOnlySpan read) || !read.SequenceEqual(expected)) + { + Fail(); + } + + sink += read.Length; + L.SetTop(top); + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Protected_global_call_with_two_arguments_and_one_result_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, "function add(a, b) return a + b end"u8); + long sink = 0; + + AllocationGate.AssertZero(() => + { + int top = L.Top; + if (!LuaGlobalFunctions.TryPush(L, s_add, "add"u8)) + { + Fail(); + } + + Int64Marshaller.Push(L, 40); + Int64Marshaller.Push(L, 2); + if (!L.TryCall(2, 1).IsOk) + { + Fail(); + } + + if (!Int64Marshaller.TryRead(L, -1, out long result) || result != 42) + { + Fail(); + } + + sink += result; + L.SetTop(top); + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Protected_call_of_a_function_on_the_stack_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return function(a, b) return a * b end"u8, 1); + long sink = 0; + + AllocationGate.AssertZero(() => + { + L.PushValue(1); + Int64Marshaller.Push(L, 6); + Int64Marshaller.Push(L, 7); + if (!L.TryCall(2, 1).IsOk) + { + Fail(); + } + + if (!Int64Marshaller.TryRead(L, -1, out long result) || result != 42) + { + Fail(); + } + + sink += result; + L.Pop(1); + }); + + Assert.NotEqual(0, sink); + Assert.Equal(1, L.Top); + } + + [Fact] + public void Protected_field_access_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return setmetatable({}, { __index = function(_, k) return #k end })"u8, 1); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!L.TryGetField(1, "abcd"u8).IsOk) + { + Fail(); + } + + if (!Int64Marshaller.TryRead(L, -1, out long result) || result != 4) + { + Fail(); + } + + sink += result; + L.Pop(1); + }); + + Assert.NotEqual(0, sink); + } + + [Fact] + public void Callback_round_trip_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + Assert.True(L.TryPushFunction(Thunks.Add).IsOk); + Assert.True(L.TrySetGlobal("add"u8).IsOk); + // The loop lives in Lua: one protected call runs many callbacks, which is the legitimate use of a callback. + LuaTest.Run(L, "return function(n) local s = 0 for i = 1, n do s = s + add(i, count()) end return s end"u8, 1); + long sink = 0; + + AllocationGate.AssertZero( + () => + { + L.PushValue(1); + Int64Marshaller.Push(L, 100); + if (!L.TryCall(1, 1).IsOk) + { + Fail(); + } + + if (!Int64Marshaller.TryRead(L, -1, out long result)) + { + Fail(); + } + + sink += result; + L.Pop(1); + }, + 200); + + Assert.True(counter.Value > 200 * 100); + Assert.NotEqual(0, sink); + callback.Release(L); + } + + [Fact] + public void Failure_path_of_a_protected_call_allocates_nothing_until_the_error_is_read() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return function() error('expected') end"u8, 1); + int failures = 0; + + AllocationGate.AssertZero(() => + { + int top = L.Top; + L.PushValue(1); + LuaStatus status = L.TryCall(0, 0); + if (status.IsOk) + { + Fail(); + } + + failures += LuaCallSupport.Fail(L, top) ? 0 : 1; + }); + + Assert.True(failures > 0); + Assert.Equal(1, L.Top); + } + + private static void Fail() + { + throw new InvalidOperationException("The round trip produced a wrong value."); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs index 10f70c72..af6c3f6a 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs @@ -1,13 +1,16 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Tests.Callbacks; @@ -19,265 +22,293 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; [Trait("Category", "NativeLua")] public sealed class CallbackLifetimeConcurrencyTests { - private static CallbackRace? s_race; - - private static unsafe LuaNativeFunction PauseBeforeLookupFunction => new(&PauseBeforeLookup); - - private static unsafe LuaNativeFunction PauseAfterLookupFunction => new(&PauseAfterLookup); - - [Fact] - public async Task Release_waits_for_a_thunk_before_its_state_lookup_then_the_thunk_reports_released() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var main = LuaTest.View(state); - using RuntimeScope scope = new(state); - using CallbackRace race = new(cancellationToken); - s_race = race; - - try - { - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(main, PauseBeforeLookupFunction, counter, out var callback) - .IsOk); - Assert.NotNull(callback); - - using var worker = RootedThread.Create(main); - Assert.True(callback.TryPush(worker.State)); - var call = StartCall(worker.State, cancellationToken); - Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The callback did not reach its pre-lookup barrier."); - - // The worker has entered the thunk but is not yet allowed to call TryGetState. Holding the same gate makes - // that lookup wait while this thread neutralizes the closure and frees its handle. - lock (LuaCallbackRegistry.Gate) - { - race.Continue.Set(); - Assert.True(race.LookupAttempted.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The callback did not attempt its state lookup."); - Assert.False(race.LookupReturned.Wait(TimeSpan.FromMilliseconds(250), cancellationToken), - "TryGetState completed while release owned the callback lifetime gate."); - callback.Release(main); - } - - var status = await call.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.True(callback.IsReleased); - Assert.Null(callback.StateObject); - Assert.Equal(0, counter.Value); - } - finally - { - s_race = null; - } - } - - [Fact] - public async Task A_thunk_that_already_acquired_state_can_finish_after_release_frees_its_handle() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var main = LuaTest.View(state); - using RuntimeScope scope = new(state); - using CallbackRace race = new(cancellationToken); - s_race = race; - - try - { - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(main, PauseAfterLookupFunction, counter, out var callback) - .IsOk); - Assert.NotNull(callback); - - using var worker = RootedThread.Create(main); - Assert.True(callback.TryPush(worker.State)); - var call = StartCall(worker.State, cancellationToken); - Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The callback did not acquire its managed state."); - - callback.Release(main); - Assert.Null(callback.StateObject); - race.Continue.Set(); - - var status = await call.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - Assert.Equal(LuaStatus.Ok, status); - Assert.Equal(1, counter.Value); - } - finally - { - s_race = null; - } - } - - [Fact] - public async Task Detach_waits_for_an_admitted_callback_and_rejects_a_callback_that_starts_after_close() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var main = LuaTest.View(state); - using RuntimeScope scope = new(state); - using CallbackRace race = new(cancellationToken); - using ManualResetEventSlim admissionClosed = new(initialState: false); - s_race = race; - LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; - - try - { - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(main, PauseAfterLookupFunction, counter, out var callback).IsOk); - Assert.NotNull(callback); - Assert.True(callback.TryRegister(main, "count"u8).IsOk); - - // This root intentionally lives through Detach. Its Dispose path detects that the attachment is gone and - // abandons its stale registry slot rather than starting a raw Lua operation after teardown. - using var worker = RootedThread.Create(main); - Assert.True(callback.TryPush(worker.State)); - var call = StartCall(worker.State, cancellationToken); - Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The callback did not acquire its managed state."); - - var detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Detach did not close callback admission."); - Assert.False(detach.IsCompleted, - "Detach completed while a previously admitted callback was still running."); - - // The closure is still registered until the active invocation returns, but the shared admission gate must - // refuse this new invocation before it reaches the plugin thunk. - var late = main.TryExecute("local ok, err = pcall(count) return ok, err"u8, 2); - Assert.True(late.IsOk); - Assert.False(main.ToBoolean(-2)); - Assert.Contains("runtime is stopping", LuaTest.ReadString(main, -1), StringComparison.Ordinal); - main.Pop(2); - Assert.Equal(0, counter.Value); - - race.Continue.Set(); - Assert.Equal(LuaStatus.Ok, await call.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)); - await detach.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - Assert.True(callback.IsReleased); - Assert.Null(callback.StateObject); - Assert.Equal(1, counter.Value); - } - finally - { - LuaRuntime.OperationAdmissionClosedForTesting = null; - race.Continue.Set(); - s_race = null; - } - } - - private static Task StartCall(LuaState state, CancellationToken cancellationToken) - { - return Task.Factory.StartNew(() => state.TryCall(0, 1), cancellationToken, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int PauseBeforeLookup(nint pointer) - { - LuaState state = new(pointer); - try - { - var race = s_race ?? throw new InvalidOperationException("No callback race is active."); - race.Entered.Set(); - if (!race.Continue.Wait(TimeSpan.FromSeconds(5), race.CancellationToken)) - return LuaThunk.Fail(state, "test barrier timed out"u8); - - race.LookupAttempted.Set(); - var found = LuaThunk.TryGetState(state, out Counter? _); - race.LookupReturned.Set(); - return found ? 0 : LuaThunk.Fail(state, "callback released"u8); - } - catch (Exception exception) - { - return LuaThunk.Fail(state, exception); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int PauseAfterLookup(nint pointer) - { - LuaState state = new(pointer); - try - { - var race = s_race ?? throw new InvalidOperationException("No callback race is active."); - if (!LuaThunk.TryGetState(state, out Counter? counter)) return LuaThunk.Fail(state, "callback released"u8); - - race.Entered.Set(); - if (!race.Continue.Wait(TimeSpan.FromSeconds(5), race.CancellationToken)) - return LuaThunk.Fail(state, "test barrier timed out"u8); - - counter.Value++; - Int32Marshaller.Push(state, counter.Value); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(state, exception); - } - } - - private sealed class CallbackRace(CancellationToken cancellationToken) : IDisposable - { - public ManualResetEventSlim Entered { get; } = new(false); - - public ManualResetEventSlim Continue { get; } = new(false); - - public ManualResetEventSlim LookupAttempted { get; } = new(false); - - public ManualResetEventSlim LookupReturned { get; } = new(false); - - public CancellationToken CancellationToken { get; } = cancellationToken; - - public void Dispose() - { - Continue.Set(); - Entered.Dispose(); - Continue.Dispose(); - LookupAttempted.Dispose(); - LookupReturned.Dispose(); - } - } - - private sealed unsafe class RootedThread : IDisposable - { - private readonly LuaRef _root; - - private RootedThread(LuaState state, LuaRef root) - { - State = state; - _root = root; - } - - public LuaState State { get; } - - public void Dispose() - { - // The root may deliberately outlive the SDK attachment in the detach race. Releasing through the normal - // operation lease when one is available, and otherwise abandoning the stale registry slot, avoids using - // the legacy unleased AcquireState escape hatch during teardown. - if (!LuaRuntime.TryAcquireOperation(out var operation)) - { - _root.Release(default); - return; - } - - using (operation) - { - _root.Release(operation.State); - } - } - - public static RootedThread Create(LuaState main) - { - var thread = lua_newthread(main.Pointer); - Assert.NotEqual(nint.Zero, (nint)thread); - return new RootedThread(new LuaState(thread), main.CreateRef()); - } - } + private static CallbackRace? s_race; + + private static unsafe LuaNativeFunction PauseBeforeLookupFunction => new(&PauseBeforeLookup); + + private static unsafe LuaNativeFunction PauseAfterLookupFunction => new(&PauseAfterLookup); + + [Fact] + public async Task Release_waits_for_a_thunk_before_its_state_lookup_then_the_thunk_reports_released() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using CallbackRace race = new(cancellationToken); + s_race = race; + + try + { + Counter counter = new(); + Assert.True(LuaCallback + .TryCreate(main, PauseBeforeLookupFunction, counter, out LuaCallback? callback) + .IsOk); + Assert.NotNull(callback); + + using RootedThread worker = RootedThread.Create(main); + Assert.True(callback.TryPush(worker.State)); + Task call = StartCall(worker.State, cancellationToken); + Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The callback did not reach its pre-lookup barrier."); + + // The worker has entered the thunk but is not yet allowed to call TryGetState. Holding the same gate makes + // that lookup wait while this thread neutralizes the closure and frees its handle. + lock (LuaCallbackRegistry.Gate) + { + race.Continue.Set(); + Assert.True(race.LookupAttempted.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The callback did not attempt its state lookup."); + Assert.False(race.LookupReturned.Wait(TimeSpan.FromMilliseconds(250), cancellationToken), + "TryGetState completed while release owned the callback lifetime gate."); + callback.Release(main); + } + + LuaStatus status = await call.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.True(callback.IsReleased); + Assert.Null(callback.StateObject); + Assert.Equal(0, counter.Value); + } + finally + { + s_race = null; + } + } + + [Fact] + public async Task A_thunk_that_already_acquired_state_can_finish_after_release_frees_its_handle() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using CallbackRace race = new(cancellationToken); + s_race = race; + + try + { + Counter counter = new(); + Assert.True(LuaCallback + .TryCreate(main, PauseAfterLookupFunction, counter, out LuaCallback? callback) + .IsOk); + Assert.NotNull(callback); + + using RootedThread worker = RootedThread.Create(main); + Assert.True(callback.TryPush(worker.State)); + Task call = StartCall(worker.State, cancellationToken); + Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The callback did not acquire its managed state."); + + callback.Release(main); + Assert.Null(callback.StateObject); + race.Continue.Set(); + + LuaStatus status = await call.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + Assert.Equal(LuaStatus.Ok, status); + Assert.Equal(1, counter.Value); + } + finally + { + s_race = null; + } + } + + [Fact] + public async Task Detach_waits_for_an_admitted_callback_and_rejects_a_callback_that_starts_after_close() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using CallbackRace race = new(cancellationToken); + using ManualResetEventSlim admissionClosed = new(false); + s_race = race; + LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; + + try + { + Counter counter = new(); + Assert.True(LuaCallback + .TryCreate(main, PauseAfterLookupFunction, counter, out LuaCallback? callback).IsOk); + Assert.NotNull(callback); + Assert.True(callback.TryRegister(main, "count"u8).IsOk); + + // This root intentionally lives through Detach. Its Dispose path detects that the attachment is gone and + // abandons its stale registry slot rather than starting a raw Lua operation after teardown. + using RootedThread worker = RootedThread.Create(main); + Assert.True(callback.TryPush(worker.State)); + Task call = StartCall(worker.State, cancellationToken); + Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The callback did not acquire its managed state."); + + Task detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Detach did not close callback admission."); + Assert.False(detach.IsCompleted, + "Detach completed while a previously admitted callback was still running."); + + // The closure is still registered until the active invocation returns, but the shared admission gate must + // refuse this new invocation before it reaches the plugin thunk. + LuaStatus late = main.TryExecute("local ok, err = pcall(count) return ok, err"u8, 2); + Assert.True(late.IsOk); + Assert.False(main.ToBoolean(-2)); + Assert.Contains("runtime is stopping", LuaTest.ReadString(main, -1), StringComparison.Ordinal); + main.Pop(2); + Assert.Equal(0, counter.Value); + + race.Continue.Set(); + Assert.Equal(LuaStatus.Ok, await call.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)); + await detach.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + Assert.True(callback.IsReleased); + Assert.Null(callback.StateObject); + Assert.Equal(1, counter.Value); + } + finally + { + LuaRuntime.OperationAdmissionClosedForTesting = null; + race.Continue.Set(); + s_race = null; + } + } + + private static Task StartCall(LuaState state, CancellationToken cancellationToken) + { + return Task.Factory.StartNew(() => state.TryCall(0, 1), cancellationToken, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int PauseBeforeLookup(nint pointer) + { + LuaState state = new(pointer); + try + { + CallbackRace race = s_race ?? throw new InvalidOperationException("No callback race is active."); + race.Entered.Set(); + if (!race.Continue.Wait(TimeSpan.FromSeconds(5), race.CancellationToken)) + { + return LuaThunk.Fail(state, "test barrier timed out"u8); + } + + race.LookupAttempted.Set(); + bool found = LuaThunk.TryGetState(state, out Counter? _); + race.LookupReturned.Set(); + return found ? 0 : LuaThunk.Fail(state, "callback released"u8); + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int PauseAfterLookup(nint pointer) + { + LuaState state = new(pointer); + try + { + CallbackRace race = s_race ?? throw new InvalidOperationException("No callback race is active."); + if (!LuaThunk.TryGetState(state, out Counter? counter)) + { + return LuaThunk.Fail(state, "callback released"u8); + } + + race.Entered.Set(); + if (!race.Continue.Wait(TimeSpan.FromSeconds(5), race.CancellationToken)) + { + return LuaThunk.Fail(state, "test barrier timed out"u8); + } + + counter.Value++; + Int32Marshaller.Push(state, counter.Value); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + + private sealed class CallbackRace(CancellationToken cancellationToken) : IDisposable + { + public ManualResetEventSlim Entered + { + get; + } = new(false); + + public ManualResetEventSlim Continue + { + get; + } = new(false); + + public ManualResetEventSlim LookupAttempted + { + get; + } = new(false); + + public ManualResetEventSlim LookupReturned + { + get; + } = new(false); + + public CancellationToken CancellationToken + { + get; + } = cancellationToken; + + public void Dispose() + { + Continue.Set(); + Entered.Dispose(); + Continue.Dispose(); + LookupAttempted.Dispose(); + LookupReturned.Dispose(); + } + } + + private sealed unsafe class RootedThread : IDisposable + { + private readonly LuaRef _root; + + private RootedThread(LuaState state, LuaRef root) + { + State = state; + _root = root; + } + + public LuaState State + { + get; + } + + public void Dispose() + { + // The root may deliberately outlive the SDK attachment in the detach race. Releasing through the normal + // operation lease when one is available, and otherwise abandoning the stale registry slot, avoids using + // the legacy unleased AcquireState escape hatch during teardown. + if (!LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)) + { + _root.Release(default); + return; + } + + using (operation) + { + _root.Release(operation.State); + } + } + + public static RootedThread Create(LuaState main) + { + lua_State* thread = lua_newthread(main.Pointer); + Assert.NotEqual(nint.Zero, (nint) thread); + return new RootedThread(new LuaState(thread), main.CreateRef()); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Counter.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Counter.cs index 87f2c8ca..195cfa66 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Counter.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Counter.cs @@ -5,7 +5,15 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; /// The managed state a carries in the callback tests. internal sealed class Counter { - public int Value { get; set; } + public int Value + { + get; + set; + } - public nint LastState { get; set; } + public nint LastState + { + get; + set; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackRegistryTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackRegistryTests.cs index 32fbd712..80df2809 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackRegistryTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackRegistryTests.cs @@ -9,114 +9,114 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; /// public sealed class LuaCallbackRegistryTests { - [Fact] - public void Remove_unlinks_the_head_a_middle_callback_and_the_tail() - { - var tail = NewCallback(); - var middle = NewCallback(); - var head = NewCallback(); - LuaCallbackRegistry.Add(tail); - LuaCallbackRegistry.Add(middle); - LuaCallbackRegistry.Add(head); - try - { - Assert.Equal(3, LuaCallbackRegistry.Count); - - LuaCallbackRegistry.Remove(middle); - - Assert.Equal(2, LuaCallbackRegistry.Count); - Assert.False(middle.IsLinked); - Assert.Null(middle.Next); - Assert.Null(middle.Previous); - Assert.Same(tail, head.Next); - Assert.Same(head, tail.Previous); - - LuaCallbackRegistry.Remove(tail); - - Assert.Equal(1, LuaCallbackRegistry.Count); - Assert.Null(head.Next); - - LuaCallbackRegistry.Remove(head); - - Assert.Equal(0, LuaCallbackRegistry.Count); - Assert.False(head.IsLinked); - - // Not linked any more: nothing to do, and the list is not touched. - LuaCallbackRegistry.Remove(head); - Assert.Equal(0, LuaCallbackRegistry.Count); - } - finally - { - LuaCallbackRegistry.Remove(head); - LuaCallbackRegistry.Remove(middle); - LuaCallbackRegistry.Remove(tail); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void Remove_takes_the_gate_itself_for_the_head_and_for_any_other_position(bool removeHead) - { - var first = NewCallback(); - var second = NewCallback(); - LuaCallbackRegistry.Add(first); - LuaCallbackRegistry.Add(second); - var target = removeHead ? second : first; - Thread worker = new(() => LuaCallbackRegistry.Remove(target)); - try - { - lock (LuaCallbackRegistry.Gate) - { - worker.Start(); - - // The worker cannot finish while this thread holds the gate. A branch that did not take it would be - // done in microseconds. - Assert.False(worker.Join(TimeSpan.FromMilliseconds(200))); - Assert.True(target.IsLinked); - } - - Assert.True(worker.Join(TimeSpan.FromSeconds(30))); - Assert.False(target.IsLinked); - Assert.Equal(1, LuaCallbackRegistry.Count); - } - finally - { - LuaCallbackRegistry.Remove(first); - LuaCallbackRegistry.Remove(second); - } - } - - [Fact] - public void Release_and_Remove_enter_the_gate_again_on_a_thread_that_holds_it() - { - var callback = NewCallback(); - var other = NewCallback(); - LuaCallbackRegistry.Add(callback); - LuaCallbackRegistry.Add(other); - try - { - lock (LuaCallbackRegistry.Gate) - { - callback.Release(default); - Assert.True(callback.IsReleased); - Assert.False(callback.IsLinked); - - LuaCallbackRegistry.Remove(other); - Assert.False(other.IsLinked); - } - - Assert.Equal(0, LuaCallbackRegistry.Count); - } - finally - { - LuaCallbackRegistry.Remove(callback); - LuaCallbackRegistry.Remove(other); - } - } - - private static LuaCallback NewCallback() - { - return new LuaCallback(default, new LuaRef(), new LuaRef()); - } + [Fact] + public void Remove_unlinks_the_head_a_middle_callback_and_the_tail() + { + LuaCallback tail = NewCallback(); + LuaCallback middle = NewCallback(); + LuaCallback head = NewCallback(); + LuaCallbackRegistry.Add(tail); + LuaCallbackRegistry.Add(middle); + LuaCallbackRegistry.Add(head); + try + { + Assert.Equal(3, LuaCallbackRegistry.Count); + + LuaCallbackRegistry.Remove(middle); + + Assert.Equal(2, LuaCallbackRegistry.Count); + Assert.False(middle.IsLinked); + Assert.Null(middle.Next); + Assert.Null(middle.Previous); + Assert.Same(tail, head.Next); + Assert.Same(head, tail.Previous); + + LuaCallbackRegistry.Remove(tail); + + Assert.Equal(1, LuaCallbackRegistry.Count); + Assert.Null(head.Next); + + LuaCallbackRegistry.Remove(head); + + Assert.Equal(0, LuaCallbackRegistry.Count); + Assert.False(head.IsLinked); + + // Not linked any more: nothing to do, and the list is not touched. + LuaCallbackRegistry.Remove(head); + Assert.Equal(0, LuaCallbackRegistry.Count); + } + finally + { + LuaCallbackRegistry.Remove(head); + LuaCallbackRegistry.Remove(middle); + LuaCallbackRegistry.Remove(tail); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Remove_takes_the_gate_itself_for_the_head_and_for_any_other_position(bool removeHead) + { + LuaCallback first = NewCallback(); + LuaCallback second = NewCallback(); + LuaCallbackRegistry.Add(first); + LuaCallbackRegistry.Add(second); + LuaCallback target = removeHead ? second : first; + Thread worker = new(() => LuaCallbackRegistry.Remove(target)); + try + { + lock (LuaCallbackRegistry.Gate) + { + worker.Start(); + + // The worker cannot finish while this thread holds the gate. A branch that did not take it would be + // done in microseconds. + Assert.False(worker.Join(TimeSpan.FromMilliseconds(200))); + Assert.True(target.IsLinked); + } + + Assert.True(worker.Join(TimeSpan.FromSeconds(30))); + Assert.False(target.IsLinked); + Assert.Equal(1, LuaCallbackRegistry.Count); + } + finally + { + LuaCallbackRegistry.Remove(first); + LuaCallbackRegistry.Remove(second); + } + } + + [Fact] + public void Release_and_Remove_enter_the_gate_again_on_a_thread_that_holds_it() + { + LuaCallback callback = NewCallback(); + LuaCallback other = NewCallback(); + LuaCallbackRegistry.Add(callback); + LuaCallbackRegistry.Add(other); + try + { + lock (LuaCallbackRegistry.Gate) + { + callback.Release(default); + Assert.True(callback.IsReleased); + Assert.False(callback.IsLinked); + + LuaCallbackRegistry.Remove(other); + Assert.False(other.IsLinked); + } + + Assert.Equal(0, LuaCallbackRegistry.Count); + } + finally + { + LuaCallbackRegistry.Remove(callback); + LuaCallbackRegistry.Remove(other); + } + } + + private static LuaCallback NewCallback() + { + return new LuaCallback(default, new LuaRef(), new LuaRef()); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs index 41929c09..462bf03c 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs @@ -6,6 +6,7 @@ using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Tests.Callbacks; @@ -17,804 +18,832 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; [Trait("Category", "NativeLua")] public sealed class LuaCallbackTests { - [Fact] - public void A_stateless_function_is_registered_and_called() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - - Assert.True(L.TryPushFunction(Thunks.Add).IsOk); - Assert.True(L.IsFunction(-1)); - Assert.True(L.TrySetGlobal("add"u8).IsOk); - Assert.Equal(0, L.Top); - - LuaTest.Run(L, "return add(40, 2), add(-1, 1)"u8, 2); - Assert.True(L.TryReadInteger(1, out var first)); - Assert.Equal(42, first); - Assert.True(L.TryReadInteger(2, out var second)); - Assert.Equal(0, second); - } - - [Fact] - public void Generated_function_closure_retained_across_state_reset_is_rejected() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - - Assert.True(LuaRuntime.TryPushGeneratedFunction(L, Thunks.Add).IsOk); - Assert.True(L.TrySetGlobal("add"u8).IsOk); - LuaTest.Run(L, "savedAdd = add"u8); - - using (LuaRuntime.BeginStateReset()) - { - } - - LuaTest.Run(L, "local ok, err = pcall(savedAdd, 1, 2) return ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("Lua function registration has expired", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - } - - [Fact] - public void A_failure_reported_by_the_thunk_is_a_catchable_lua_error_with_the_message() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Add, "add"u8); - - LuaTest.Run(L, "local ok, err = pcall(add, 'x', 2)\nreturn ok, err"u8, 2); - - Assert.True(BooleanMarshaller.TryRead(L, 1, out var ok)); - Assert.False(ok); - var message = LuaTest.ReadString(L, 2); - Assert.Contains("add expects two numbers", message, StringComparison.Ordinal); - } - - [Fact] - public void An_unprotected_call_of_a_failing_thunk_fails_the_enclosing_protected_call() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Add, "add"u8); - - var status = L.TryExecute("local v = add(nil, nil)\nreturn v"u8, 1, "=script"u8); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(1, L.Top); - var message = LuaError.FromStack(L, status).Message; - Assert.Contains("add expects two numbers", message, StringComparison.Ordinal); - // error(message, 2) blames the script line that called the function, not the SDK's wrapper. - Assert.StartsWith("script:1:", message, StringComparison.Ordinal); - } - - [Fact] - public void A_managed_exception_inside_a_thunk_never_escapes_and_becomes_a_lua_error() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Throw, "throwing"u8); - - LuaTest.Run(L, "local ok, err = pcall(throwing)\nreturn ok, err"u8, 2); - - Assert.True(BooleanMarshaller.TryRead(L, 1, out var ok)); - Assert.False(ok); - var message = LuaTest.ReadString(L, 2); - Assert.Contains("System.InvalidOperationException", message, StringComparison.Ordinal); - Assert.Contains("managed boom", message, StringComparison.Ordinal); - Assert.Equal(2, L.Top); - } - - [Fact] - public void An_unchecked_function_hands_the_sentinel_and_message_to_the_caller_as_results() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - L.PushUncheckedFunction(Thunks.Add); - Assert.True(L.TrySetGlobal("rawAdd"u8).IsOk); - - LuaTest.Run(L, "local a, b = rawAdd('x', 1)\nreturn type(a), b, rawAdd(1, 2)"u8, 3); - - Assert.Equal("userdata", LuaTest.ReadString(L, 1)); - Assert.Equal("add expects two numbers", LuaTest.ReadString(L, 2)); - Assert.True(L.TryReadInteger(3, out var sum)); - Assert.Equal(3, sum); - } - - [Fact] - public void Echo_shows_that_a_thunk_sees_exactly_the_caller_arguments_and_returns_exactly_its_results() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Echo, "echo"u8); - - LuaTest.Run(L, "return select('#', echo()), select('#', echo(nil)), echo(1, 'two', nil)"u8, 5); - - Assert.True(L.TryReadInteger(1, out var none)); - Assert.Equal(0, none); - Assert.True(L.TryReadInteger(2, out var oneNil)); - Assert.Equal(1, oneNil); - Assert.True(L.TryReadInteger(3, out var one)); - Assert.Equal(1, one); - Assert.Equal("two", LuaTest.ReadString(L, 4)); - Assert.True(L.IsNil(5)); - } - - [Fact] - public void A_callback_carries_its_state_through_the_upvalue() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - - var status = LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback); - - Assert.True(status.IsOk); - Assert.NotNull(callback); - Assert.Equal(0, L.Top); - Assert.Same(counter, callback.State); - Assert.Same(counter, callback.StateObject); - Assert.True(callback.IsCurrent); - Assert.False(callback.IsReleased); - Assert.Equal(1, LuaCallbackRegistry.Count); - - Assert.True(callback.TryRegister(L, "count"u8).IsOk); - LuaTest.Run(L, "return count(), count(), count()"u8, 3); - Assert.Equal(3, counter.Value); - Assert.Equal(state.Pointer, counter.LastState); - Assert.True(L.TryReadInteger(3, out var third)); - Assert.Equal(3, third); - - callback.Release(L); - Assert.Equal(0, LuaCallbackRegistry.Count); - } - - [Fact] - public unsafe void Protected_raw_setters_accept_a_table_closure_upvalue() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - - L.CreateTable(); - lua_pushcclosure(L.Pointer, Thunks.SetUpvalueTable.Pointer, 1); - var status = L.TryCall(0, 1); - - Assert.True(status.IsOk, status.IsOk ? "" : LuaError.FromStack(L, status).Message); - L.PushString("name"u8); - Assert.Equal(LuaType.Number, L.RawGet(1)); - Assert.True(L.TryReadInteger(-1, out var named)); - Assert.Equal(1, named); - L.Pop(1); - - Assert.Equal(LuaType.Number, L.RawGetIndex(1, 2)); - Assert.True(L.TryReadInteger(-1, out var indexed)); - Assert.Equal(2, indexed); - L.Pop(1); - - Assert.Equal(LuaType.Number, L.RawGetPointer(1, 0x77)); - Assert.True(L.TryReadInteger(-1, out var pointed)); - Assert.Equal(3, pointed); - L.Pop(1); - } - - [Fact] - public void Two_callbacks_of_the_same_thunk_have_independent_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter a = new(); - Counter b = new() { Value = 100 }; - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, a, out var callbackA).IsOk); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, b, out var callbackB).IsOk); - Assert.True(callbackA!.TryRegister(L, "countA"u8).IsOk); - Assert.True(callbackB!.TryRegister(L, "countB"u8).IsOk); - - LuaTest.Run(L, "countA(); countA(); return countB()"u8, 1); - - Assert.Equal(2, a.Value); - Assert.Equal(101, b.Value); - callbackA.Release(L); - callbackB.Release(L); - } - - [Fact] - public void Releasing_a_callback_neutralizes_the_closure_a_script_kept() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - LuaTest.Run(L, "kept = count"u8); - - callback.Release(L); - callback.Release(L); - - Assert.True(callback.IsReleased); - Assert.False(callback.IsCurrent); - Assert.Null(callback.State); - Assert.False(callback.TryPush(L)); - - // A Try* member reports, it does not throw: a runtime-error status with the message on the stack, like any - // other failed protected operation. - var status = callback.TryRegister(L, "again"u8); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(1, L.Top); - Assert.Contains("released", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - // The function a script kept still exists, but its upvalue is gone: it reports instead of touching freed memory. - LuaTest.Run(L, "local ok, err = pcall(kept)\nreturn ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - Assert.Equal(0, counter.Value); - } - - [Fact] - public void Detach_neutralizes_every_callback_the_plugin_forgot() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - LuaCallback? callback; - using (new RuntimeScope(state)) - { - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - LuaTest.Run(L, "count()"u8); - Assert.Equal(1, LuaCallbackRegistry.Count); - } - - Assert.True(callback.IsReleased); - Assert.Equal(0, LuaCallbackRegistry.Count); - Assert.Null(callback.StateObject); - - LuaTest.Run(L, "local ok, err = pcall(count)\nreturn ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - Assert.Equal(1, counter.Value); - } - - [Fact] - public void BeginStateReset_neutralizes_every_callback_before_the_state_is_replaced() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - LuaTest.Run(L, "kept = count"u8); - var before = LuaRuntime.CurrentStateIdentity; - - using (LuaRuntime.BeginStateReset()) - { - } - - var after = LuaRuntime.CurrentStateIdentity; - Assert.Equal(before.AttachEpoch, after.AttachEpoch); - Assert.Equal(before.StateGeneration + 1, after.StateGeneration); - Assert.True(callback.IsReleased); - Assert.False(callback.IsCurrent); - Assert.Null(callback.StateObject); - Assert.Equal(0, LuaCallbackRegistry.Count); - - // The fixture leaves the old state alive so this verifies preparation order: the old closure was neutralized - // before a future host reset can replace that state. - LuaTest.Run(L, "local ok, err = pcall(kept)\nreturn ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - Assert.Equal(0, counter.Value); - } - - [Fact] - public void Dispose_while_detached_abandons_the_handle_instead_of_freeing_it_behind_the_closure() - { - LuaTest.RequireNativeLua(); - LuaRuntime.Detach(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - - callback.Dispose(); - - Assert.True(callback.IsReleased); - Assert.Equal(0, LuaCallbackRegistry.Count); - // No state could be acquired, so the closure was not neutralized and the state object deliberately stays alive. - LuaTest.Run(L, "return count()"u8, 1); - Assert.Equal(1, counter.Value); - } - - [Fact] - public async Task Detach_closes_admission_and_drains_callback_creation_before_registry_publication() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - using ManualResetEventSlim creationPaused = new(initialState: false); - using ManualResetEventSlim allowPublication = new(initialState: false); - using ManualResetEventSlim admissionClosed = new(initialState: false); - Counter counter = new(); - - LuaCallback.BeforeRegistryAddForTesting = () => - { - creationPaused.Set(); - if (!allowPublication.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The callback-publication barrier timed out."); - }; - LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; - - try - { - var creation = Task.Factory.StartNew(() => - { - var status = LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback); - Assert.True(status.IsOk); - return callback; - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(creationPaused.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Callback creation did not reach its pre-registry barrier."); - - var detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Detach did not close Lua operation admission."); - Assert.False(LuaRuntime.TryAcquireOperation(out var rejected)); - rejected.Dispose(); - Assert.False(detach.IsCompleted, - "Detach completed while an admitted callback creation had not published to the registry."); - - allowPublication.Set(); - var callback = await creation.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - await detach.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - Assert.NotNull(callback); - Assert.True(callback.IsReleased); - Assert.Null(callback.StateObject); - Assert.Equal(0, LuaCallbackRegistry.Count); - } - finally - { - LuaCallback.BeforeRegistryAddForTesting = null; - LuaRuntime.OperationAdmissionClosedForTesting = null; - allowPublication.Set(); - } - } - - [Fact] - public async Task Dispose_after_detach_closes_admission_defers_to_callback_neutralization() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - using ManualResetEventSlim admissionClosed = new(initialState: false); - using ManualResetEventSlim allowCleanup = new(initialState: false); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.NotNull(callback); - Assert.True(callback.TryRegister(L, "count"u8).IsOk); - - LuaRuntime.OperationAdmissionClosedForTesting = () => - { - admissionClosed.Set(); - if (!allowCleanup.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The detach-cleanup barrier timed out."); - }; - - try - { - var detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Detach did not close operation admission."); - - callback.Dispose(); - - // Detach has the only state that may clear the Lua upvalue. Dispose therefore leaves the callback linked - // until that cleanup runs instead of abandoning its handle and losing the closure from the registry. - Assert.False(callback.IsReleased); - Assert.Equal(1, LuaCallbackRegistry.Count); - - allowCleanup.Set(); - await detach.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - Assert.True(callback.IsReleased); - Assert.Null(callback.StateObject); - Assert.Equal(0, LuaCallbackRegistry.Count); - - LuaTest.Run(L, "local ok, err = pcall(count) return ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - Assert.Equal(0, counter.Value); - } - finally - { - LuaRuntime.OperationAdmissionClosedForTesting = null; - allowCleanup.Set(); - } - } - - [Fact] - public async Task Dispose_observing_closed_admission_remains_linked_when_detach_failure_reopens_it() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - using DisposeAdmissionFailureRace race = new(cancellationToken); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, new Counter(), out var first).IsOk); - Assert.True(first!.TryRegister(L, "first"u8).IsOk); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, new Counter(), out var second).IsOk); - Assert.True(second!.TryRegister(L, "second"u8).IsOk); - - var detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(race.AdmissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Detach did not close operation admission."); - - var dispose = Task.Factory.StartNew(first.Dispose, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(race.DisposeObservedRefusal.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Dispose did not observe the closed admission gate."); - - race.AllowDetachCleanup.Set(); - await Assert.ThrowsAsync(() => detach.WaitAsync(TimeSpan.FromSeconds(5), - cancellationToken)); - Assert.True(second.IsReleased); - Assert.False(first.IsReleased); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(1, LuaCallbackRegistry.Count); - - race.AllowDisposeToReturn.Set(); - await dispose.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - Assert.False(first.IsReleased); - Assert.Equal(1, LuaCallbackRegistry.Count); - - DisposeAdmissionFailureRace.DisableFailureSeams(); - LuaRuntime.Detach(); - - Assert.True(first.IsReleased); - Assert.Equal(0, LuaCallbackRegistry.Count); - LuaTest.Run(L, "local ok, err = pcall(first) return ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - } - - [Fact] - public void Detach_cleanup_failure_keeps_remaining_callbacks_and_allows_a_retry() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Counter firstCounter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, firstCounter, out var first).IsOk); - Assert.True(first!.TryRegister(L, "first"u8).IsOk); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, new Counter(), out var second).IsOk); - Assert.True(second!.TryRegister(L, "second"u8).IsOk); - var releases = 0; - LuaCallbackRegistry.AfterReleaseForTesting = () => - { - if (++releases == 1) throw new InvalidOperationException("deterministic callback cleanup failure"); - }; - - try - { - Assert.Throws(LuaRuntime.Detach); - - // The newly created callback was the registry head and was released before the injected error. Its older - // sibling remains linked; the old host binding is still published and normal work is admitted again. - Assert.True(second.IsReleased); - Assert.False(first.IsReleased); - Assert.Equal(1, LuaCallbackRegistry.Count); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); - Assert.True(LuaRuntime.TryAcquireOperation(out var operation)); - operation.Dispose(); - - LuaTest.Run(L, "return first()"u8, 1); - Assert.Equal(1, firstCounter.Value); - L.Pop(1); - - LuaCallbackRegistry.AfterReleaseForTesting = null; - LuaRuntime.Detach(); - - Assert.True(first.IsReleased); - Assert.Equal(0, LuaCallbackRegistry.Count); - LuaTest.Run(L, "local ok, err = pcall(first) return ok, err"u8, 2); - Assert.False(L.ToBoolean(1)); - Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - } - finally - { - LuaCallbackRegistry.AfterReleaseForTesting = null; - } - } - - [Fact] - public unsafe void Failed_attach_replacement_reopens_admission_for_the_previous_binding() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state, withPusher: false); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.NotNull(callback); - var replacement = HostDouble.CreateBinding(state.L); - LuaCallbackRegistry.AfterReleaseForTesting = static () => - throw new InvalidOperationException("deterministic callback cleanup failure"); - - try - { - Assert.Throws(() => LuaRuntime.Attach(replacement)); - - Assert.True(callback.IsReleased); - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); - Assert.True(LuaRuntime.TryAcquireOperation(out var operation)); - operation.Dispose(); - } - finally - { - LuaCallbackRegistry.AfterReleaseForTesting = null; - } - } - - [Fact] - public void Dispose_with_the_runtime_attached_releases_like_release() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - - callback.Dispose(); - - Assert.Null(callback.StateObject); - LuaTest.Run(L, "return pcall(count)"u8, 2); - Assert.False(L.ToBoolean(1)); - } - - [Fact] - public void A_callback_created_in_an_earlier_epoch_is_stale_but_still_safe() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - LuaCallback? callback; - using (new RuntimeScope(state)) - { - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out callback).IsOk); - } - - // Detach released it already; a fresh epoch makes any surviving reference stale. - using (new RuntimeScope(state)) - { - Assert.False(callback!.IsCurrent); - Assert.False(callback.TryPush(L)); - Assert.Equal(0, L.Top); - } - } - - [Fact] - public void TryCreate_validates_its_arguments() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - - Assert.Throws(() => LuaCallback.TryCreate(L, default, new Counter(), out _)); - Assert.Throws(() => LuaCallback.TryCreate(L, Thunks.Count, null!, out _)); - Assert.Throws(() => L.TryPushFunction(default)); - Assert.Throws(() => L.PushUncheckedFunction(default)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void A_string_argument_reaches_a_string_parameter_and_a_wrong_one_is_named_like_lua_does() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Greet, "greet"u8); - - LuaTest.Run(L, "local ok, err = pcall(greet, 42)\nreturn greet('world'), ok, err, select(2, pcall(greet))"u8, - 4); - - Assert.Equal("hello, world", LuaTest.ReadString(L, 1)); - Assert.False(L.ToBoolean(2)); - Assert.EndsWith("bad argument #1 (string expected, got number)", LuaTest.ReadString(L, 3), - StringComparison.Ordinal); - Assert.EndsWith("bad argument #1 (string expected, got no value)", LuaTest.ReadString(L, 4), - StringComparison.Ordinal); - } - - [Fact] - public void FailBadArgument_allocates_nothing_on_the_managed_side() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Greet, "greet"u8); - LuaTest.Run(L, "return function() local ok = pcall(greet, 42) return ok end"u8, 1); - var failures = 0; - - AllocationGate.AssertZero( - () => - { - L.PushValue(1); - if (!L.TryCall(0, 1).IsOk || L.ToBoolean(-1)) - throw new InvalidOperationException("greet(42) should fail inside pcall"); - - failures++; - L.Pop(1); - }, - 200); - - Assert.True(failures > 0); - } - - [Fact] - public void The_error_channel_survives_compacting_garbage_collections() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Register(L, Thunks.Add, "add"u8); - var sentinelBefore = LuaHelpers.SentinelAddress; - - // The registry keys and the sentinel are addresses compared for the life of the load context. Static - // value-type storage may move under a compacting collection; the block the helpers use must not. - for (var round = 0; round < 3; round++) - { - for (var i = 0; i < 10_000; i++) _ = new byte[64]; - - GC.Collect(2, GCCollectionMode.Forced, true, true); - GC.WaitForPendingFinalizers(); - } - - Assert.Equal(sentinelBefore, LuaHelpers.SentinelAddress); - LuaTest.Run(L, "local ok, err = pcall(add, 'x', 2)\nreturn ok, err, add(1, 2)"u8, 3); - Assert.False(L.ToBoolean(1)); - Assert.Contains("add expects two numbers", LuaTest.ReadString(L, 2), StringComparison.Ordinal); - Assert.True(L.TryReadInteger(3, out var sum)); - Assert.Equal(3, sum); - - // Still the same helper set: a second registration does not install a second chunk. - Assert.True(L.TryGetGlobal("add"u8).IsOk); - Assert.True(L.IsFunction(-1)); - } - - [Fact] - public void The_first_protected_operation_of_a_state_works_from_a_thunk_that_used_most_of_its_free_slots() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - // The global is set raw so that this state has no helpers installed when the thunk runs. - L.PushGlobalTable(); - L.PushString("x"u8); - L.PushInteger(1234); - Assert.True(L.TryRawSet(-3)); - L.Pop(1); - - // The thunk is pushed bare (no wrapper, no helper install) and runs the install itself from a deep stack: - // the install asks lua_checkstack for the room lua_pcallk's result contract requires. - // Two results are kept so that a thunk failure (sentinel, message) is readable: unchecked functions have no wrapper. - L.PushUncheckedFunction(Thunks.DeepStackFirstProtected); - var status = L.TryCall(0, 2); - - Assert.True(status.IsOk, status.IsOk ? "" : LuaError.FromStack(L, status).Message); - Assert.True(L.TypeOf(1) == LuaType.Number, L.TryReadString(2, out var failure) ? failure : "no value"); - Assert.True(L.TryReadInteger(1, out var value)); - Assert.Equal(1234, value); - Assert.Equal(2, L.Top); - } - - [Fact] - public void Failure_of_the_thunk_inside_a_coroutine_uses_the_coroutine_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - Counter counter = new(); - Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out var callback).IsOk); - Assert.True(callback!.TryRegister(L, "count"u8).IsOk); - - LuaTest.Run(L, "local co = coroutine.wrap(function() return count() end)\nreturn co()"u8, 1); - - Assert.Equal(1, counter.Value); - Assert.NotEqual(state.Pointer, counter.LastState); - callback.Release(L); - } - - private static void Register(LuaState L, LuaNativeFunction thunk, ReadOnlySpan name) - { - Assert.True(L.TryPushFunction(thunk).IsOk); - Assert.True(L.TrySetGlobal(name).IsOk); - } - - private sealed class DisposeAdmissionFailureRace : IDisposable - { - private readonly CancellationToken _cancellationToken; - - public DisposeAdmissionFailureRace(CancellationToken cancellationToken) - { - _cancellationToken = cancellationToken; - LuaRuntime.OperationAdmissionClosedForTesting = OnAdmissionClosed; - LuaCallback.DisposeAdmissionRefusedForTesting = OnDisposeAdmissionRefused; - LuaCallbackRegistry.AfterReleaseForTesting = ThrowCleanupFailure; - } - - public ManualResetEventSlim AdmissionClosed { get; } = new(initialState: false); - - public ManualResetEventSlim AllowDetachCleanup { get; } = new(initialState: false); - - public ManualResetEventSlim DisposeObservedRefusal { get; } = new(initialState: false); - - public ManualResetEventSlim AllowDisposeToReturn { get; } = new(initialState: false); - - public void Dispose() - { - DisableFailureSeams(); - AllowDetachCleanup.Set(); - AllowDisposeToReturn.Set(); - AdmissionClosed.Dispose(); - AllowDetachCleanup.Dispose(); - DisposeObservedRefusal.Dispose(); - AllowDisposeToReturn.Dispose(); - } - - public static void DisableFailureSeams() - { - LuaRuntime.OperationAdmissionClosedForTesting = null; - LuaCallback.DisposeAdmissionRefusedForTesting = null; - LuaCallbackRegistry.AfterReleaseForTesting = null; - } - - private void OnAdmissionClosed() - { - AdmissionClosed.Set(); - if (!AllowDetachCleanup.Wait(TimeSpan.FromSeconds(5), _cancellationToken)) - throw new TimeoutException("The detach-cleanup barrier timed out."); - } - - private void OnDisposeAdmissionRefused() - { - DisposeObservedRefusal.Set(); - if (!AllowDisposeToReturn.Wait(TimeSpan.FromSeconds(5), _cancellationToken)) - throw new TimeoutException("The callback-disposal barrier timed out."); - } - - private static void ThrowCleanupFailure() - { - throw new InvalidOperationException("deterministic callback cleanup failure"); - } - } + [Fact] + public void A_stateless_function_is_registered_and_called() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + + Assert.True(L.TryPushFunction(Thunks.Add).IsOk); + Assert.True(L.IsFunction(-1)); + Assert.True(L.TrySetGlobal("add"u8).IsOk); + Assert.Equal(0, L.Top); + + LuaTest.Run(L, "return add(40, 2), add(-1, 1)"u8, 2); + Assert.True(L.TryReadInteger(1, out long first)); + Assert.Equal(42, first); + Assert.True(L.TryReadInteger(2, out long second)); + Assert.Equal(0, second); + } + + [Fact] + public void Generated_function_closure_retained_across_state_reset_is_rejected() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + + Assert.True(LuaRuntime.TryPushGeneratedFunction(L, Thunks.Add).IsOk); + Assert.True(L.TrySetGlobal("add"u8).IsOk); + LuaTest.Run(L, "savedAdd = add"u8); + + using (LuaRuntime.BeginStateReset()) + { + } + + LuaTest.Run(L, "local ok, err = pcall(savedAdd, 1, 2) return ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("Lua function registration has expired", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + } + + [Fact] + public void A_failure_reported_by_the_thunk_is_a_catchable_lua_error_with_the_message() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Add, "add"u8); + + LuaTest.Run(L, "local ok, err = pcall(add, 'x', 2)\nreturn ok, err"u8, 2); + + Assert.True(BooleanMarshaller.TryRead(L, 1, out bool ok)); + Assert.False(ok); + string message = LuaTest.ReadString(L, 2); + Assert.Contains("add expects two numbers", message, StringComparison.Ordinal); + } + + [Fact] + public void An_unprotected_call_of_a_failing_thunk_fails_the_enclosing_protected_call() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Add, "add"u8); + + LuaStatus status = L.TryExecute("local v = add(nil, nil)\nreturn v"u8, 1, "=script"u8); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(1, L.Top); + string message = LuaError.FromStack(L, status).Message; + Assert.Contains("add expects two numbers", message, StringComparison.Ordinal); + // error(message, 2) blames the script line that called the function, not the SDK's wrapper. + Assert.StartsWith("script:1:", message, StringComparison.Ordinal); + } + + [Fact] + public void A_managed_exception_inside_a_thunk_never_escapes_and_becomes_a_lua_error() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Throw, "throwing"u8); + + LuaTest.Run(L, "local ok, err = pcall(throwing)\nreturn ok, err"u8, 2); + + Assert.True(BooleanMarshaller.TryRead(L, 1, out bool ok)); + Assert.False(ok); + string message = LuaTest.ReadString(L, 2); + Assert.Contains("System.InvalidOperationException", message, StringComparison.Ordinal); + Assert.Contains("managed boom", message, StringComparison.Ordinal); + Assert.Equal(2, L.Top); + } + + [Fact] + public void An_unchecked_function_hands_the_sentinel_and_message_to_the_caller_as_results() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + L.PushUncheckedFunction(Thunks.Add); + Assert.True(L.TrySetGlobal("rawAdd"u8).IsOk); + + LuaTest.Run(L, "local a, b = rawAdd('x', 1)\nreturn type(a), b, rawAdd(1, 2)"u8, 3); + + Assert.Equal("userdata", LuaTest.ReadString(L, 1)); + Assert.Equal("add expects two numbers", LuaTest.ReadString(L, 2)); + Assert.True(L.TryReadInteger(3, out long sum)); + Assert.Equal(3, sum); + } + + [Fact] + public void Echo_shows_that_a_thunk_sees_exactly_the_caller_arguments_and_returns_exactly_its_results() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Echo, "echo"u8); + + LuaTest.Run(L, "return select('#', echo()), select('#', echo(nil)), echo(1, 'two', nil)"u8, 5); + + Assert.True(L.TryReadInteger(1, out long none)); + Assert.Equal(0, none); + Assert.True(L.TryReadInteger(2, out long oneNil)); + Assert.Equal(1, oneNil); + Assert.True(L.TryReadInteger(3, out long one)); + Assert.Equal(1, one); + Assert.Equal("two", LuaTest.ReadString(L, 4)); + Assert.True(L.IsNil(5)); + } + + [Fact] + public void A_callback_carries_its_state_through_the_upvalue() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + + LuaStatus status = LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback); + + Assert.True(status.IsOk); + Assert.NotNull(callback); + Assert.Equal(0, L.Top); + Assert.Same(counter, callback.State); + Assert.Same(counter, callback.StateObject); + Assert.True(callback.IsCurrent); + Assert.False(callback.IsReleased); + Assert.Equal(1, LuaCallbackRegistry.Count); + + Assert.True(callback.TryRegister(L, "count"u8).IsOk); + LuaTest.Run(L, "return count(), count(), count()"u8, 3); + Assert.Equal(3, counter.Value); + Assert.Equal(state.Pointer, counter.LastState); + Assert.True(L.TryReadInteger(3, out long third)); + Assert.Equal(3, third); + + callback.Release(L); + Assert.Equal(0, LuaCallbackRegistry.Count); + } + + [Fact] + public unsafe void Protected_raw_setters_accept_a_table_closure_upvalue() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + + L.CreateTable(); + lua_pushcclosure(L.Pointer, Thunks.SetUpvalueTable.Pointer, 1); + LuaStatus status = L.TryCall(0, 1); + + Assert.True(status.IsOk, status.IsOk ? "" : LuaError.FromStack(L, status).Message); + L.PushString("name"u8); + Assert.Equal(LuaType.Number, L.RawGet(1)); + Assert.True(L.TryReadInteger(-1, out long named)); + Assert.Equal(1, named); + L.Pop(1); + + Assert.Equal(LuaType.Number, L.RawGetIndex(1, 2)); + Assert.True(L.TryReadInteger(-1, out long indexed)); + Assert.Equal(2, indexed); + L.Pop(1); + + Assert.Equal(LuaType.Number, L.RawGetPointer(1, 0x77)); + Assert.True(L.TryReadInteger(-1, out long pointed)); + Assert.Equal(3, pointed); + L.Pop(1); + } + + [Fact] + public void Two_callbacks_of_the_same_thunk_have_independent_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter a = new(); + Counter b = new() { Value = 100 }; + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, a, out LuaCallback? callbackA).IsOk); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, b, out LuaCallback? callbackB).IsOk); + Assert.True(callbackA!.TryRegister(L, "countA"u8).IsOk); + Assert.True(callbackB!.TryRegister(L, "countB"u8).IsOk); + + LuaTest.Run(L, "countA(); countA(); return countB()"u8, 1); + + Assert.Equal(2, a.Value); + Assert.Equal(101, b.Value); + callbackA.Release(L); + callbackB.Release(L); + } + + [Fact] + public void Releasing_a_callback_neutralizes_the_closure_a_script_kept() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + LuaTest.Run(L, "kept = count"u8); + + callback.Release(L); + callback.Release(L); + + Assert.True(callback.IsReleased); + Assert.False(callback.IsCurrent); + Assert.Null(callback.State); + Assert.False(callback.TryPush(L)); + + // A Try* member reports, it does not throw: a runtime-error status with the message on the stack, like any + // other failed protected operation. + LuaStatus status = callback.TryRegister(L, "again"u8); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(1, L.Top); + Assert.Contains("released", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + // The function a script kept still exists, but its upvalue is gone: it reports instead of touching freed memory. + LuaTest.Run(L, "local ok, err = pcall(kept)\nreturn ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + Assert.Equal(0, counter.Value); + } + + [Fact] + public void Detach_neutralizes_every_callback_the_plugin_forgot() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + LuaCallback? callback; + using (new RuntimeScope(state)) + { + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + LuaTest.Run(L, "count()"u8); + Assert.Equal(1, LuaCallbackRegistry.Count); + } + + Assert.True(callback.IsReleased); + Assert.Equal(0, LuaCallbackRegistry.Count); + Assert.Null(callback.StateObject); + + LuaTest.Run(L, "local ok, err = pcall(count)\nreturn ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + Assert.Equal(1, counter.Value); + } + + [Fact] + public void BeginStateReset_neutralizes_every_callback_before_the_state_is_replaced() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + LuaTest.Run(L, "kept = count"u8); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + + using (LuaRuntime.BeginStateReset()) + { + } + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.True(callback.IsReleased); + Assert.False(callback.IsCurrent); + Assert.Null(callback.StateObject); + Assert.Equal(0, LuaCallbackRegistry.Count); + + // The fixture leaves the old state alive so this verifies preparation order: the old closure was neutralized + // before a future host reset can replace that state. + LuaTest.Run(L, "local ok, err = pcall(kept)\nreturn ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + Assert.Equal(0, counter.Value); + } + + [Fact] + public void Dispose_while_detached_abandons_the_handle_instead_of_freeing_it_behind_the_closure() + { + LuaTest.RequireNativeLua(); + LuaRuntime.Detach(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + + callback.Dispose(); + + Assert.True(callback.IsReleased); + Assert.Equal(0, LuaCallbackRegistry.Count); + // No state could be acquired, so the closure was not neutralized and the state object deliberately stays alive. + LuaTest.Run(L, "return count()"u8, 1); + Assert.Equal(1, counter.Value); + } + + [Fact] + public async Task Detach_closes_admission_and_drains_callback_creation_before_registry_publication() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + using ManualResetEventSlim creationPaused = new(false); + using ManualResetEventSlim allowPublication = new(false); + using ManualResetEventSlim admissionClosed = new(false); + Counter counter = new(); + + LuaCallback.BeforeRegistryAddForTesting = () => + { + creationPaused.Set(); + if (!allowPublication.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The callback-publication barrier timed out."); + } + }; + LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; + + try + { + Task?> creation = Task.Factory.StartNew(() => + { + LuaStatus status = LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback); + Assert.True(status.IsOk); + return callback; + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(creationPaused.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Callback creation did not reach its pre-registry barrier."); + + Task detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Detach did not close Lua operation admission."); + Assert.False(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation rejected)); + rejected.Dispose(); + Assert.False(detach.IsCompleted, + "Detach completed while an admitted callback creation had not published to the registry."); + + allowPublication.Set(); + LuaCallback? callback = await creation.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + await detach.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + Assert.NotNull(callback); + Assert.True(callback.IsReleased); + Assert.Null(callback.StateObject); + Assert.Equal(0, LuaCallbackRegistry.Count); + } + finally + { + LuaCallback.BeforeRegistryAddForTesting = null; + LuaRuntime.OperationAdmissionClosedForTesting = null; + allowPublication.Set(); + } + } + + [Fact] + public async Task Dispose_after_detach_closes_admission_defers_to_callback_neutralization() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + using ManualResetEventSlim admissionClosed = new(false); + using ManualResetEventSlim allowCleanup = new(false); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.NotNull(callback); + Assert.True(callback.TryRegister(L, "count"u8).IsOk); + + LuaRuntime.OperationAdmissionClosedForTesting = () => + { + admissionClosed.Set(); + if (!allowCleanup.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The detach-cleanup barrier timed out."); + } + }; + + try + { + Task detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Detach did not close operation admission."); + + callback.Dispose(); + + // Detach has the only state that may clear the Lua upvalue. Dispose therefore leaves the callback linked + // until that cleanup runs instead of abandoning its handle and losing the closure from the registry. + Assert.False(callback.IsReleased); + Assert.Equal(1, LuaCallbackRegistry.Count); + + allowCleanup.Set(); + await detach.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + Assert.True(callback.IsReleased); + Assert.Null(callback.StateObject); + Assert.Equal(0, LuaCallbackRegistry.Count); + + LuaTest.Run(L, "local ok, err = pcall(count) return ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + Assert.Equal(0, counter.Value); + } + finally + { + LuaRuntime.OperationAdmissionClosedForTesting = null; + allowCleanup.Set(); + } + } + + [Fact] + public async Task Dispose_observing_closed_admission_remains_linked_when_detach_failure_reopens_it() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + using DisposeAdmissionFailureRace race = new(cancellationToken); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, new Counter(), out LuaCallback? first).IsOk); + Assert.True(first!.TryRegister(L, "first"u8).IsOk); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, new Counter(), out LuaCallback? second).IsOk); + Assert.True(second!.TryRegister(L, "second"u8).IsOk); + + Task detach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(race.AdmissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Detach did not close operation admission."); + + Task dispose = Task.Factory.StartNew(first.Dispose, cancellationToken, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(race.DisposeObservedRefusal.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Dispose did not observe the closed admission gate."); + + race.AllowDetachCleanup.Set(); + await Assert.ThrowsAsync(() => detach.WaitAsync(TimeSpan.FromSeconds(5), + cancellationToken)); + Assert.True(second.IsReleased); + Assert.False(first.IsReleased); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(1, LuaCallbackRegistry.Count); + + race.AllowDisposeToReturn.Set(); + await dispose.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + Assert.False(first.IsReleased); + Assert.Equal(1, LuaCallbackRegistry.Count); + + DisposeAdmissionFailureRace.DisableFailureSeams(); + LuaRuntime.Detach(); + + Assert.True(first.IsReleased); + Assert.Equal(0, LuaCallbackRegistry.Count); + LuaTest.Run(L, "local ok, err = pcall(first) return ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + } + + [Fact] + public void Detach_cleanup_failure_keeps_remaining_callbacks_and_allows_a_retry() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Counter firstCounter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, firstCounter, out LuaCallback? first).IsOk); + Assert.True(first!.TryRegister(L, "first"u8).IsOk); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, new Counter(), out LuaCallback? second).IsOk); + Assert.True(second!.TryRegister(L, "second"u8).IsOk); + int releases = 0; + LuaCallbackRegistry.AfterReleaseForTesting = () => + { + if (++releases == 1) + { + throw new InvalidOperationException("deterministic callback cleanup failure"); + } + }; + + try + { + Assert.Throws(LuaRuntime.Detach); + + // The newly created callback was the registry head and was released before the injected error. Its older + // sibling remains linked; the old host binding is still published and normal work is admitted again. + Assert.True(second.IsReleased); + Assert.False(first.IsReleased); + Assert.Equal(1, LuaCallbackRegistry.Count); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); + Assert.True(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)); + operation.Dispose(); + + LuaTest.Run(L, "return first()"u8, 1); + Assert.Equal(1, firstCounter.Value); + L.Pop(1); + + LuaCallbackRegistry.AfterReleaseForTesting = null; + LuaRuntime.Detach(); + + Assert.True(first.IsReleased); + Assert.Equal(0, LuaCallbackRegistry.Count); + LuaTest.Run(L, "local ok, err = pcall(first) return ok, err"u8, 2); + Assert.False(L.ToBoolean(1)); + Assert.Contains("callback released", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + } + finally + { + LuaCallbackRegistry.AfterReleaseForTesting = null; + } + } + + [Fact] + public unsafe void Failed_attach_replacement_reopens_admission_for_the_previous_binding() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state, false); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.NotNull(callback); + LuaHostBinding replacement = HostDouble.CreateBinding(state.L); + LuaCallbackRegistry.AfterReleaseForTesting = static () => + throw new InvalidOperationException("deterministic callback cleanup failure"); + + try + { + Assert.Throws(() => LuaRuntime.Attach(replacement)); + + Assert.True(callback.IsReleased); + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); + Assert.True(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)); + operation.Dispose(); + } + finally + { + LuaCallbackRegistry.AfterReleaseForTesting = null; + } + } + + [Fact] + public void Dispose_with_the_runtime_attached_releases_like_release() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + + callback.Dispose(); + + Assert.Null(callback.StateObject); + LuaTest.Run(L, "return pcall(count)"u8, 2); + Assert.False(L.ToBoolean(1)); + } + + [Fact] + public void A_callback_created_in_an_earlier_epoch_is_stale_but_still_safe() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + LuaCallback? callback; + using (new RuntimeScope(state)) + { + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out callback).IsOk); + } + + // Detach released it already; a fresh epoch makes any surviving reference stale. + using (new RuntimeScope(state)) + { + Assert.False(callback!.IsCurrent); + Assert.False(callback.TryPush(L)); + Assert.Equal(0, L.Top); + } + } + + [Fact] + public void TryCreate_validates_its_arguments() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + + Assert.Throws(() => LuaCallback.TryCreate(L, default, new Counter(), out _)); + Assert.Throws(() => LuaCallback.TryCreate(L, Thunks.Count, null!, out _)); + Assert.Throws(() => L.TryPushFunction(default)); + Assert.Throws(() => L.PushUncheckedFunction(default)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void A_string_argument_reaches_a_string_parameter_and_a_wrong_one_is_named_like_lua_does() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Greet, "greet"u8); + + LuaTest.Run(L, "local ok, err = pcall(greet, 42)\nreturn greet('world'), ok, err, select(2, pcall(greet))"u8, + 4); + + Assert.Equal("hello, world", LuaTest.ReadString(L, 1)); + Assert.False(L.ToBoolean(2)); + Assert.EndsWith("bad argument #1 (string expected, got number)", LuaTest.ReadString(L, 3), + StringComparison.Ordinal); + Assert.EndsWith("bad argument #1 (string expected, got no value)", LuaTest.ReadString(L, 4), + StringComparison.Ordinal); + } + + [Fact] + public void FailBadArgument_allocates_nothing_on_the_managed_side() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Greet, "greet"u8); + LuaTest.Run(L, "return function() local ok = pcall(greet, 42) return ok end"u8, 1); + int failures = 0; + + AllocationGate.AssertZero( + () => + { + L.PushValue(1); + if (!L.TryCall(0, 1).IsOk || L.ToBoolean(-1)) + { + throw new InvalidOperationException("greet(42) should fail inside pcall"); + } + + failures++; + L.Pop(1); + }, + 200); + + Assert.True(failures > 0); + } + + [Fact] + public void The_error_channel_survives_compacting_garbage_collections() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Register(L, Thunks.Add, "add"u8); + IntPtr sentinelBefore = LuaHelpers.SentinelAddress; + + // The registry keys and the sentinel are addresses compared for the life of the load context. Static + // value-type storage may move under a compacting collection; the block the helpers use must not. + for (int round = 0; round < 3; round++) + { + for (int i = 0; i < 10_000; i++) + { + _ = new byte[64]; + } + + GC.Collect(2, GCCollectionMode.Forced, true, true); + GC.WaitForPendingFinalizers(); + } + + Assert.Equal(sentinelBefore, LuaHelpers.SentinelAddress); + LuaTest.Run(L, "local ok, err = pcall(add, 'x', 2)\nreturn ok, err, add(1, 2)"u8, 3); + Assert.False(L.ToBoolean(1)); + Assert.Contains("add expects two numbers", LuaTest.ReadString(L, 2), StringComparison.Ordinal); + Assert.True(L.TryReadInteger(3, out long sum)); + Assert.Equal(3, sum); + + // Still the same helper set: a second registration does not install a second chunk. + Assert.True(L.TryGetGlobal("add"u8).IsOk); + Assert.True(L.IsFunction(-1)); + } + + [Fact] + public void The_first_protected_operation_of_a_state_works_from_a_thunk_that_used_most_of_its_free_slots() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + // The global is set raw so that this state has no helpers installed when the thunk runs. + L.PushGlobalTable(); + L.PushString("x"u8); + L.PushInteger(1234); + Assert.True(L.TryRawSet(-3)); + L.Pop(1); + + // The thunk is pushed bare (no wrapper, no helper install) and runs the install itself from a deep stack: + // the install asks lua_checkstack for the room lua_pcallk's result contract requires. + // Two results are kept so that a thunk failure (sentinel, message) is readable: unchecked functions have no wrapper. + L.PushUncheckedFunction(Thunks.DeepStackFirstProtected); + LuaStatus status = L.TryCall(0, 2); + + Assert.True(status.IsOk, status.IsOk ? "" : LuaError.FromStack(L, status).Message); + Assert.True(L.TypeOf(1) == LuaType.Number, L.TryReadString(2, out string? failure) ? failure : "no value"); + Assert.True(L.TryReadInteger(1, out long value)); + Assert.Equal(1234, value); + Assert.Equal(2, L.Top); + } + + [Fact] + public void Failure_of_the_thunk_inside_a_coroutine_uses_the_coroutine_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + Counter counter = new(); + Assert.True(LuaCallback.TryCreate(L, Thunks.Count, counter, out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(L, "count"u8).IsOk); + + LuaTest.Run(L, "local co = coroutine.wrap(function() return count() end)\nreturn co()"u8, 1); + + Assert.Equal(1, counter.Value); + Assert.NotEqual(state.Pointer, counter.LastState); + callback.Release(L); + } + + private static void Register(LuaState L, LuaNativeFunction thunk, ReadOnlySpan name) + { + Assert.True(L.TryPushFunction(thunk).IsOk); + Assert.True(L.TrySetGlobal(name).IsOk); + } + + private sealed class DisposeAdmissionFailureRace : IDisposable + { + private readonly CancellationToken _cancellationToken; + + public DisposeAdmissionFailureRace(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + LuaRuntime.OperationAdmissionClosedForTesting = OnAdmissionClosed; + LuaCallback.DisposeAdmissionRefusedForTesting = OnDisposeAdmissionRefused; + LuaCallbackRegistry.AfterReleaseForTesting = ThrowCleanupFailure; + } + + public ManualResetEventSlim AdmissionClosed + { + get; + } = new(false); + + public ManualResetEventSlim AllowDetachCleanup + { + get; + } = new(false); + + public ManualResetEventSlim DisposeObservedRefusal + { + get; + } = new(false); + + public ManualResetEventSlim AllowDisposeToReturn + { + get; + } = new(false); + + public void Dispose() + { + DisableFailureSeams(); + AllowDetachCleanup.Set(); + AllowDisposeToReturn.Set(); + AdmissionClosed.Dispose(); + AllowDetachCleanup.Dispose(); + DisposeObservedRefusal.Dispose(); + AllowDisposeToReturn.Dispose(); + } + + public static void DisableFailureSeams() + { + LuaRuntime.OperationAdmissionClosedForTesting = null; + LuaCallback.DisposeAdmissionRefusedForTesting = null; + LuaCallbackRegistry.AfterReleaseForTesting = null; + } + + private void OnAdmissionClosed() + { + AdmissionClosed.Set(); + if (!AllowDetachCleanup.Wait(TimeSpan.FromSeconds(5), _cancellationToken)) + { + throw new TimeoutException("The detach-cleanup barrier timed out."); + } + } + + private void OnDisposeAdmissionRefused() + { + DisposeObservedRefusal.Set(); + if (!AllowDisposeToReturn.Wait(TimeSpan.FromSeconds(5), _cancellationToken)) + { + throw new TimeoutException("The callback-disposal barrier timed out."); + } + } + + private static void ThrowCleanupFailure() + { + throw new InvalidOperationException("deterministic callback cleanup failure"); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs index 1189c4f7..9b40b874 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs @@ -13,412 +13,477 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; [Trait("Category", "NativeLua")] public sealed class LuaHostSubscriptionTests { - [Fact] - public void Registration_failure_leaves_the_callback_inert_and_does_not_unregister() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Action? hostCallback = null; - var calls = 0; - var unregisters = 0; - - var registered = LuaHostSubscription.TryRegister(L, () => calls++, - (registrationState, callback) => - { - hostCallback = callback; - return null; - }, out var subscription); - - Assert.False(registered); - Assert.Null(subscription); - Assert.NotNull(hostCallback); - hostCallback!(); - Assert.Equal(0, calls); - Assert.Equal(0, unregisters); - Assert.Equal(0, LuaHostSubscriptionRegistry.Count); - } - - [Fact] - public void Registrar_exception_leaves_its_captured_callback_inert() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Action? hostCallback = null; - var calls = 0; - - Assert.Throws(() => LuaHostSubscription.TryRegister(L, () => calls++, - (registrationState, callback) => - { - hostCallback = callback; - throw new InvalidOperationException("registration failure"); - }, out _)); - - hostCallback!(); - Assert.Equal(0, calls); - Assert.Equal(0, LuaHostSubscriptionRegistry.Count); - } - - [Fact] - public void Detach_unregisters_lifo_once_and_late_callbacks_are_inert() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - List releases = []; - Action? firstCallback = null; - Action? lastCallback = null; - - Assert.True(LuaHostSubscription.TryRegister(L, static () => { }, - (registrationState, callback) => - { - firstCallback = callback; - return releaseState => releases.Add("timer-a"); - }, out var first)); - Assert.True(LuaHostSubscription.TryRegister(L, static () => { }, - (registrationState, callback) => releaseState => releases.Add("hotkey-b"), out var second)); - Assert.True(LuaHostSubscription.TryRegister(L, static () => { }, - (registrationState, callback) => - { - lastCallback = callback; - return releaseState => releases.Add("timer-c"); - }, out var third)); - - Assert.NotNull(first); - Assert.NotNull(second); - Assert.NotNull(third); - Assert.Equal(3, LuaHostSubscriptionRegistry.Count); - - LuaRuntime.Detach(); - - Assert.Equal(["timer-c", "hotkey-b", "timer-a"], releases); - Assert.True(first.IsDisposed); - Assert.True(second.IsDisposed); - Assert.True(third.IsDisposed); - Assert.Equal(0, LuaHostSubscriptionRegistry.Count); - - firstCallback!(); - lastCallback!(); - first.Dispose(); - second.Dispose(); - third.Dispose(); - Assert.Equal(["timer-c", "hotkey-b", "timer-a"], releases); - } - - [Fact] - public void Reset_and_reenable_leave_the_old_subscription_callback_inert() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Action? hostCallback = null; - var calls = 0; - var unregisters = 0; - - Assert.True(LuaHostSubscription.TryRegister(L, () => calls++, - (registrationState, callback) => - { - hostCallback = callback; - return releaseState => unregisters++; - }, out var subscription)); - Assert.NotNull(subscription); - var oldIdentity = subscription.Identity; - - using (LuaRuntime.BeginStateReset()) - { - } - - Assert.True(subscription.IsDisposed); - Assert.Equal(1, unregisters); - Assert.NotEqual(oldIdentity, LuaRuntime.CurrentStateIdentity); - hostCallback!(); - Assert.Equal(0, calls); - - LuaRuntime.Attach(scope.Binding); - hostCallback(); - Assert.Equal(0, calls); - Assert.Equal(1, unregisters); - } - - [Fact] - public void Reset_reopens_registration_admission_for_new_host_subscriptions() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var oldUnregisters = 0; - var newUnregisters = 0; - - Assert.True(LuaHostSubscription.TryRegister(L, static () => { }, - (registrationState, callback) => releaseState => oldUnregisters++, out var oldSubscription)); - Assert.NotNull(oldSubscription); - - using (LuaRuntime.BeginStateReset()) - { - } - - Assert.True(oldSubscription.IsDisposed); - Assert.Equal(1, oldUnregisters); - - Assert.True(LuaHostSubscription.TryRegister(L, static () => { }, - (registrationState, callback) => releaseState => newUnregisters++, out var newSubscription)); - Assert.NotNull(newSubscription); - Assert.Equal(1, LuaHostSubscriptionRegistry.Count); - - newSubscription.Dispose(); - Assert.True(newSubscription.IsDisposed); - Assert.Equal(1, newUnregisters); - Assert.Equal(0, LuaHostSubscriptionRegistry.Count); - } - - [Fact] - public async Task Disable_admission_waits_for_an_entered_callback_and_rejects_a_late_one() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - using ManualResetEventSlim entered = new(initialState: false); - using ManualResetEventSlim allowReturn = new(initialState: false); - using ManualResetEventSlim admissionClosed = new(initialState: false); - Action? hostCallback = null; - var calls = 0; - var unregisters = 0; - - Assert.True(LuaHostSubscription.TryRegister(L, () => - { - calls++; - entered.Set(); - if (!allowReturn.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The subscription callback barrier timed out."); - }, (registrationState, callback) => - { - hostCallback = callback; - return releaseState => unregisters++; - }, out var subscription)); - Assert.NotNull(subscription); - LuaHostSubscriptionRegistry.CallbackAdmissionClosedForTesting = admissionClosed.Set; - - try - { - var enteredCall = Task.Factory.StartNew(hostCallback!, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The host callback did not enter its handler."); - - var close = Task.Factory.StartNew(LuaRuntime.CloseHostSubscriptionAdmissionAndDrain, cancellationToken, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "Subscription admission did not close."); - Assert.False(close.IsCompleted, "Admission close completed while a callback was still admitted."); - - hostCallback!(); - Assert.Equal(1, calls); - - allowReturn.Set(); - await enteredCall.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - await close.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - LuaRuntime.Detach(); - Assert.Equal(1, unregisters); - Assert.True(subscription.IsDisposed); - } - finally - { - LuaHostSubscriptionRegistry.CallbackAdmissionClosedForTesting = null; - allowReturn.Set(); - } - } - - [Fact] - public async Task Disable_drain_does_not_hold_the_registry_gate_against_an_admitted_callback() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - using var race = new DisableDrainRace(L, cancellationToken); - Assert.True(race.Register()); - Assert.NotNull(race.FirstSubscription); - Assert.NotNull(race.SecondSubscription); - LuaHostSubscription.CallbackDrainStartedForTesting = race.CallbackDrainStarted.Set; - - try - { - var enteredCall = Task.Factory.StartNew(race.HostCallback!, cancellationToken, TaskCreationOptions.LongRunning, - TaskScheduler.Default); - Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The host callback did not enter its handler."); - - var close = Task.Factory.StartNew(LuaRuntime.CloseHostSubscriptionAdmissionAndDrain, cancellationToken, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(race.CallbackDrainStarted.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The subscription drain did not begin."); - - race.AllowCallbackActions.Set(); - Assert.True(race.CallbackActionsCompleted.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The admitted callback was blocked while registering and disposing during the drain."); - await enteredCall.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - await close.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - Assert.Null(race.CallbackFailure); - Assert.Equal(1, race.RejectedRegistrationUnregisters); - Assert.True(race.SecondSubscription.IsDisposed); - Assert.Equal(1, race.SecondUnregisters); - Assert.False(race.FirstSubscription.IsDisposed); - - LuaRuntime.Detach(); - Assert.True(race.FirstSubscription.IsDisposed); - } - finally - { - LuaHostSubscription.CallbackDrainStartedForTesting = null; - race.AllowCallbackActions.Set(); - } - } - - [Fact] - public void Callback_and_unregister_failures_are_contained_and_unregister_is_single_use() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Action? hostCallback = null; - var unregisters = 0; - - Assert.True(LuaHostSubscription.TryRegister(L, - static () => throw new InvalidOperationException("handler failure"), - (registrationState, callback) => - { - hostCallback = callback; - return releaseState => - { - unregisters++; - throw new InvalidOperationException("unregister failure"); - }; - }, out var subscription)); - Assert.NotNull(subscription); - - hostCallback!(); - Assert.IsType(subscription.LastCallbackException); - - subscription.Dispose(); - subscription.Dispose(); - Assert.Equal(1, unregisters); - Assert.IsType(subscription.LastUnregisterException); - Assert.True(subscription.IsDisposed); - Assert.Equal(0, LuaHostSubscriptionRegistry.Count); - } - - [Fact] - public void A_callback_cannot_dispose_its_own_host_registration() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaHostSubscription? subscription = null; - Action? hostCallback = null; - var unregisters = 0; - - Assert.True(LuaHostSubscription.TryRegister(L, () => subscription!.Dispose(), - (registrationState, callback) => - { - hostCallback = callback; - return releaseState => unregisters++; - }, out subscription)); - Assert.NotNull(subscription); - - hostCallback!(); - - Assert.IsType(subscription.LastCallbackException); - Assert.False(subscription.IsDisposed); - Assert.Equal(0, unregisters); - - subscription.Dispose(); - Assert.True(subscription.IsDisposed); - Assert.Equal(1, unregisters); - } - - private sealed class DisableDrainRace(LuaState state, CancellationToken cancellationToken) : IDisposable - { - public ManualResetEventSlim Entered { get; } = new(initialState: false); - - public ManualResetEventSlim AllowCallbackActions { get; } = new(initialState: false); - - public ManualResetEventSlim CallbackDrainStarted { get; } = new(initialState: false); - - public ManualResetEventSlim CallbackActionsCompleted { get; } = new(initialState: false); - - public Action? HostCallback { get; private set; } - - public LuaHostSubscription? FirstSubscription { get; private set; } - - public LuaHostSubscription? SecondSubscription { get; private set; } - - public Exception? CallbackFailure { get; private set; } - - public int RejectedRegistrationUnregisters { get; private set; } - - public int SecondUnregisters { get; private set; } - - public bool Register() - { - if (!LuaHostSubscription.TryRegister(state, RunAdmittedCallback, - (registrationState, callback) => - { - HostCallback = callback; - return static _ => { }; - }, out var firstSubscription)) - return false; - - FirstSubscription = firstSubscription; - return LuaHostSubscription.TryRegister(state, static () => { }, - (registrationState, callback) => releaseState => SecondUnregisters++, out var secondSubscription) - && (SecondSubscription = secondSubscription) is not null; - } - - public void Dispose() - { - Entered.Dispose(); - AllowCallbackActions.Dispose(); - CallbackDrainStarted.Dispose(); - CallbackActionsCompleted.Dispose(); - } - - private void RunAdmittedCallback() - { - Entered.Set(); - try - { - if (!AllowCallbackActions.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The admitted callback action barrier timed out."); - - var registered = LuaHostSubscription.TryRegister(state, static () => { }, - (registrationState, callback) => releaseState => RejectedRegistrationUnregisters++, - out var rejectedSubscription); - if (registered || rejectedSubscription is not null) - throw new InvalidOperationException("Registration unexpectedly succeeded after admission closed."); - - SecondSubscription!.Dispose(); - } - catch (Exception exception) - { - CallbackFailure = exception; - } - finally - { - CallbackActionsCompleted.Set(); - } - } - } + [Fact] + public void Registration_failure_leaves_the_callback_inert_and_does_not_unregister() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Action? hostCallback = null; + int calls = 0; + int unregisters = 0; + + bool registered = LuaHostSubscription.TryRegister(L, () => calls++, + (registrationState, callback) => + { + hostCallback = callback; + return null; + }, out LuaHostSubscription? subscription); + + Assert.False(registered); + Assert.Null(subscription); + Assert.NotNull(hostCallback); + hostCallback!(); + Assert.Equal(0, calls); + Assert.Equal(0, unregisters); + Assert.Equal(0, LuaHostSubscriptionRegistry.Count); + } + + [Fact] + public void Registrar_exception_leaves_its_captured_callback_inert() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Action? hostCallback = null; + int calls = 0; + + Assert.Throws(() => LuaHostSubscription.TryRegister(L, () => calls++, + (registrationState, callback) => + { + hostCallback = callback; + throw new InvalidOperationException("registration failure"); + }, out _)); + + hostCallback!(); + Assert.Equal(0, calls); + Assert.Equal(0, LuaHostSubscriptionRegistry.Count); + } + + [Fact] + public void Detach_unregisters_lifo_once_and_late_callbacks_are_inert() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + List releases = []; + Action? firstCallback = null; + Action? lastCallback = null; + + Assert.True(LuaHostSubscription.TryRegister(L, static () => + { + }, + (registrationState, callback) => + { + firstCallback = callback; + return releaseState => releases.Add("timer-a"); + }, out LuaHostSubscription? first)); + Assert.True(LuaHostSubscription.TryRegister(L, static () => + { + }, + (registrationState, callback) => releaseState => releases.Add("hotkey-b"), + out LuaHostSubscription? second)); + Assert.True(LuaHostSubscription.TryRegister(L, static () => + { + }, + (registrationState, callback) => + { + lastCallback = callback; + return releaseState => releases.Add("timer-c"); + }, out LuaHostSubscription? third)); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.NotNull(third); + Assert.Equal(3, LuaHostSubscriptionRegistry.Count); + + LuaRuntime.Detach(); + + Assert.Equal(["timer-c", "hotkey-b", "timer-a"], releases); + Assert.True(first.IsDisposed); + Assert.True(second.IsDisposed); + Assert.True(third.IsDisposed); + Assert.Equal(0, LuaHostSubscriptionRegistry.Count); + + firstCallback!(); + lastCallback!(); + first.Dispose(); + second.Dispose(); + third.Dispose(); + Assert.Equal(["timer-c", "hotkey-b", "timer-a"], releases); + } + + [Fact] + public void Reset_and_reenable_leave_the_old_subscription_callback_inert() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Action? hostCallback = null; + int calls = 0; + int unregisters = 0; + + Assert.True(LuaHostSubscription.TryRegister(L, () => calls++, + (registrationState, callback) => + { + hostCallback = callback; + return releaseState => unregisters++; + }, out LuaHostSubscription? subscription)); + Assert.NotNull(subscription); + LuaStateIdentity oldIdentity = subscription.Identity; + + using (LuaRuntime.BeginStateReset()) + { + } + + Assert.True(subscription.IsDisposed); + Assert.Equal(1, unregisters); + Assert.NotEqual(oldIdentity, LuaRuntime.CurrentStateIdentity); + hostCallback!(); + Assert.Equal(0, calls); + + LuaRuntime.Attach(scope.Binding); + hostCallback(); + Assert.Equal(0, calls); + Assert.Equal(1, unregisters); + } + + [Fact] + public void Reset_reopens_registration_admission_for_new_host_subscriptions() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + int oldUnregisters = 0; + int newUnregisters = 0; + + Assert.True(LuaHostSubscription.TryRegister(L, static () => + { + }, + (registrationState, callback) => releaseState => oldUnregisters++, + out LuaHostSubscription? oldSubscription)); + Assert.NotNull(oldSubscription); + + using (LuaRuntime.BeginStateReset()) + { + } + + Assert.True(oldSubscription.IsDisposed); + Assert.Equal(1, oldUnregisters); + + Assert.True(LuaHostSubscription.TryRegister(L, static () => + { + }, + (registrationState, callback) => releaseState => newUnregisters++, + out LuaHostSubscription? newSubscription)); + Assert.NotNull(newSubscription); + Assert.Equal(1, LuaHostSubscriptionRegistry.Count); + + newSubscription.Dispose(); + Assert.True(newSubscription.IsDisposed); + Assert.Equal(1, newUnregisters); + Assert.Equal(0, LuaHostSubscriptionRegistry.Count); + } + + [Fact] + public async Task Disable_admission_waits_for_an_entered_callback_and_rejects_a_late_one() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + using ManualResetEventSlim entered = new(false); + using ManualResetEventSlim allowReturn = new(false); + using ManualResetEventSlim admissionClosed = new(false); + Action? hostCallback = null; + int calls = 0; + int unregisters = 0; + + Assert.True(LuaHostSubscription.TryRegister(L, () => + { + calls++; + entered.Set(); + if (!allowReturn.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The subscription callback barrier timed out."); + } + }, (registrationState, callback) => + { + hostCallback = callback; + return releaseState => unregisters++; + }, out LuaHostSubscription? subscription)); + Assert.NotNull(subscription); + LuaHostSubscriptionRegistry.CallbackAdmissionClosedForTesting = admissionClosed.Set; + + try + { + Task enteredCall = Task.Factory.StartNew(hostCallback!, cancellationToken, TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The host callback did not enter its handler."); + + Task close = Task.Factory.StartNew(LuaRuntime.CloseHostSubscriptionAdmissionAndDrain, cancellationToken, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "Subscription admission did not close."); + Assert.False(close.IsCompleted, "Admission close completed while a callback was still admitted."); + + hostCallback!(); + Assert.Equal(1, calls); + + allowReturn.Set(); + await enteredCall.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + await close.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + LuaRuntime.Detach(); + Assert.Equal(1, unregisters); + Assert.True(subscription.IsDisposed); + } + finally + { + LuaHostSubscriptionRegistry.CallbackAdmissionClosedForTesting = null; + allowReturn.Set(); + } + } + + [Fact] + public async Task Disable_drain_does_not_hold_the_registry_gate_against_an_admitted_callback() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + using DisableDrainRace race = new(L, cancellationToken); + Assert.True(race.Register()); + Assert.NotNull(race.FirstSubscription); + Assert.NotNull(race.SecondSubscription); + LuaHostSubscription.CallbackDrainStartedForTesting = race.CallbackDrainStarted.Set; + + try + { + Task enteredCall = Task.Factory.StartNew(race.HostCallback!, cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + Assert.True(race.Entered.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The host callback did not enter its handler."); + + Task close = Task.Factory.StartNew(LuaRuntime.CloseHostSubscriptionAdmissionAndDrain, cancellationToken, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(race.CallbackDrainStarted.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The subscription drain did not begin."); + + race.AllowCallbackActions.Set(); + Assert.True(race.CallbackActionsCompleted.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The admitted callback was blocked while registering and disposing during the drain."); + await enteredCall.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + await close.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + Assert.Null(race.CallbackFailure); + Assert.Equal(1, race.RejectedRegistrationUnregisters); + Assert.True(race.SecondSubscription.IsDisposed); + Assert.Equal(1, race.SecondUnregisters); + Assert.False(race.FirstSubscription.IsDisposed); + + LuaRuntime.Detach(); + Assert.True(race.FirstSubscription.IsDisposed); + } + finally + { + LuaHostSubscription.CallbackDrainStartedForTesting = null; + race.AllowCallbackActions.Set(); + } + } + + [Fact] + public void Callback_and_unregister_failures_are_contained_and_unregister_is_single_use() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Action? hostCallback = null; + int unregisters = 0; + + Assert.True(LuaHostSubscription.TryRegister(L, + static () => throw new InvalidOperationException("handler failure"), + (registrationState, callback) => + { + hostCallback = callback; + return releaseState => + { + unregisters++; + throw new InvalidOperationException("unregister failure"); + }; + }, out LuaHostSubscription? subscription)); + Assert.NotNull(subscription); + + hostCallback!(); + Assert.IsType(subscription.LastCallbackException); + + subscription.Dispose(); + subscription.Dispose(); + Assert.Equal(1, unregisters); + Assert.IsType(subscription.LastUnregisterException); + Assert.True(subscription.IsDisposed); + Assert.Equal(0, LuaHostSubscriptionRegistry.Count); + } + + [Fact] + public void A_callback_cannot_dispose_its_own_host_registration() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaHostSubscription? subscription = null; + Action? hostCallback = null; + int unregisters = 0; + + Assert.True(LuaHostSubscription.TryRegister(L, () => subscription!.Dispose(), + (registrationState, callback) => + { + hostCallback = callback; + return releaseState => unregisters++; + }, out subscription)); + Assert.NotNull(subscription); + + hostCallback!(); + + Assert.IsType(subscription.LastCallbackException); + Assert.False(subscription.IsDisposed); + Assert.Equal(0, unregisters); + + subscription.Dispose(); + Assert.True(subscription.IsDisposed); + Assert.Equal(1, unregisters); + } + + private sealed class DisableDrainRace(LuaState state, CancellationToken cancellationToken) : IDisposable + { + public ManualResetEventSlim Entered + { + get; + } = new(false); + + public ManualResetEventSlim AllowCallbackActions + { + get; + } = new(false); + + public ManualResetEventSlim CallbackDrainStarted + { + get; + } = new(false); + + public ManualResetEventSlim CallbackActionsCompleted + { + get; + } = new(false); + + public Action? HostCallback + { + get; + private set; + } + + public LuaHostSubscription? FirstSubscription + { + get; + private set; + } + + public LuaHostSubscription? SecondSubscription + { + get; + private set; + } + + public Exception? CallbackFailure + { + get; + private set; + } + + public int RejectedRegistrationUnregisters + { + get; + private set; + } + + public int SecondUnregisters + { + get; + private set; + } + + public void Dispose() + { + Entered.Dispose(); + AllowCallbackActions.Dispose(); + CallbackDrainStarted.Dispose(); + CallbackActionsCompleted.Dispose(); + } + + public bool Register() + { + if (!LuaHostSubscription.TryRegister(state, RunAdmittedCallback, + (registrationState, callback) => + { + HostCallback = callback; + return static _ => + { + }; + }, out LuaHostSubscription? firstSubscription)) + { + return false; + } + + FirstSubscription = firstSubscription; + return LuaHostSubscription.TryRegister(state, static () => + { + }, + (registrationState, callback) => releaseState => SecondUnregisters++, + out LuaHostSubscription? secondSubscription) + && (SecondSubscription = secondSubscription) is not null; + } + + private void RunAdmittedCallback() + { + Entered.Set(); + try + { + if (!AllowCallbackActions.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The admitted callback action barrier timed out."); + } + + bool registered = LuaHostSubscription.TryRegister(state, static () => + { + }, + (registrationState, callback) => releaseState => RejectedRegistrationUnregisters++, + out LuaHostSubscription? rejectedSubscription); + if (registered || rejectedSubscription is not null) + { + throw new InvalidOperationException("Registration unexpectedly succeeded after admission closed."); + } + + SecondSubscription!.Dispose(); + } + catch (Exception exception) + { + CallbackFailure = exception; + } + finally + { + CallbackActionsCompleted.Set(); + } + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaNativeFunctionTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaNativeFunctionTests.cs index 1909f6ab..8793fe51 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaNativeFunctionTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaNativeFunctionTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Interop.Types; @@ -8,45 +9,45 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; /// The function-address value type. No Lua library involved: nothing here calls the function. public sealed unsafe class LuaNativeFunctionTests { - [Fact] - public void Default_is_the_null_function() - { - LuaNativeFunction function = default; - - Assert.True(function.IsNull); - Assert.Equal(0, function.Address); - Assert.Equal("lua_CFunction@0x0", function.ToString()); - } - - [Fact] - public void The_typed_constructor_and_the_address_constructor_agree() - { - delegate* unmanaged[Cdecl] byHandle = &ReturnZeroByHandle; - // A thunk written against lua_State* has no typed constructor (no raw pointer in the public surface): its - // address is passed as an integer. - var byPointer = (nint)(delegate* unmanaged[Cdecl])&ReturnZeroByPointer; - - LuaNativeFunction a = new(byHandle); - LuaNativeFunction b = new((nint)byHandle); - LuaNativeFunction c = new(byPointer); - - Assert.False(a.IsNull); - Assert.Equal(a, b); - Assert.True(a == b); - Assert.True(a != c); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - Assert.Equal(byPointer, c.Address); - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReturnZeroByHandle(nint L) - { - return 0; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReturnZeroByPointer(lua_State* L) - { - return 0; - } + [Fact] + public void Default_is_the_null_function() + { + LuaNativeFunction function = default; + + Assert.True(function.IsNull); + Assert.Equal(0, function.Address); + Assert.Equal("lua_CFunction@0x0", function.ToString()); + } + + [Fact] + public void The_typed_constructor_and_the_address_constructor_agree() + { + delegate* unmanaged[Cdecl] byHandle = &ReturnZeroByHandle; + // A thunk written against lua_State* has no typed constructor (no raw pointer in the public surface): its + // address is passed as an integer. + IntPtr byPointer = (nint) (delegate* unmanaged[Cdecl]) &ReturnZeroByPointer; + + LuaNativeFunction a = new(byHandle); + LuaNativeFunction b = new((nint) byHandle); + LuaNativeFunction c = new(byPointer); + + Assert.False(a.IsNull); + Assert.Equal(a, b); + Assert.True(a == b); + Assert.True(a != c); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.Equal(byPointer, c.Address); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReturnZeroByHandle(nint L) + { + return 0; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReturnZeroByPointer(lua_State* L) + { + return 0; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Thunks.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Thunks.cs index 079eca44..c93db173 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Thunks.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/Thunks.cs @@ -1,8 +1,11 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Tests.Callbacks; @@ -13,172 +16,192 @@ namespace CheatEngine.SDK.Lua.Tests.Callbacks; /// internal static unsafe class Thunks { - /// - /// Slots fills before its first protected operation: most of - /// LUA_MINSTACK. - /// - public const int DeepStackSlots = LuaState.MinimumFreeSlots - 2; - - public static LuaNativeFunction Add => new(&AddThunk); - - public static LuaNativeFunction Count => new(&CountThunk); - - public static LuaNativeFunction Throw => new(&ThrowThunk); - - public static LuaNativeFunction Echo => new(&EchoThunk); - - public static LuaNativeFunction Greet => new(&GreetThunk); - - public static LuaNativeFunction DeepStackFirstProtected => new(&DeepStackFirstProtectedThunk); - - public static LuaNativeFunction SetUpvalueTable => new(&SetUpvalueTableThunk); - - /// add(a, b): the sum, or the error "add expects two numbers". - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int AddThunk(nint handle) - { - LuaState L = new(handle); - try - { - if (!Int64Marshaller.TryRead(L, 1, out var a) || !Int64Marshaller.TryRead(L, 2, out var b)) - return LuaThunk.Fail(L, "add expects two numbers"u8); - - Int64Marshaller.Push(L, a + b); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(L, exception); - } - } - - /// count(): increments the carried as state and returns the new value. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int CountThunk(nint handle) - { - LuaState L = new(handle); - try - { - if (!LuaThunk.TryGetState(L, out Counter? counter)) - return LuaThunk.Fail(L, "count: no state (callback released)"u8); - - counter.Value++; - counter.LastState = handle; - Int32Marshaller.Push(L, counter.Value); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(L, exception); - } - } - - /// throwing(): throws a managed exception, which the catch-all converts. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ThrowThunk(nint handle) - { - LuaState L = new(handle); - try - { - return ThrowInvalidOperation(); - } - catch (Exception exception) - { - return LuaThunk.Fail(L, exception); - } - } - - /// echo(...): returns its arguments unchanged. - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int EchoThunk(nint handle) - { - LuaState L = new(handle); - return L.Top; - } - - /// - /// greet(name): "hello, " + name. The string argument goes straight into a parameter - /// (no null-forgiving operator: is annotated), and a wrong argument is - /// reported with Lua's own wording through . - /// - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int GreetThunk(nint handle) - { - LuaState L = new(handle); - try - { - if (!StringMarshaller.TryRead(L, 1, out var name)) return LuaThunk.FailBadArgument(L, 1, "string"u8); - - StringMarshaller.Push(L, Greeting(name)); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(L, exception); - } - } - - /// - /// deep(): fills most of the slots Lua guarantees a C function, then runs the first protected operation of - /// the state (the helper install) from that depth, and returns the global x read that way. Exercises the - /// stack budget of the install on a state that has no helpers yet. - /// - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int DeepStackFirstProtectedThunk(nint handle) - { - LuaState L = new(handle); - try - { - for (var i = 0; i < DeepStackSlots; i++) L.PushInteger(i); - - var status = L.TryGetGlobal("x"u8); - if (!status.IsOk) return LuaThunk.Fail(L, "TryGetGlobal failed at depth"u8); - - // The pushed integers must be intact under the result. - for (var i = 0; i < DeepStackSlots; i++) - if (!L.TryReadInteger(i + 1, out var value) || value != i) - return LuaThunk.Fail(L, "the stack below the protected call was disturbed"u8); - - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(L, exception); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int SetUpvalueTableThunk(nint handle) - { - LuaState L = new(handle); - try - { - var table = lua_upvalueindex(1); - L.PushString("name"u8); - L.PushInteger(1); - if (!L.TryRawSet(table)) return LuaThunk.Fail(L, "raw set failed"u8); - - L.PushInteger(2); - L.RawSetIndex(table, 2); - - L.PushInteger(3); - L.RawSetPointer(table, 0x77); - L.PushValue(table); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(L, exception); - } - } - - private static string Greeting(string name) - { - return "hello, " + name; - } - - private static int ThrowInvalidOperation() - { - throw new InvalidOperationException("managed boom"); - } + /// + /// Slots fills before its first protected operation: most of + /// LUA_MINSTACK. + /// + public const int DeepStackSlots = LuaState.MinimumFreeSlots - 2; + + public static LuaNativeFunction Add => new(&AddThunk); + + public static LuaNativeFunction Count => new(&CountThunk); + + public static LuaNativeFunction Throw => new(&ThrowThunk); + + public static LuaNativeFunction Echo => new(&EchoThunk); + + public static LuaNativeFunction Greet => new(&GreetThunk); + + public static LuaNativeFunction DeepStackFirstProtected => new(&DeepStackFirstProtectedThunk); + + public static LuaNativeFunction SetUpvalueTable => new(&SetUpvalueTableThunk); + + /// add(a, b): the sum, or the error "add expects two numbers". + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int AddThunk(nint handle) + { + LuaState L = new(handle); + try + { + if (!Int64Marshaller.TryRead(L, 1, out long a) || !Int64Marshaller.TryRead(L, 2, out long b)) + { + return LuaThunk.Fail(L, "add expects two numbers"u8); + } + + Int64Marshaller.Push(L, a + b); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(L, exception); + } + } + + /// count(): increments the carried as state and returns the new value. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int CountThunk(nint handle) + { + LuaState L = new(handle); + try + { + if (!LuaThunk.TryGetState(L, out Counter? counter)) + { + return LuaThunk.Fail(L, "count: no state (callback released)"u8); + } + + counter.Value++; + counter.LastState = handle; + Int32Marshaller.Push(L, counter.Value); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(L, exception); + } + } + + /// throwing(): throws a managed exception, which the catch-all converts. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ThrowThunk(nint handle) + { + LuaState L = new(handle); + try + { + return ThrowInvalidOperation(); + } + catch (Exception exception) + { + return LuaThunk.Fail(L, exception); + } + } + + /// echo(...): returns its arguments unchanged. + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int EchoThunk(nint handle) + { + LuaState L = new(handle); + return L.Top; + } + + /// + /// greet(name): "hello, " + name. The string argument goes straight into a parameter + /// (no null-forgiving operator: is annotated), and a wrong argument is + /// reported with Lua's own wording through . + /// + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int GreetThunk(nint handle) + { + LuaState L = new(handle); + try + { + if (!StringMarshaller.TryRead(L, 1, out string? name)) + { + return LuaThunk.FailBadArgument(L, 1, "string"u8); + } + + StringMarshaller.Push(L, Greeting(name)); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(L, exception); + } + } + + /// + /// deep(): fills most of the slots Lua guarantees a C function, then runs the first protected operation of + /// the state (the helper install) from that depth, and returns the global x read that way. Exercises the + /// stack budget of the install on a state that has no helpers yet. + /// + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int DeepStackFirstProtectedThunk(nint handle) + { + LuaState L = new(handle); + try + { + for (int i = 0; i < DeepStackSlots; i++) + { + L.PushInteger(i); + } + + LuaStatus status = L.TryGetGlobal("x"u8); + if (!status.IsOk) + { + return LuaThunk.Fail(L, "TryGetGlobal failed at depth"u8); + } + + // The pushed integers must be intact under the result. + for (int i = 0; i < DeepStackSlots; i++) + { + if (!L.TryReadInteger(i + 1, out long value) || value != i) + { + return LuaThunk.Fail(L, "the stack below the protected call was disturbed"u8); + } + } + + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(L, exception); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int SetUpvalueTableThunk(nint handle) + { + LuaState L = new(handle); + try + { + int table = lua_upvalueindex(1); + L.PushString("name"u8); + L.PushInteger(1); + if (!L.TryRawSet(table)) + { + return LuaThunk.Fail(L, "raw set failed"u8); + } + + L.PushInteger(2); + L.RawSetIndex(table, 2); + + L.PushInteger(3); + L.RawSetPointer(table, 0x77); + L.PushValue(table); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(L, exception); + } + } + + private static string Greeting(string name) + { + return "hello, " + name; + } + + private static int ThrowInvalidOperation() + { + throw new InvalidOperationException("managed boom"); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaStatusTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaStatusTests.cs index 33d014e2..814967cf 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaStatusTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaStatusTests.cs @@ -6,82 +6,82 @@ namespace CheatEngine.SDK.Lua.Tests.Calls; /// The status value type against the C API constants. No Lua library involved. public sealed class LuaStatusTests { - [Fact] - public void Named_statuses_carry_the_c_api_codes() - { - Assert.Equal(LuaApi.LUA_OK, LuaStatus.Ok.Code); - Assert.Equal(LuaApi.LUA_YIELD, LuaStatus.Yield.Code); - Assert.Equal(LuaApi.LUA_ERRRUN, LuaStatus.RuntimeError.Code); - Assert.Equal(LuaApi.LUA_ERRSYNTAX, LuaStatus.SyntaxError.Code); - Assert.Equal(LuaApi.LUA_ERRMEM, LuaStatus.MemoryError.Code); - Assert.Equal(LuaApi.LUA_ERRGCMM, LuaStatus.GcMetamethodError.Code); - Assert.Equal(LuaApi.LUA_ERRERR, LuaStatus.MessageHandlerError.Code); - Assert.Equal(LuaApi.LUA_ERRFILE, LuaStatus.FileError.Code); - } + [Fact] + public void Named_statuses_carry_the_c_api_codes() + { + Assert.Equal(LuaApi.LUA_OK, LuaStatus.Ok.Code); + Assert.Equal(LuaApi.LUA_YIELD, LuaStatus.Yield.Code); + Assert.Equal(LuaApi.LUA_ERRRUN, LuaStatus.RuntimeError.Code); + Assert.Equal(LuaApi.LUA_ERRSYNTAX, LuaStatus.SyntaxError.Code); + Assert.Equal(LuaApi.LUA_ERRMEM, LuaStatus.MemoryError.Code); + Assert.Equal(LuaApi.LUA_ERRGCMM, LuaStatus.GcMetamethodError.Code); + Assert.Equal(LuaApi.LUA_ERRERR, LuaStatus.MessageHandlerError.Code); + Assert.Equal(LuaApi.LUA_ERRFILE, LuaStatus.FileError.Code); + } - [Fact] - public void Only_ok_is_ok() - { - Assert.True(LuaStatus.Ok.IsOk); - Assert.True(default(LuaStatus).IsOk); - Assert.False(LuaStatus.RuntimeError.IsOk); - Assert.False(new LuaStatus(99).IsOk); - } + [Fact] + public void Only_ok_is_ok() + { + Assert.True(LuaStatus.Ok.IsOk); + Assert.True(default(LuaStatus).IsOk); + Assert.False(LuaStatus.RuntimeError.IsOk); + Assert.False(new LuaStatus(99).IsOk); + } - [Fact] - public void Equality_is_by_code() - { - Assert.Equal(LuaStatus.RuntimeError, new LuaStatus(2)); - Assert.True(LuaStatus.RuntimeError == new LuaStatus(2)); - Assert.True(LuaStatus.RuntimeError != LuaStatus.SyntaxError); - Assert.Equal(LuaStatus.RuntimeError.GetHashCode(), new LuaStatus(2).GetHashCode()); - } + [Fact] + public void Equality_is_by_code() + { + Assert.Equal(LuaStatus.RuntimeError, new LuaStatus(2)); + Assert.True(LuaStatus.RuntimeError == new LuaStatus(2)); + Assert.True(LuaStatus.RuntimeError != LuaStatus.SyntaxError); + Assert.Equal(LuaStatus.RuntimeError.GetHashCode(), new LuaStatus(2).GetHashCode()); + } - [Fact] - public void ToString_names_known_codes_and_prints_unknown_ones() - { - Assert.Equal("LUA_OK", LuaStatus.Ok.ToString()); - Assert.Equal("LUA_ERRRUN", LuaStatus.RuntimeError.ToString()); - Assert.Equal("LUA_ERRGCMM", LuaStatus.GcMetamethodError.ToString()); - Assert.Equal("42", new LuaStatus(42).ToString()); - } + [Fact] + public void ToString_names_known_codes_and_prints_unknown_ones() + { + Assert.Equal("LUA_OK", LuaStatus.Ok.ToString()); + Assert.Equal("LUA_ERRRUN", LuaStatus.RuntimeError.ToString()); + Assert.Equal("LUA_ERRGCMM", LuaStatus.GcMetamethodError.ToString()); + Assert.Equal("42", new LuaStatus(42).ToString()); + } - [Fact] - public void ThrowIfFailed_does_nothing_for_ok_even_without_a_state() - { - Assert.Null(Record.Exception(() => LuaStatus.Ok.ThrowIfFailed(default))); - } + [Fact] + public void ThrowIfFailed_does_nothing_for_ok_even_without_a_state() + { + Assert.Null(Record.Exception(() => LuaStatus.Ok.ThrowIfFailed(default))); + } - [Fact] - public void ThrowIfFailed_throws_a_lua_exception_for_a_failure_and_describes_a_missing_error_value() - { - var exception = Assert.Throws(() => LuaStatus.RuntimeError.ThrowIfFailed(default)); + [Fact] + public void ThrowIfFailed_throws_a_lua_exception_for_a_failure_and_describes_a_missing_error_value() + { + LuaException exception = Assert.Throws(() => LuaStatus.RuntimeError.ThrowIfFailed(default)); - Assert.Equal(LuaStatus.RuntimeError, exception.Status); - Assert.Contains("no error value", exception.Message, StringComparison.Ordinal); - } + Assert.Equal(LuaStatus.RuntimeError, exception.Status); + Assert.Contains("no error value", exception.Message, StringComparison.Ordinal); + } - [Fact] - public void LuaError_equality_and_text() - { - LuaError first = new(LuaStatus.RuntimeError, "boom"); - LuaError second = new(LuaStatus.RuntimeError, "boom"); + [Fact] + public void LuaError_equality_and_text() + { + LuaError first = new(LuaStatus.RuntimeError, "boom"); + LuaError second = new(LuaStatus.RuntimeError, "boom"); - Assert.Equal(first, second); - Assert.True(first == second); - Assert.True(first != new LuaError(LuaStatus.SyntaxError, "boom")); - Assert.Equal("LUA_ERRRUN: boom", first.ToString()); - Assert.Equal(string.Empty, new LuaError(LuaStatus.Ok, null!).Message); - } + Assert.Equal(first, second); + Assert.True(first == second); + Assert.True(first != new LuaError(LuaStatus.SyntaxError, "boom")); + Assert.Equal("LUA_ERRRUN: boom", first.ToString()); + Assert.Equal(string.Empty, new LuaError(LuaStatus.Ok, null!).Message); + } - [Fact] - public void LuaException_from_error_carries_status_and_message() - { - var exception = Assert.Throws(() => - LuaException.Throw(new LuaError(LuaStatus.SyntaxError, "unexpected symbol"))); + [Fact] + public void LuaException_from_error_carries_status_and_message() + { + LuaException exception = Assert.Throws(() => + LuaException.Throw(new LuaError(LuaStatus.SyntaxError, "unexpected symbol"))); - Assert.Equal(LuaStatus.SyntaxError, exception.Status); - Assert.Equal("unexpected symbol", exception.Message); - Assert.True(new LuaException("plain").Status.IsOk); - } + Assert.Equal(LuaStatus.SyntaxError, exception.Status); + Assert.Equal("unexpected symbol", exception.Message); + Assert.True(new LuaException("plain").Status.IsOk); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalFunctionsStateIdentityTests.cs b/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalFunctionsStateIdentityTests.cs index 7c96d5b9..b364b7c8 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalFunctionsStateIdentityTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalFunctionsStateIdentityTests.cs @@ -1,10 +1,12 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -16,126 +18,141 @@ namespace CheatEngine.SDK.Lua.Tests.CompilerServices; [Trait("Category", "NativeLua")] public sealed class LuaGlobalFunctionsStateIdentityTests { - private static GlobalResolutionRace? s_race; - - [Fact] - [SuppressMessage("Meziantou.Analyzer", "MA0051", - Justification = - "The regression test must keep the admission barrier, reset, and cache publication assertions in one ordered scenario.")] - public async Task Resolve_holds_an_operation_lease_until_rebind_so_a_state_reset_cannot_publish_an_old_slot() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - using GlobalResolutionRace race = new(cancellationToken); - LuaRef cache = new(); - var before = LuaRuntime.CurrentStateIdentity; - Volatile.Write(ref s_race, race); - LuaRuntime.OperationAdmissionClosedForTesting = race.AdmissionClosed.Set; - - L.PushUncheckedFunction(CreatePauseResolution()); - Assert.True(L.TrySetGlobal("pauseResolution"u8).IsOk); - LuaTest.Run(L, """ - setmetatable(_G, { - __index = function(_, name) - if name == 'cachedAfterReset' then - pauseResolution() - return function() return 42 end - end - end - }) - """u8); - - try - { - var resolver = Task.Factory.StartNew( - () => LuaGlobalFunctions.TryPush(L, cache, "cachedAfterReset"u8), cancellationToken, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(race.ResolverPaused.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The global resolution did not reach its Lua barrier."); - - var reset = Task.Factory.StartNew(BeginAndCompleteStateReset, cancellationToken, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(race.AdmissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The state reset did not close operation admission."); - Assert.False(LuaRuntime.TryAcquireOperation(out var rejected)); - rejected.Dispose(); - Assert.False(reset.IsCompleted, - "The state reset completed while the admitted global resolution was still paused."); - - race.AllowResolverToComplete.Set(); - Assert.True(await resolver.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)); - await reset.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - var after = LuaRuntime.CurrentStateIdentity; - Assert.Equal(before.AttachEpoch, after.AttachEpoch); - Assert.Equal(before.StateGeneration + 1, after.StateGeneration); - Assert.True(cache.IsResolved); - Assert.Equal(before, cache.Identity); - Assert.False(cache.IsCurrent); - - using (LuaRuntime.EnterStateOperation(L)) - { - L.Pop(1); - } - - Assert.Equal(0, L.Top); - } - finally - { - LuaRuntime.OperationAdmissionClosedForTesting = null; - race.AllowResolverToComplete.Set(); - Volatile.Write(ref s_race, null); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int PauseResolution(nint ignored) - { - try - { - var race = Volatile.Read(ref s_race); - if (race is null) return 0; - - race.ResolverPaused.Set(); - race.AllowResolverToComplete.Wait(TimeSpan.FromSeconds(5), race.CancellationToken); - } - catch - { - // A bare lua_CFunction has no managed exception boundary. The test's owning thread reports timeout failures. - } - - return 0; - } - - private static unsafe LuaNativeFunction CreatePauseResolution() - { - return new LuaNativeFunction(&PauseResolution); - } - - private static void BeginAndCompleteStateReset() - { - using var reset = LuaRuntime.BeginStateReset(); - } - - private sealed class GlobalResolutionRace(CancellationToken cancellationToken) : IDisposable - { - public ManualResetEventSlim ResolverPaused { get; } = new(initialState: false); - - public ManualResetEventSlim AllowResolverToComplete { get; } = new(initialState: false); - - public ManualResetEventSlim AdmissionClosed { get; } = new(initialState: false); - - public CancellationToken CancellationToken { get; } = cancellationToken; - - public void Dispose() - { - AllowResolverToComplete.Set(); - ResolverPaused.Dispose(); - AllowResolverToComplete.Dispose(); - AdmissionClosed.Dispose(); - } - } + private static GlobalResolutionRace? s_race; + + [Fact] + [SuppressMessage("Meziantou.Analyzer", "MA0051", + Justification = + "The regression test must keep the admission barrier, reset, and cache publication assertions in one ordered scenario.")] + public async Task Resolve_holds_an_operation_lease_until_rebind_so_a_state_reset_cannot_publish_an_old_slot() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + using GlobalResolutionRace race = new(cancellationToken); + LuaRef cache = new(); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + Volatile.Write(ref s_race, race); + LuaRuntime.OperationAdmissionClosedForTesting = race.AdmissionClosed.Set; + + L.PushUncheckedFunction(CreatePauseResolution()); + Assert.True(L.TrySetGlobal("pauseResolution"u8).IsOk); + LuaTest.Run(L, """ + setmetatable(_G, { + __index = function(_, name) + if name == 'cachedAfterReset' then + pauseResolution() + return function() return 42 end + end + end + }) + """u8); + + try + { + Task resolver = Task.Factory.StartNew( + () => LuaGlobalFunctions.TryPush(L, cache, "cachedAfterReset"u8), cancellationToken, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(race.ResolverPaused.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The global resolution did not reach its Lua barrier."); + + Task reset = Task.Factory.StartNew(BeginAndCompleteStateReset, cancellationToken, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(race.AdmissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The state reset did not close operation admission."); + Assert.False(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation rejected)); + rejected.Dispose(); + Assert.False(reset.IsCompleted, + "The state reset completed while the admitted global resolution was still paused."); + + race.AllowResolverToComplete.Set(); + Assert.True(await resolver.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)); + await reset.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.True(cache.IsResolved); + Assert.Equal(before, cache.Identity); + Assert.False(cache.IsCurrent); + + using (LuaRuntime.EnterStateOperation(L)) + { + L.Pop(1); + } + + Assert.Equal(0, L.Top); + } + finally + { + LuaRuntime.OperationAdmissionClosedForTesting = null; + race.AllowResolverToComplete.Set(); + Volatile.Write(ref s_race, null); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int PauseResolution(nint ignored) + { + try + { + GlobalResolutionRace? race = Volatile.Read(ref s_race); + if (race is null) + { + return 0; + } + + race.ResolverPaused.Set(); + race.AllowResolverToComplete.Wait(TimeSpan.FromSeconds(5), race.CancellationToken); + } + catch + { + // A bare lua_CFunction has no managed exception boundary. The test's owning thread reports timeout failures. + } + + return 0; + } + + private static unsafe LuaNativeFunction CreatePauseResolution() + { + return new LuaNativeFunction(&PauseResolution); + } + + private static void BeginAndCompleteStateReset() + { + using LuaStateResetTransition reset = LuaRuntime.BeginStateReset(); + } + + private sealed class GlobalResolutionRace(CancellationToken cancellationToken) : IDisposable + { + public ManualResetEventSlim ResolverPaused + { + get; + } = new(false); + + public ManualResetEventSlim AllowResolverToComplete + { + get; + } = new(false); + + public ManualResetEventSlim AdmissionClosed + { + get; + } = new(false); + + public CancellationToken CancellationToken + { + get; + } = cancellationToken; + + public void Dispose() + { + AllowResolverToComplete.Set(); + ResolverPaused.Dispose(); + AllowResolverToComplete.Dispose(); + AdmissionClosed.Dispose(); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs b/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs index 96fdc608..194a1a52 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs @@ -4,6 +4,7 @@ using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; namespace CheatEngine.SDK.Lua.Tests.Generated; @@ -15,69 +16,80 @@ namespace CheatEngine.SDK.Lua.Tests.Generated; /// internal static partial class MemoryBindings { - /// Reads a 32-bit integer from the target process through Cheat Engine's readInteger. - /// - /// when the address is unreadable (readInteger returned nil), the global - /// is missing, or the call raised. - /// - [LuaGlobal("readInteger")] - private static partial bool TryReadInt32Raw(nuint address, bool signed, out int value); + /// Reads a 32-bit integer from the target process through Cheat Engine's readInteger. + /// + /// when the address is unreadable (readInteger returned nil), the global + /// is missing, or the call raised. + /// + [LuaGlobal("readInteger")] + private static partial bool TryReadInt32Raw(nuint address, bool signed, out int value); - public static bool TryReadInt32(nuint address, out int value) - { - return TryReadInt32Raw(address, signed: true, out value); - } + public static bool TryReadInt32(nuint address, out int value) + { + return TryReadInt32Raw(address, true, out value); + } - /// The throwing form of the same binding: every failure is a . - [LuaGlobal("readInteger")] - private static partial int ReadInt32Raw(nuint address, bool signed); + /// The throwing form of the same binding: every failure is a . + [LuaGlobal("readInteger")] + private static partial int ReadInt32Raw(nuint address, bool signed); - public static int ReadInt32(nuint address) - { - return ReadInt32Raw(address, signed: true); - } + public static int ReadInt32(nuint address) + { + return ReadInt32Raw(address, true); + } } // ---- what the generator emits ------------------------------------------------------------------------------------- internal static partial class MemoryBindings { - private static readonly LuaRef s_readInteger = new(); + private static readonly LuaRef s_readInteger = new(); - private static partial bool TryReadInt32Raw(nuint address, bool signed, out int value) - { - var L = LuaRuntime.AcquireState(); // one state acquisition per operation - var top = L.Top; // explicit settop: no EH region on the success path - if (!LuaGlobalFunctions.TryPush(L, s_readInteger, - "readInteger"u8)) // rawgeti on the cached ref; resolve + type-check + luaL_ref on first use - return LuaCallSupport.Fail(L, top, out value); // cold, NoInlining: restore top, default the result + private static partial bool TryReadInt32Raw(nuint address, bool signed, out int value) + { + LuaState L = LuaRuntime.AcquireState(); // one state acquisition per operation + int top = L.Top; // explicit settop: no EH region on the success path + if (!LuaGlobalFunctions.TryPush(L, s_readInteger, + "readInteger"u8)) // rawgeti on the cached ref; resolve + type-check + luaL_ref on first use + { + return LuaCallSupport.Fail(L, top, out value); // cold, NoInlining: restore top, default the result + } - AddressMarshaller.Push(L, address); // pushinteger of the address bits - BooleanMarshaller.Push(L, signed); - if (!L.TryCall(2, 1).IsOk) // lua_pcallk(L, 2, 1, 0, 0, null) - return LuaCallSupport.Fail(L, top, out value); // the error value is discarded with the frame + AddressMarshaller.Push(L, address); // pushinteger of the address bits + BooleanMarshaller.Push(L, signed); + if (!L.TryCall(2, 1).IsOk) // lua_pcallk(L, 2, 1, 0, 0, null) + { + return LuaCallSupport.Fail(L, top, out value); // the error value is discarded with the frame + } - var ok = Int32Marshaller.TryRead(L, -1, out value); // tointegerx + range check; nil => false, no exception - L.SetTop(top); - return ok; - } + bool ok = Int32Marshaller.TryRead(L, -1, out value); // tointegerx + range check; nil => false, no exception + L.SetTop(top); + return ok; + } - // The throwing form has the same three exits, each a [DoesNotReturn] cold helper that restores the stack first. - private static partial int ReadInt32Raw(nuint address, bool signed) - { - var L = LuaRuntime.AcquireState(); - var top = L.Top; - if (!LuaGlobalFunctions.TryPush(L, s_readInteger, "readInteger"u8)) - LuaCallSupport.ThrowUnresolvedGlobal(L, top, "readInteger"); // exit 1: no such function + // The throwing form has the same three exits, each a [DoesNotReturn] cold helper that restores the stack first. + private static partial int ReadInt32Raw(nuint address, bool signed) + { + LuaState L = LuaRuntime.AcquireState(); + int top = L.Top; + if (!LuaGlobalFunctions.TryPush(L, s_readInteger, "readInteger"u8)) + { + LuaCallSupport.ThrowUnresolvedGlobal(L, top, "readInteger"); // exit 1: no such function + } - AddressMarshaller.Push(L, address); - BooleanMarshaller.Push(L, signed); - var status = L.TryCall(2, 1); - if (!status.IsOk) LuaCallSupport.Throw(L, top, status); // exit 2: the call raised + AddressMarshaller.Push(L, address); + BooleanMarshaller.Push(L, signed); + LuaStatus status = L.TryCall(2, 1); + if (!status.IsOk) + { + LuaCallSupport.Throw(L, top, status); // exit 2: the call raised + } - if (!Int32Marshaller.TryRead(L, -1, out var value)) - LuaCallSupport.ThrowUnexpectedResult(L, top, -1, "readInteger", "an integer"); // exit 3: nil or wrong type + if (!Int32Marshaller.TryRead(L, -1, out int value)) + { + LuaCallSupport.ThrowUnexpectedResult(L, top, -1, "readInteger", "an integer"); // exit 3: nil or wrong type + } - L.SetTop(top); - return value; - } + L.SetTop(top); + return value; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Generated/ReadIntegerBindingTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Generated/ReadIntegerBindingTests.cs index 538fb284..584e914c 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Generated/ReadIntegerBindingTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Generated/ReadIntegerBindingTests.cs @@ -1,5 +1,6 @@ using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -12,193 +13,198 @@ namespace CheatEngine.SDK.Lua.Tests.Generated; [Trait("Category", "NativeLua")] public sealed class ReadIntegerBindingTests { - private static ReadOnlySpan StandIn => """ - local memory = { [0x1000] = 42, [0x1004] = -7, [0x1008] = 0x100000000, [0x100C] = 2.5 } - function readInteger(address) - if address == 0xDEAD then error('access violation') end - return memory[address] - end - """u8; - - [Fact] - public void Reads_a_value_the_stand_in_knows() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - - Assert.True(MemoryBindings.TryReadInt32(0x1000, out var value)); - - Assert.Equal(42, value); - Assert.True(MemoryBindings.TryReadInt32(0x1004, out var negative)); - Assert.Equal(-7, negative); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Nil_result_is_false_without_an_exception() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - - Assert.False(MemoryBindings.TryReadInt32(0x2000, out var value)); - - Assert.Equal(0, value); - Assert.Equal(0, L.Top); - } - - [Fact] - public void A_value_that_does_not_fit_or_is_not_an_integer_is_false() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - - Assert.False(MemoryBindings.TryReadInt32(0x1008, out _)); - Assert.False(MemoryBindings.TryReadInt32(0x100C, out _)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void A_raising_global_is_false_and_leaves_the_stack_balanced() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - - Assert.False(MemoryBindings.TryReadInt32(0xDEAD, out var value)); - - Assert.Equal(0, value); - Assert.Equal(0, L.Top); - Assert.True(MemoryBindings.TryReadInt32(0x1000, out var after)); - Assert.Equal(42, after); - } - - [Fact] - public void A_missing_global_is_false_and_is_looked_up_again_once_defined() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - - Assert.False(MemoryBindings.TryReadInt32(0x1000, out _)); - Assert.Equal(0, L.Top); - - LuaTest.Run(L, StandIn); - Assert.True(MemoryBindings.TryReadInt32(0x1000, out var value)); - Assert.Equal(42, value); - } - - [Fact] - public void The_cached_reference_is_re_resolved_after_the_host_re_attaches() - { - LuaTest.RequireNativeLua(); - using NativeLuaState first = new(); - using NativeLuaState second = new(); - var L1 = LuaTest.View(first); - var L2 = LuaTest.View(second); - using (new RuntimeScope(first)) - { - LuaTest.Run(L1, StandIn); - Assert.True(MemoryBindings.TryReadInt32(0x1000, out var value)); - Assert.Equal(42, value); - } - - // A different state, a new epoch: the slot cached for the first state must not be used against the second. - using (new RuntimeScope(second)) - { - LuaTest.Run(L2, "function readInteger(address) return address + 1 end"u8); - Assert.True(MemoryBindings.TryReadInt32(0x1000, out var value)); - Assert.Equal(0x1001, value); - Assert.Equal(0, L2.Top); - } - } - - [Fact] - public void The_throwing_form_returns_the_value_and_leaves_the_stack_balanced() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - - Assert.Equal(42, MemoryBindings.ReadInt32(0x1000)); - Assert.Equal(-7, MemoryBindings.ReadInt32(0x1004)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void The_throwing_form_has_one_exception_per_exit_and_names_the_type_of_an_unexpected_result() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - - // Exit 1: the global is not defined yet. - var unresolved = Assert.Throws(() => MemoryBindings.ReadInt32(0x1000)); - Assert.Contains("'readInteger'", unresolved.Message, StringComparison.Ordinal); - Assert.Contains("undefined", unresolved.Message, StringComparison.Ordinal); - Assert.Equal(0, L.Top); - - LuaTest.Run(L, StandIn); - - // Exit 2: the call raised; the Lua message travels with the status. - var raised = Assert.Throws(() => MemoryBindings.ReadInt32(0xDEAD)); - Assert.Equal(LuaStatus.RuntimeError, raised.Status); - Assert.Contains("access violation", raised.Message, StringComparison.Ordinal); - Assert.Equal(0, L.Top); - - // Exit 3: the call succeeded but the result is nil (unknown address) or not an integer (2.5): the message - // names the Lua type actually received. - var nil = Assert.Throws(() => MemoryBindings.ReadInt32(0x2000)); - Assert.Equal("The Lua global 'readInteger' returned a nil value, not an integer.", nil.Message); - var fraction = Assert.Throws(() => MemoryBindings.ReadInt32(0x100C)); - Assert.Equal("The Lua global 'readInteger' returned a number value, not an integer.", fraction.Message); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Throws_while_the_plugin_is_not_enabled() - { - LuaTest.RequireNativeLua(); - LuaRuntime.Detach(); - - Assert.Throws(() => MemoryBindings.TryReadInt32(0x1000, out _)); - } - - [Fact] - public void The_generated_shape_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!MemoryBindings.TryReadInt32(0x1000, out var value) || value != 42) - throw new InvalidOperationException("wrong value"); - - if (MemoryBindings.TryReadInt32(0x2000, out _)) throw new InvalidOperationException("unexpected value"); - - sink += value; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } + private static ReadOnlySpan StandIn => """ + local memory = { [0x1000] = 42, [0x1004] = -7, [0x1008] = 0x100000000, [0x100C] = 2.5 } + function readInteger(address) + if address == 0xDEAD then error('access violation') end + return memory[address] + end + """u8; + + [Fact] + public void Reads_a_value_the_stand_in_knows() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + + Assert.True(MemoryBindings.TryReadInt32(0x1000, out int value)); + + Assert.Equal(42, value); + Assert.True(MemoryBindings.TryReadInt32(0x1004, out int negative)); + Assert.Equal(-7, negative); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Nil_result_is_false_without_an_exception() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + + Assert.False(MemoryBindings.TryReadInt32(0x2000, out int value)); + + Assert.Equal(0, value); + Assert.Equal(0, L.Top); + } + + [Fact] + public void A_value_that_does_not_fit_or_is_not_an_integer_is_false() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + + Assert.False(MemoryBindings.TryReadInt32(0x1008, out _)); + Assert.False(MemoryBindings.TryReadInt32(0x100C, out _)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void A_raising_global_is_false_and_leaves_the_stack_balanced() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + + Assert.False(MemoryBindings.TryReadInt32(0xDEAD, out int value)); + + Assert.Equal(0, value); + Assert.Equal(0, L.Top); + Assert.True(MemoryBindings.TryReadInt32(0x1000, out int after)); + Assert.Equal(42, after); + } + + [Fact] + public void A_missing_global_is_false_and_is_looked_up_again_once_defined() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + + Assert.False(MemoryBindings.TryReadInt32(0x1000, out _)); + Assert.Equal(0, L.Top); + + LuaTest.Run(L, StandIn); + Assert.True(MemoryBindings.TryReadInt32(0x1000, out int value)); + Assert.Equal(42, value); + } + + [Fact] + public void The_cached_reference_is_re_resolved_after_the_host_re_attaches() + { + LuaTest.RequireNativeLua(); + using NativeLuaState first = new(); + using NativeLuaState second = new(); + LuaState L1 = LuaTest.View(first); + LuaState L2 = LuaTest.View(second); + using (new RuntimeScope(first)) + { + LuaTest.Run(L1, StandIn); + Assert.True(MemoryBindings.TryReadInt32(0x1000, out int value)); + Assert.Equal(42, value); + } + + // A different state, a new epoch: the slot cached for the first state must not be used against the second. + using (new RuntimeScope(second)) + { + LuaTest.Run(L2, "function readInteger(address) return address + 1 end"u8); + Assert.True(MemoryBindings.TryReadInt32(0x1000, out int value)); + Assert.Equal(0x1001, value); + Assert.Equal(0, L2.Top); + } + } + + [Fact] + public void The_throwing_form_returns_the_value_and_leaves_the_stack_balanced() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + + Assert.Equal(42, MemoryBindings.ReadInt32(0x1000)); + Assert.Equal(-7, MemoryBindings.ReadInt32(0x1004)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void The_throwing_form_has_one_exception_per_exit_and_names_the_type_of_an_unexpected_result() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + + // Exit 1: the global is not defined yet. + LuaException unresolved = Assert.Throws(() => MemoryBindings.ReadInt32(0x1000)); + Assert.Contains("'readInteger'", unresolved.Message, StringComparison.Ordinal); + Assert.Contains("undefined", unresolved.Message, StringComparison.Ordinal); + Assert.Equal(0, L.Top); + + LuaTest.Run(L, StandIn); + + // Exit 2: the call raised; the Lua message travels with the status. + LuaException raised = Assert.Throws(() => MemoryBindings.ReadInt32(0xDEAD)); + Assert.Equal(LuaStatus.RuntimeError, raised.Status); + Assert.Contains("access violation", raised.Message, StringComparison.Ordinal); + Assert.Equal(0, L.Top); + + // Exit 3: the call succeeded but the result is nil (unknown address) or not an integer (2.5): the message + // names the Lua type actually received. + LuaException nil = Assert.Throws(() => MemoryBindings.ReadInt32(0x2000)); + Assert.Equal("The Lua global 'readInteger' returned a nil value, not an integer.", nil.Message); + LuaException fraction = Assert.Throws(() => MemoryBindings.ReadInt32(0x100C)); + Assert.Equal("The Lua global 'readInteger' returned a number value, not an integer.", fraction.Message); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Throws_while_the_plugin_is_not_enabled() + { + LuaTest.RequireNativeLua(); + LuaRuntime.Detach(); + + Assert.Throws(() => MemoryBindings.TryReadInt32(0x1000, out _)); + } + + [Fact] + public void The_generated_shape_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!MemoryBindings.TryReadInt32(0x1000, out int value) || value != 42) + { + throw new InvalidOperationException("wrong value"); + } + + if (MemoryBindings.TryReadInt32(0x2000, out _)) + { + throw new InvalidOperationException("unexpected value"); + } + + sink += value; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindingTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindingTests.cs index 62fc5170..f6481996 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindingTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindingTests.cs @@ -1,3 +1,4 @@ +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -10,123 +11,125 @@ namespace CheatEngine.SDK.Lua.Tests.Generated; [Trait("Category", "NativeLua")] public sealed class StringBindingTests { - private static ReadOnlySpan StandIn => """ - local memory = { [0x1000] = 'Cheat Engine', [0x2000] = string.rep('A', 4096) } - function readString(address, maxLength) - if address == 0xDEAD then error('access violation') end - local s = memory[address] - if s == nil then return nil end - return s:sub(1, maxLength) - end - function churn() - collectgarbage(); collectgarbage() - local keep = {} - for i = 1, 64 do keep[i] = string.rep('B', 4096) end - return #keep - end - """u8; - - [Fact] - public void Copy_out_shape_reads_the_bytes_and_leaves_the_stack_balanced() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - Span buffer = stackalloc byte[64]; - - Assert.True(StringBindings.TryReadString(0x1000, 64, buffer, out var written)); - - Assert.Equal(12, written); - Assert.True(buffer[..written].SequenceEqual("Cheat Engine"u8)); - Assert.Equal(0, L.Top); - - Assert.True(StringBindings.TryReadString(0x1000, 5, buffer, out written)); - Assert.True(buffer[..written].SequenceEqual("Cheat"u8)); - } - - [Fact] - public void Copy_out_shape_fails_cleanly_for_nil_a_too_small_buffer_a_raise_and_a_missing_global() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Span buffer = stackalloc byte[8]; - - Assert.False(StringBindings.TryReadString(0x1000, 64, buffer, out var written)); // no global yet - Assert.Equal(0, written); - LuaTest.Run(L, StandIn); - Assert.False(StringBindings.TryReadString(0x3000, 64, buffer, out written)); // nil - Assert.Equal(0, written); - Assert.False(StringBindings.TryReadString(0x1000, 64, buffer, out written)); // 12 bytes into 8 - Assert.Equal(0, written); - Assert.False(StringBindings.TryReadString(0xDEAD, 64, buffer, out written)); // raised - Assert.Equal(0, written); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Copied_bytes_survive_collection_and_reuse_of_the_lua_string_memory() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - var copy = new byte[4096]; - - // The result string is 4096 x 'A'; after the wrapper returned it is unreachable from Lua. A span into it - // would now be dangling (the churn below collects garbage and allocates 'B' strings, which can reuse that - // memory); the copy-out shape has already taken the bytes. - Assert.True(StringBindings.TryReadString(0x2000, 4096, copy, out var written)); - Assert.Equal(4096, written); - LuaTest.Run(L, "return churn()"u8, 1); - Assert.True(L.TryReadInteger(-1, out var kept)); - Assert.Equal(64, kept); - L.Pop(1); - - Assert.True(copy.AsSpan().IndexOfAnyExcept((byte)'A') < 0); - Assert.Equal(0, L.Top); - } - - [Fact] - public void String_shape_decodes_the_text_and_is_null_on_failure() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - - Assert.True(StringBindings.TryReadString(0x1000, 64, out var text)); - Assert.Equal("Cheat Engine", text); - Assert.False(StringBindings.TryReadString(0x3000, 64, out var missing)); - Assert.Null(missing); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Copy_out_shape_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIn); - var buffer = new byte[64]; - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!StringBindings.TryReadString(0x1000, 64, buffer, out var written) || written != 12) - throw new InvalidOperationException("wrong value"); - - sink += written; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } + private static ReadOnlySpan StandIn => """ + local memory = { [0x1000] = 'Cheat Engine', [0x2000] = string.rep('A', 4096) } + function readString(address, maxLength) + if address == 0xDEAD then error('access violation') end + local s = memory[address] + if s == nil then return nil end + return s:sub(1, maxLength) + end + function churn() + collectgarbage(); collectgarbage() + local keep = {} + for i = 1, 64 do keep[i] = string.rep('B', 4096) end + return #keep + end + """u8; + + [Fact] + public void Copy_out_shape_reads_the_bytes_and_leaves_the_stack_balanced() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + Span buffer = stackalloc byte[64]; + + Assert.True(StringBindings.TryReadString(0x1000, 64, buffer, out int written)); + + Assert.Equal(12, written); + Assert.True(buffer[..written].SequenceEqual("Cheat Engine"u8)); + Assert.Equal(0, L.Top); + + Assert.True(StringBindings.TryReadString(0x1000, 5, buffer, out written)); + Assert.True(buffer[..written].SequenceEqual("Cheat"u8)); + } + + [Fact] + public void Copy_out_shape_fails_cleanly_for_nil_a_too_small_buffer_a_raise_and_a_missing_global() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Span buffer = stackalloc byte[8]; + + Assert.False(StringBindings.TryReadString(0x1000, 64, buffer, out int written)); // no global yet + Assert.Equal(0, written); + LuaTest.Run(L, StandIn); + Assert.False(StringBindings.TryReadString(0x3000, 64, buffer, out written)); // nil + Assert.Equal(0, written); + Assert.False(StringBindings.TryReadString(0x1000, 64, buffer, out written)); // 12 bytes into 8 + Assert.Equal(0, written); + Assert.False(StringBindings.TryReadString(0xDEAD, 64, buffer, out written)); // raised + Assert.Equal(0, written); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Copied_bytes_survive_collection_and_reuse_of_the_lua_string_memory() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + byte[] copy = new byte[4096]; + + // The result string is 4096 x 'A'; after the wrapper returned it is unreachable from Lua. A span into it + // would now be dangling (the churn below collects garbage and allocates 'B' strings, which can reuse that + // memory); the copy-out shape has already taken the bytes. + Assert.True(StringBindings.TryReadString(0x2000, 4096, copy, out int written)); + Assert.Equal(4096, written); + LuaTest.Run(L, "return churn()"u8, 1); + Assert.True(L.TryReadInteger(-1, out long kept)); + Assert.Equal(64, kept); + L.Pop(1); + + Assert.True(copy.AsSpan().IndexOfAnyExcept((byte) 'A') < 0); + Assert.Equal(0, L.Top); + } + + [Fact] + public void String_shape_decodes_the_text_and_is_null_on_failure() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + + Assert.True(StringBindings.TryReadString(0x1000, 64, out string? text)); + Assert.Equal("Cheat Engine", text); + Assert.False(StringBindings.TryReadString(0x3000, 64, out string? missing)); + Assert.Null(missing); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Copy_out_shape_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIn); + byte[] buffer = new byte[64]; + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!StringBindings.TryReadString(0x1000, 64, buffer, out int written) || written != 12) + { + throw new InvalidOperationException("wrong value"); + } + + sink += written; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindings.cs b/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindings.cs index 75122eb2..97112267 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindings.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Generated/StringBindings.cs @@ -1,9 +1,11 @@ using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; namespace CheatEngine.SDK.Lua.Tests.Generated; @@ -15,55 +17,67 @@ namespace CheatEngine.SDK.Lua.Tests.Generated; /// internal static partial class StringBindings { - /// Reads a string from the target process through Cheat Engine's readString, into the caller's buffer. - /// - /// when the read failed (nil), the text does not fit - /// , the global is missing, or the call raised. - /// - [LuaGlobal("readString")] - public static partial bool TryReadString(nuint address, int maxLength, Span destination, out int written); + /// Reads a string from the target process through Cheat Engine's readString, into the caller's buffer. + /// + /// when the read failed (nil), the text does not fit + /// , the global is missing, or the call raised. + /// + [LuaGlobal("readString")] + public static partial bool TryReadString(nuint address, int maxLength, Span destination, out int written); - /// The allocating convenience: the same call, decoded into a . - [LuaGlobal("readString")] - public static partial bool TryReadString(nuint address, int maxLength, [MaybeNullWhen(false)] out string value); + /// The allocating convenience: the same call, decoded into a . + [LuaGlobal("readString")] + public static partial bool TryReadString(nuint address, int maxLength, [MaybeNullWhen(false)] out string value); } // ---- what the generator emits ------------------------------------------------------------------------------------- internal static partial class StringBindings { - private static readonly LuaRef s_readString = new(); + private static readonly LuaRef s_readString = new(); - public static partial bool TryReadString(nuint address, int maxLength, Span destination, out int written) - { - var L = LuaRuntime.AcquireState(); - var top = L.Top; - if (!LuaGlobalFunctions.TryPush(L, s_readString, "readString"u8)) - return LuaCallSupport.Fail(L, top, out written); + public static partial bool TryReadString(nuint address, int maxLength, Span destination, out int written) + { + LuaState L = LuaRuntime.AcquireState(); + int top = L.Top; + if (!LuaGlobalFunctions.TryPush(L, s_readString, "readString"u8)) + { + return LuaCallSupport.Fail(L, top, out written); + } - AddressMarshaller.Push(L, address); - Int32Marshaller.Push(L, maxLength); - if (!L.TryCall(2, 1).IsOk) return LuaCallSupport.Fail(L, top, out written); + AddressMarshaller.Push(L, address); + Int32Marshaller.Push(L, maxLength); + if (!L.TryCall(2, 1).IsOk) + { + return LuaCallSupport.Fail(L, top, out written); + } - var ok = L.TryCopyUtf8(-1, destination, out written); // the copy happens while the string is still on the stack - L.SetTop(top); - return ok; - } + bool ok = L.TryCopyUtf8(-1, destination, + out written); // the copy happens while the string is still on the stack + L.SetTop(top); + return ok; + } - // The [MaybeNullWhen(false)] of the declaring part applies here too (attributes of partial parts are merged, so - // the implementing part must not repeat it: CS0579). - public static partial bool TryReadString(nuint address, int maxLength, out string value) - { - var L = LuaRuntime.AcquireState(); - var top = L.Top; - if (!LuaGlobalFunctions.TryPush(L, s_readString, "readString"u8)) return LuaCallSupport.Fail(L, top, out value); + // The [MaybeNullWhen(false)] of the declaring part applies here too (attributes of partial parts are merged, so + // the implementing part must not repeat it: CS0579). + public static partial bool TryReadString(nuint address, int maxLength, out string value) + { + LuaState L = LuaRuntime.AcquireState(); + int top = L.Top; + if (!LuaGlobalFunctions.TryPush(L, s_readString, "readString"u8)) + { + return LuaCallSupport.Fail(L, top, out value); + } - AddressMarshaller.Push(L, address); - Int32Marshaller.Push(L, maxLength); - if (!L.TryCall(2, 1).IsOk) return LuaCallSupport.Fail(L, top, out value); + AddressMarshaller.Push(L, address); + Int32Marshaller.Push(L, maxLength); + if (!L.TryCall(2, 1).IsOk) + { + return LuaCallSupport.Fail(L, top, out value); + } - var ok = StringMarshaller.TryRead(L, -1, - out value); // decodes into a new string: the one allocation of this shape - L.SetTop(top); - return ok; - } + bool ok = StringMarshaller.TryRead(L, -1, + out value); // decodes into a new string: the one allocation of this shape + L.SetTop(top); + return ok; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs index bce889bc..efefae90 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; @@ -10,314 +11,314 @@ namespace CheatEngine.SDK.Lua.Tests.Marshalling; [Trait("Category", "NativeLua")] public sealed class MarshallerRoundTripTests { - [Theory] - [InlineData(0)] - [InlineData(42)] - [InlineData(-1)] - [InlineData(int.MinValue)] - [InlineData(int.MaxValue)] - public void Int32_round_trips(int value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Int32Marshaller.Push(L, value); - - Assert.True(L.IsInteger(-1)); - Assert.True(Int32Marshaller.TryRead(L, -1, out var read)); - Assert.Equal(value, read); - Assert.Equal(1, L.Top); - } - - [Theory] - [InlineData(int.MaxValue + 1L)] - [InlineData(int.MinValue - 1L)] - [InlineData(long.MaxValue)] - public void Int32_refuses_values_that_do_not_fit_instead_of_truncating(long value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Int64Marshaller.Push(L, value); - - Assert.False(Int32Marshaller.TryRead(L, -1, out var read)); - Assert.Equal(0, read); - Assert.True(Int64Marshaller.TryRead(L, -1, out var wide)); - Assert.Equal(value, wide); - } - - [Theory] - [InlineData(0L)] - [InlineData(long.MinValue)] - [InlineData(long.MaxValue)] - [InlineData(0x7FFF_FFFF_FFFF_FFF0L)] - public void Int64_round_trips(long value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Int64Marshaller.Push(L, value); - - Assert.True(Int64Marshaller.TryRead(L, -1, out var read)); - Assert.Equal(value, read); - } - - [Theory] - [InlineData(0UL)] - [InlineData(0x1000UL)] - [InlineData(0x7FFF_FFFF_FFFF_FFFFUL)] - [InlineData(0x8000_0000_0000_0000UL)] - [InlineData(0xFFFF_FFFF_FFFF_FFF0UL)] - [InlineData(ulong.MaxValue)] - public void Address_round_trips_including_values_above_long_MaxValue(ulong value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - var address = (nuint)value; - - AddressMarshaller.Push(L, address); - - Assert.True(L.IsInteger(-1)); - Assert.True(AddressMarshaller.TryRead(L, -1, out var read)); - Assert.Equal(address, read); - - // The same bits seen from Lua: an address above long.MaxValue is a negative lua_Integer. - Assert.True(L.TryReadInteger(-1, out var bits)); - Assert.Equal(unchecked((long)value), bits); - } - - [Fact] - public void Address_pushed_from_lua_as_a_negative_integer_reads_as_the_high_address() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - LuaTest.Run(L, "return -16"u8, 1); - - Assert.True(AddressMarshaller.TryRead(L, -1, out var read)); - Assert.Equal(unchecked((nuint)0xFFFF_FFFF_FFFF_FFF0UL), read); - } - - [Theory] - [InlineData("00400000")] // Cheat Engine's hexadecimal text: Lua's coercion would read it as 400000 decimal. - [InlineData("16")] - [InlineData("0x10")] - [InlineData("7FF6A0001000")] - public void Address_refuses_strings_even_when_lua_could_convert_them(string text) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - StringMarshaller.Push(L, text); - - Assert.False(AddressMarshaller.TryRead(L, -1, out var read)); - Assert.Equal((nuint)0, read); - Assert.Equal(LuaType.String, L.TypeOf(-1)); - } - - [Fact] - public void Address_accepts_a_float_with_an_integral_value_like_lua_does() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushNumber(4096.0); - L.PushNumber(4096.5); - - Assert.True(AddressMarshaller.TryRead(L, 1, out var integral)); - Assert.Equal((nuint)4096, integral); - Assert.False(AddressMarshaller.TryRead(L, 2, out _)); - } - - [Theory] - [InlineData(0.0)] - [InlineData(2.5)] - [InlineData(-1e300)] - [InlineData(double.MaxValue)] - [InlineData(double.PositiveInfinity)] - public void Double_round_trips(double value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - DoubleMarshaller.Push(L, value); - - Assert.False(L.IsInteger(-1)); - Assert.True(DoubleMarshaller.TryRead(L, -1, out var read)); - Assert.Equal(value, read); - } - - [Fact] - public void Double_nan_round_trips_as_nan() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - DoubleMarshaller.Push(L, double.NaN); - - Assert.True(DoubleMarshaller.TryRead(L, -1, out var read)); - Assert.True(double.IsNaN(read)); - } - - [Theory] - [InlineData(0.0f)] - [InlineData(1.5f)] - [InlineData(-3.25f)] - [InlineData(float.MaxValue)] - public void Single_round_trips(float value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - SingleMarshaller.Push(L, value); - - Assert.True(SingleMarshaller.TryRead(L, -1, out var read)); - Assert.Equal(value, read); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void Boolean_round_trips(bool value) - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - BooleanMarshaller.Push(L, value); - - Assert.Equal(LuaType.Boolean, L.TypeOf(-1)); - Assert.True(BooleanMarshaller.TryRead(L, -1, out var read)); - Assert.Equal(value, read); - } - - [Fact] - public void Boolean_is_strict_nil_and_numbers_are_not_booleans() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushNil(); - L.PushInteger(1); - - Assert.False(BooleanMarshaller.TryRead(L, 1, out var fromNil)); - Assert.False(fromNil); - Assert.False(BooleanMarshaller.TryRead(L, 2, out _)); - Assert.False(BooleanMarshaller.TryRead(L, 3, out _)); - Assert.True(L.ToBoolean(2)); - } - - [Fact] - public void Integer_and_float_are_discriminated() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushInteger(3); - L.PushNumber(3.0); - L.PushNumber(2.5); - L.PushString("42"u8); - L.PushString("2.5"u8); - L.PushBoolean(true); - - // 3: an integer; both marshallers accept it. - Assert.True(L.IsInteger(1)); - Assert.True(Int64Marshaller.TryRead(L, 1, out var i1)); - Assert.Equal(3, i1); - Assert.True(DoubleMarshaller.TryRead(L, 1, out var d1)); - Assert.Equal(3.0, d1); - - // 3.0: a float with an integral value; Lua's conversion accepts it as an integer, IsInteger tells the difference. - Assert.False(L.IsInteger(2)); - Assert.True(Int64Marshaller.TryRead(L, 2, out var i2)); - Assert.Equal(3, i2); - - // 2.5: a float that has no integer representation. - Assert.False(Int64Marshaller.TryRead(L, 3, out _)); - Assert.False(Int32Marshaller.TryRead(L, 3, out _)); - Assert.True(DoubleMarshaller.TryRead(L, 3, out var d3)); - Assert.Equal(2.5, d3); - - // Strings follow Lua's own coercion; the string marshallers stay strict the other way round (see string tests). - Assert.True(Int64Marshaller.TryRead(L, 4, out var i4)); - Assert.Equal(42, i4); - Assert.False(Int64Marshaller.TryRead(L, 5, out _)); - Assert.True(DoubleMarshaller.TryRead(L, 5, out var d5)); - Assert.Equal(2.5, d5); - - // A boolean is never a number. - Assert.False(Int64Marshaller.TryRead(L, 6, out _)); - Assert.False(DoubleMarshaller.TryRead(L, 6, out _)); - Assert.False(AddressMarshaller.TryRead(L, 6, out _)); - - // Nothing was converted in place. - Assert.Equal(LuaType.String, L.TypeOf(4)); - Assert.Equal(LuaType.Number, L.TypeOf(2)); - Assert.Equal(6, L.Top); - } - - [Fact] - public void Absent_values_read_as_failures_not_as_defaults() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Assert.False(Int32Marshaller.TryRead(L, 1, out _)); - Assert.False(Int64Marshaller.TryRead(L, 1, out _)); - Assert.False(DoubleMarshaller.TryRead(L, 1, out _)); - Assert.False(AddressMarshaller.TryRead(L, 1, out _)); - Assert.False(BooleanMarshaller.TryRead(L, 1, out _)); - Assert.False(Utf8Marshaller.TryRead(L, 1, out var utf8)); - Assert.True(utf8.IsEmpty); - Assert.False(StringMarshaller.TryRead(L, 1, out var text)); - Assert.Null(text); - } - - [Fact] - public void Generic_code_over_a_marshaller_is_the_same_call() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - PushThrough(L, 1234); - PushThrough(L, unchecked((nuint)0xFFFF_FFFF_0000_0001UL)); - // The string marshaller's T is the type a declaration names, 'string': this instantiation must compile - // without a nullability warning, and a failed read is 'maybe null when false'. - PushThrough(L, "text"); - PushThrough, Utf8Marshaller>(L, "bytes"u8); - - Assert.True(ReadThrough(L, 1, out var i)); - Assert.Equal(1234, i); - Assert.True(ReadThrough(L, 2, out var a)); - Assert.Equal(unchecked((nuint)0xFFFF_FFFF_0000_0001UL), a); - Assert.True(ReadThrough(L, 3, out var s)); - Assert.Equal("text", s); - Assert.False(ReadThrough(L, 1, out var notAString)); - Assert.Null(notAString); - Assert.True(ReadThrough, Utf8Marshaller>(L, 4, out var bytes)); - Assert.True(bytes.SequenceEqual("bytes"u8)); - } - - private static void PushThrough(LuaState L, T value) - where T : allows ref struct - where TMarshaller : ILuaMarshaller - { - TMarshaller.Push(L, value); - } - - private static bool ReadThrough(LuaState L, int index, [MaybeNullWhen(false)] out T value) - where T : allows ref struct - where TMarshaller : ILuaMarshaller - { - return TMarshaller.TryRead(L, index, out value); - } + [Theory] + [InlineData(0)] + [InlineData(42)] + [InlineData(-1)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public void Int32_round_trips(int value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Int32Marshaller.Push(L, value); + + Assert.True(L.IsInteger(-1)); + Assert.True(Int32Marshaller.TryRead(L, -1, out int read)); + Assert.Equal(value, read); + Assert.Equal(1, L.Top); + } + + [Theory] + [InlineData(int.MaxValue + 1L)] + [InlineData(int.MinValue - 1L)] + [InlineData(long.MaxValue)] + public void Int32_refuses_values_that_do_not_fit_instead_of_truncating(long value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Int64Marshaller.Push(L, value); + + Assert.False(Int32Marshaller.TryRead(L, -1, out int read)); + Assert.Equal(0, read); + Assert.True(Int64Marshaller.TryRead(L, -1, out long wide)); + Assert.Equal(value, wide); + } + + [Theory] + [InlineData(0L)] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + [InlineData(0x7FFF_FFFF_FFFF_FFF0L)] + public void Int64_round_trips(long value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Int64Marshaller.Push(L, value); + + Assert.True(Int64Marshaller.TryRead(L, -1, out long read)); + Assert.Equal(value, read); + } + + [Theory] + [InlineData(0UL)] + [InlineData(0x1000UL)] + [InlineData(0x7FFF_FFFF_FFFF_FFFFUL)] + [InlineData(0x8000_0000_0000_0000UL)] + [InlineData(0xFFFF_FFFF_FFFF_FFF0UL)] + [InlineData(ulong.MaxValue)] + public void Address_round_trips_including_values_above_long_MaxValue(ulong value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + UIntPtr address = (nuint) value; + + AddressMarshaller.Push(L, address); + + Assert.True(L.IsInteger(-1)); + Assert.True(AddressMarshaller.TryRead(L, -1, out UIntPtr read)); + Assert.Equal(address, read); + + // The same bits seen from Lua: an address above long.MaxValue is a negative lua_Integer. + Assert.True(L.TryReadInteger(-1, out long bits)); + Assert.Equal(unchecked((long) value), bits); + } + + [Fact] + public void Address_pushed_from_lua_as_a_negative_integer_reads_as_the_high_address() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return -16"u8, 1); + + Assert.True(AddressMarshaller.TryRead(L, -1, out UIntPtr read)); + Assert.Equal(unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL), read); + } + + [Theory] + [InlineData("00400000")] // Cheat Engine's hexadecimal text: Lua's coercion would read it as 400000 decimal. + [InlineData("16")] + [InlineData("0x10")] + [InlineData("7FF6A0001000")] + public void Address_refuses_strings_even_when_lua_could_convert_them(string text) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + StringMarshaller.Push(L, text); + + Assert.False(AddressMarshaller.TryRead(L, -1, out UIntPtr read)); + Assert.Equal((nuint) 0, read); + Assert.Equal(LuaType.String, L.TypeOf(-1)); + } + + [Fact] + public void Address_accepts_a_float_with_an_integral_value_like_lua_does() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushNumber(4096.0); + L.PushNumber(4096.5); + + Assert.True(AddressMarshaller.TryRead(L, 1, out UIntPtr integral)); + Assert.Equal((nuint) 4096, integral); + Assert.False(AddressMarshaller.TryRead(L, 2, out _)); + } + + [Theory] + [InlineData(0.0)] + [InlineData(2.5)] + [InlineData(-1e300)] + [InlineData(double.MaxValue)] + [InlineData(double.PositiveInfinity)] + public void Double_round_trips(double value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + DoubleMarshaller.Push(L, value); + + Assert.False(L.IsInteger(-1)); + Assert.True(DoubleMarshaller.TryRead(L, -1, out double read)); + Assert.Equal(value, read); + } + + [Fact] + public void Double_nan_round_trips_as_nan() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + DoubleMarshaller.Push(L, double.NaN); + + Assert.True(DoubleMarshaller.TryRead(L, -1, out double read)); + Assert.True(double.IsNaN(read)); + } + + [Theory] + [InlineData(0.0f)] + [InlineData(1.5f)] + [InlineData(-3.25f)] + [InlineData(float.MaxValue)] + public void Single_round_trips(float value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + SingleMarshaller.Push(L, value); + + Assert.True(SingleMarshaller.TryRead(L, -1, out float read)); + Assert.Equal(value, read); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Boolean_round_trips(bool value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + BooleanMarshaller.Push(L, value); + + Assert.Equal(LuaType.Boolean, L.TypeOf(-1)); + Assert.True(BooleanMarshaller.TryRead(L, -1, out bool read)); + Assert.Equal(value, read); + } + + [Fact] + public void Boolean_is_strict_nil_and_numbers_are_not_booleans() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushNil(); + L.PushInteger(1); + + Assert.False(BooleanMarshaller.TryRead(L, 1, out bool fromNil)); + Assert.False(fromNil); + Assert.False(BooleanMarshaller.TryRead(L, 2, out _)); + Assert.False(BooleanMarshaller.TryRead(L, 3, out _)); + Assert.True(L.ToBoolean(2)); + } + + [Fact] + public void Integer_and_float_are_discriminated() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(3); + L.PushNumber(3.0); + L.PushNumber(2.5); + L.PushString("42"u8); + L.PushString("2.5"u8); + L.PushBoolean(true); + + // 3: an integer; both marshallers accept it. + Assert.True(L.IsInteger(1)); + Assert.True(Int64Marshaller.TryRead(L, 1, out long i1)); + Assert.Equal(3, i1); + Assert.True(DoubleMarshaller.TryRead(L, 1, out double d1)); + Assert.Equal(3.0, d1); + + // 3.0: a float with an integral value; Lua's conversion accepts it as an integer, IsInteger tells the difference. + Assert.False(L.IsInteger(2)); + Assert.True(Int64Marshaller.TryRead(L, 2, out long i2)); + Assert.Equal(3, i2); + + // 2.5: a float that has no integer representation. + Assert.False(Int64Marshaller.TryRead(L, 3, out _)); + Assert.False(Int32Marshaller.TryRead(L, 3, out _)); + Assert.True(DoubleMarshaller.TryRead(L, 3, out double d3)); + Assert.Equal(2.5, d3); + + // Strings follow Lua's own coercion; the string marshallers stay strict the other way round (see string tests). + Assert.True(Int64Marshaller.TryRead(L, 4, out long i4)); + Assert.Equal(42, i4); + Assert.False(Int64Marshaller.TryRead(L, 5, out _)); + Assert.True(DoubleMarshaller.TryRead(L, 5, out double d5)); + Assert.Equal(2.5, d5); + + // A boolean is never a number. + Assert.False(Int64Marshaller.TryRead(L, 6, out _)); + Assert.False(DoubleMarshaller.TryRead(L, 6, out _)); + Assert.False(AddressMarshaller.TryRead(L, 6, out _)); + + // Nothing was converted in place. + Assert.Equal(LuaType.String, L.TypeOf(4)); + Assert.Equal(LuaType.Number, L.TypeOf(2)); + Assert.Equal(6, L.Top); + } + + [Fact] + public void Absent_values_read_as_failures_not_as_defaults() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Assert.False(Int32Marshaller.TryRead(L, 1, out _)); + Assert.False(Int64Marshaller.TryRead(L, 1, out _)); + Assert.False(DoubleMarshaller.TryRead(L, 1, out _)); + Assert.False(AddressMarshaller.TryRead(L, 1, out _)); + Assert.False(BooleanMarshaller.TryRead(L, 1, out _)); + Assert.False(Utf8Marshaller.TryRead(L, 1, out ReadOnlySpan utf8)); + Assert.True(utf8.IsEmpty); + Assert.False(StringMarshaller.TryRead(L, 1, out string? text)); + Assert.Null(text); + } + + [Fact] + public void Generic_code_over_a_marshaller_is_the_same_call() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + PushThrough(L, 1234); + PushThrough(L, unchecked((nuint) 0xFFFF_FFFF_0000_0001UL)); + // The string marshaller's T is the type a declaration names, 'string': this instantiation must compile + // without a nullability warning, and a failed read is 'maybe null when false'. + PushThrough(L, "text"); + PushThrough, Utf8Marshaller>(L, "bytes"u8); + + Assert.True(ReadThrough(L, 1, out int i)); + Assert.Equal(1234, i); + Assert.True(ReadThrough(L, 2, out UIntPtr a)); + Assert.Equal(unchecked((nuint) 0xFFFF_FFFF_0000_0001UL), a); + Assert.True(ReadThrough(L, 3, out string? s)); + Assert.Equal("text", s); + Assert.False(ReadThrough(L, 1, out string? notAString)); + Assert.Null(notAString); + Assert.True(ReadThrough, Utf8Marshaller>(L, 4, out ReadOnlySpan bytes)); + Assert.True(bytes.SequenceEqual("bytes"u8)); + } + + private static void PushThrough(LuaState L, T value) + where T : allows ref struct + where TMarshaller : ILuaMarshaller + { + TMarshaller.Push(L, value); + } + + private static bool ReadThrough(LuaState L, int index, [MaybeNullWhen(false)] out T value) + where T : allows ref struct + where TMarshaller : ILuaMarshaller + { + return TMarshaller.TryRead(L, index, out value); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs index 19c256b4..b23e2120 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs @@ -12,73 +12,75 @@ namespace CheatEngine.SDK.Lua.Tests.Protected; [Trait("Category", "NativeLua")] public sealed class CheckStackFailureProcessTests { - [Fact] - public async Task Direct_checkstack_growth_with_a_rejecting_allocator_returns_zero_and_recovers() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + [Fact] + public async Task Direct_checkstack_growth_with_a_rejecting_allocator_returns_zero_and_recovers() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - var baseDirectory = AppContext.BaseDirectory; - var probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); - var runtimeConfig = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json"); - var depsFile = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json"); - Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); + string baseDirectory = AppContext.BaseDirectory; + string probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); + string runtimeConfig = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json"); + string depsFile = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json"); + Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); - ProcessStartInfo start = new("dotnet") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - start.ArgumentList.Add("exec"); - start.ArgumentList.Add("--runtimeconfig"); - start.ArgumentList.Add(runtimeConfig); - start.ArgumentList.Add("--depsfile"); - start.ArgumentList.Add(depsFile); - start.ArgumentList.Add(probe); - start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); - start.ArgumentList.Add("--checkstack-growth"); + ProcessStartInfo start = new("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(runtimeConfig); + start.ArgumentList.Add("--depsfile"); + start.ArgumentList.Add(depsFile); + start.ArgumentList.Add(probe); + start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); + start.ArgumentList.Add("--checkstack-growth"); - using var process = Process.Start(start); - Assert.NotNull(process); - var cancellationToken = TestContext.Current.CancellationToken; - var output = process.StandardOutput.ReadToEndAsync(cancellationToken); - var error = process.StandardError.ReadToEndAsync(cancellationToken); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(TimeSpan.FromSeconds(30)); - try - { - await process.WaitForExitAsync(timeout.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - process.Kill(entireProcessTree: true); - Assert.Fail("The direct lua_checkstack failure probe did not exit within 30 seconds."); - } + using Process? process = Process.Start(start); + Assert.NotNull(process); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + process.Kill(true); + Assert.Fail("The direct lua_checkstack failure probe did not exit within 30 seconds."); + } - var standardOutput = await output; - var standardError = await error; - Assert.True(process.ExitCode == 0, - string.Create(CultureInfo.InvariantCulture, - $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); - AssertMarkers(standardOutput, - "MARK lua_checkstack-direct-growth-before-reject", - "MARK lua_checkstack-direct-growth-returned-zero", - "MARK lua_checkstack-direct-growth-recovery-reserved", - "MARK lua_checkstack-bridge-fill-before-reject", - "MARK lua_checkstack-bridge-stack-full", - "MARK lua_checkstack-bridge-returned-no-error-status", - "MARK lua_checkstack-bridge-stack-restored", - "MARK lua_pushuncheckedfunction-fill-before-reject", - "MARK lua_pushuncheckedfunction-stack-full", - "MARK lua_pushuncheckedfunction-reservation-rejected", - "MARK lua_pushuncheckedfunction-stack-restored", - "PASS lua_checkstack direct rejected-growth returns 0 and the state recovers"); - } + string standardOutput = await output; + string standardError = await error; + Assert.True(process.ExitCode == 0, + string.Create(CultureInfo.InvariantCulture, + $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); + AssertMarkers(standardOutput, + "MARK lua_checkstack-direct-growth-before-reject", + "MARK lua_checkstack-direct-growth-returned-zero", + "MARK lua_checkstack-direct-growth-recovery-reserved", + "MARK lua_checkstack-bridge-fill-before-reject", + "MARK lua_checkstack-bridge-stack-full", + "MARK lua_checkstack-bridge-returned-no-error-status", + "MARK lua_checkstack-bridge-stack-restored", + "MARK lua_pushuncheckedfunction-fill-before-reject", + "MARK lua_pushuncheckedfunction-stack-full", + "MARK lua_pushuncheckedfunction-reservation-rejected", + "MARK lua_pushuncheckedfunction-stack-restored", + "PASS lua_checkstack direct rejected-growth returns 0 and the state recovers"); + } - private static void AssertMarkers(string standardOutput, params string[] markers) - { - for (var index = 0; index < markers.Length; index++) - Assert.Contains(markers[index], standardOutput, StringComparison.Ordinal); - } + private static void AssertMarkers(string standardOutput, params string[] markers) + { + for (int index = 0; index < markers.Length; index++) + { + Assert.Contains(markers[index], standardOutput, StringComparison.Ordinal); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs index a07d770e..e86af5ab 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs @@ -9,116 +9,117 @@ namespace CheatEngine.SDK.Lua.Tests.Protected; [Trait("Category", "NativeLua")] public sealed class NativeFailureProcessTests { - [Fact] - public async Task Generated_function_PushClosure_failure_returns_status_and_restores_stack() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + [Fact] + public async Task Generated_function_PushClosure_failure_returns_status_and_restores_stack() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - var baseDirectory = AppContext.BaseDirectory; - var probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); - var runtimeConfig = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json"); - var depsFile = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json"); - Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); + string baseDirectory = AppContext.BaseDirectory; + string probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); + string runtimeConfig = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json"); + string depsFile = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json"); + Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); - ProcessStartInfo start = new("dotnet") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - start.ArgumentList.Add("exec"); - start.ArgumentList.Add("--runtimeconfig"); - start.ArgumentList.Add(runtimeConfig); - start.ArgumentList.Add("--depsfile"); - start.ArgumentList.Add(depsFile); - start.ArgumentList.Add(probe); - start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); - start.ArgumentList.Add("--generated-function-allocation"); + ProcessStartInfo start = new("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(runtimeConfig); + start.ArgumentList.Add("--depsfile"); + start.ArgumentList.Add(depsFile); + start.ArgumentList.Add(probe); + start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); + start.ArgumentList.Add("--generated-function-allocation"); - using var process = Process.Start(start); - Assert.NotNull(process); - var cancellationToken = TestContext.Current.CancellationToken; - var output = process.StandardOutput.ReadToEndAsync(cancellationToken); - var error = process.StandardError.ReadToEndAsync(cancellationToken); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(TimeSpan.FromSeconds(30)); - try - { - await process.WaitForExitAsync(timeout.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - process.Kill(true); - Assert.Fail("The generated-function allocation probe did not exit within 30 seconds."); - } + using Process? process = Process.Start(start); + Assert.NotNull(process); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + process.Kill(true); + Assert.Fail("The generated-function allocation probe did not exit within 30 seconds."); + } - var standardOutput = await output; - var standardError = await error; - Assert.True(process.ExitCode == 0, - string.Create(CultureInfo.InvariantCulture, - $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); - Assert.Contains("MARK TryPushGeneratedFunction PushClosure status, stack, and ownership recovered", standardOutput, - StringComparison.Ordinal); - Assert.Contains("PASS generated function closure allocation failure returns status and restores stack", - standardOutput, StringComparison.Ordinal); - } + string standardOutput = await output; + string standardError = await error; + Assert.True(process.ExitCode == 0, + string.Create(CultureInfo.InvariantCulture, + $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); + Assert.Contains("MARK TryPushGeneratedFunction PushClosure status, stack, and ownership recovered", + standardOutput, + StringComparison.Ordinal); + Assert.Contains("PASS generated function closure allocation failure returns status and restores stack", + standardOutput, StringComparison.Ordinal); + } - [Fact] - public async Task Allocation_failures_return_through_the_native_boundary() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + [Fact] + public async Task Allocation_failures_return_through_the_native_boundary() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - var baseDirectory = AppContext.BaseDirectory; - var probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); - var runtimeConfig = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json"); - var depsFile = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json"); - Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); + string baseDirectory = AppContext.BaseDirectory; + string probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); + string runtimeConfig = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json"); + string depsFile = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json"); + Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); - ProcessStartInfo start = new("dotnet") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - start.ArgumentList.Add("exec"); - start.ArgumentList.Add("--runtimeconfig"); - start.ArgumentList.Add(runtimeConfig); - start.ArgumentList.Add("--depsfile"); - start.ArgumentList.Add(depsFile); - start.ArgumentList.Add(probe); - start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); + ProcessStartInfo start = new("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(runtimeConfig); + start.ArgumentList.Add("--depsfile"); + start.ArgumentList.Add(depsFile); + start.ArgumentList.Add(probe); + start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); - using var process = Process.Start(start); - Assert.NotNull(process); - var cancellationToken = TestContext.Current.CancellationToken; - var output = process.StandardOutput.ReadToEndAsync(cancellationToken); - var error = process.StandardError.ReadToEndAsync(cancellationToken); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(TimeSpan.FromSeconds(30)); - try - { - await process.WaitForExitAsync(timeout.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - process.Kill(true); - Assert.Fail("The native failure probe did not exit within 30 seconds."); - } + using Process? process = Process.Start(start); + Assert.NotNull(process); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + process.Kill(true); + Assert.Fail("The native failure probe did not exit within 30 seconds."); + } - var standardOutput = await output; - var standardError = await error; - Assert.True(process.ExitCode == 0, - string.Create(CultureInfo.InvariantCulture, - $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); - Assert.Contains("MARK PushByteTable protected allocator boundary recovered", standardOutput, - StringComparison.Ordinal); - Assert.Contains("MARK PushHostObject native pusher longjmp observed", standardOutput, StringComparison.Ordinal); - Assert.Contains("MARK LuaRef.Release protected allocator boundary recovered", standardOutput, - StringComparison.Ordinal); - Assert.Contains("PASS native protected allocation, finalizer, and host-object longjmp boundaries", - standardOutput, - StringComparison.Ordinal); - } + string standardOutput = await output; + string standardError = await error; + Assert.True(process.ExitCode == 0, + string.Create(CultureInfo.InvariantCulture, + $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); + Assert.Contains("MARK PushByteTable protected allocator boundary recovered", standardOutput, + StringComparison.Ordinal); + Assert.Contains("MARK PushHostObject native pusher longjmp observed", standardOutput, StringComparison.Ordinal); + Assert.Contains("MARK LuaRef.Release protected allocator boundary recovered", standardOutput, + StringComparison.Ordinal); + Assert.Contains("PASS native protected allocation, finalizer, and host-object longjmp boundaries", + standardOutput, + StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs index dfb74256..631d5654 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -12,121 +13,121 @@ namespace CheatEngine.SDK.Lua.Tests.References; /// public sealed class LuaRefEpochTests { - [Fact] - public void A_new_reference_is_unresolved_and_not_current() - { - LuaRef reference = new(); - - Assert.False(reference.IsResolved); - Assert.False(reference.IsCurrent); - Assert.Equal(LuaApi.LUA_NOREF, reference.Reference); - Assert.Equal(default, reference.Identity); - Assert.Equal(0, reference.Epoch); - Assert.Equal(0, reference.StateGeneration); - Assert.Equal("LuaRef(unresolved)", reference.ToString()); - } - - [Fact] - public void A_reference_bound_in_the_current_state_identity_is_current() - { - LuaRef reference = new(); - var identity = LuaRuntime.CurrentStateIdentity; - - reference.Rebind(17, identity); - - Assert.True(reference.IsResolved); - Assert.True(reference.IsCurrent); - Assert.Equal(17, reference.Reference); - Assert.Equal(identity, reference.Identity); - Assert.Equal(identity.AttachEpoch, reference.Epoch); - Assert.Equal(identity.StateGeneration, reference.StateGeneration); - Assert.True(reference.TryGetCurrent(out var slot)); - Assert.Equal(17, slot); - Assert.Equal( - string.Create(CultureInfo.InvariantCulture, - $"LuaRef(17, attach epoch {identity.AttachEpoch}, state generation {identity.StateGeneration})"), - reference.ToString()); - } - - [Fact] - public void A_reference_from_another_epoch_is_resolved_but_stale() - { - LuaRef reference = new(); - var identity = LuaRuntime.CurrentStateIdentity; - reference.Rebind(17, new LuaStateIdentity(identity.AttachEpoch + 1, identity.StateGeneration)); - - Assert.True(reference.IsResolved); - Assert.False(reference.IsCurrent); - Assert.False(reference.TryGetCurrent(out _)); - } - - [Fact] - public void A_reference_from_another_state_generation_is_resolved_but_stale() - { - LuaRef reference = new(); - var identity = LuaRuntime.CurrentStateIdentity; - reference.Rebind(17, new LuaStateIdentity(identity.AttachEpoch, identity.StateGeneration + 1)); - - Assert.True(reference.IsResolved); - Assert.Equal(identity.AttachEpoch, reference.Epoch); - Assert.False(reference.IsCurrent); - Assert.False(reference.TryGetCurrent(out _)); - } - - [Fact] - public void A_reference_to_nil_is_a_valid_current_reference() - { - LuaRef reference = new(); - reference.Rebind(LuaApi.LUA_REFNIL, LuaRuntime.CurrentStateIdentity); - - Assert.True(reference.IsResolved); - Assert.True(reference.IsCurrent); - } - - [Fact] - public void Releasing_a_stale_reference_marks_it_released_without_touching_lua() - { - LuaRef reference = new(); - var identity = LuaRuntime.CurrentStateIdentity; - reference.Rebind(17, new LuaStateIdentity(identity.AttachEpoch + 1, identity.StateGeneration)); - - reference.Release(default); - reference.Release(default); - - Assert.False(reference.IsResolved); - Assert.False(reference.IsCurrent); - } - - [Fact] - public void Releasing_a_current_reference_with_no_state_only_forgets_it() - { - LuaRef reference = new(); - reference.Rebind(17, LuaRuntime.CurrentStateIdentity); - - reference.Release(default); - - Assert.False(reference.IsResolved); - } - - [Fact] - public void Dispose_while_detached_forgets_the_reference() - { - LuaRuntime.Detach(); - LuaRef reference = new(); - reference.Rebind(17, LuaRuntime.CurrentStateIdentity); - - reference.Dispose(); - - Assert.False(reference.IsResolved); - } - - [Fact] - public void Pushing_an_unresolved_or_null_reference_pushes_nothing() - { - // No state is needed: both checks fail before any C API call is made. - LuaState none = default; - - Assert.False(none.TryPushRef(new LuaRef())); - Assert.False(none.TryPushRef(null!)); - } + [Fact] + public void A_new_reference_is_unresolved_and_not_current() + { + LuaRef reference = new(); + + Assert.False(reference.IsResolved); + Assert.False(reference.IsCurrent); + Assert.Equal(LuaApi.LUA_NOREF, reference.Reference); + Assert.Equal(default, reference.Identity); + Assert.Equal(0, reference.Epoch); + Assert.Equal(0, reference.StateGeneration); + Assert.Equal("LuaRef(unresolved)", reference.ToString()); + } + + [Fact] + public void A_reference_bound_in_the_current_state_identity_is_current() + { + LuaRef reference = new(); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + + reference.Rebind(17, identity); + + Assert.True(reference.IsResolved); + Assert.True(reference.IsCurrent); + Assert.Equal(17, reference.Reference); + Assert.Equal(identity, reference.Identity); + Assert.Equal(identity.AttachEpoch, reference.Epoch); + Assert.Equal(identity.StateGeneration, reference.StateGeneration); + Assert.True(reference.TryGetCurrent(out int slot)); + Assert.Equal(17, slot); + Assert.Equal( + string.Create(CultureInfo.InvariantCulture, + $"LuaRef(17, attach epoch {identity.AttachEpoch}, state generation {identity.StateGeneration})"), + reference.ToString()); + } + + [Fact] + public void A_reference_from_another_epoch_is_resolved_but_stale() + { + LuaRef reference = new(); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + reference.Rebind(17, new LuaStateIdentity(identity.AttachEpoch + 1, identity.StateGeneration)); + + Assert.True(reference.IsResolved); + Assert.False(reference.IsCurrent); + Assert.False(reference.TryGetCurrent(out _)); + } + + [Fact] + public void A_reference_from_another_state_generation_is_resolved_but_stale() + { + LuaRef reference = new(); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + reference.Rebind(17, new LuaStateIdentity(identity.AttachEpoch, identity.StateGeneration + 1)); + + Assert.True(reference.IsResolved); + Assert.Equal(identity.AttachEpoch, reference.Epoch); + Assert.False(reference.IsCurrent); + Assert.False(reference.TryGetCurrent(out _)); + } + + [Fact] + public void A_reference_to_nil_is_a_valid_current_reference() + { + LuaRef reference = new(); + reference.Rebind(LuaApi.LUA_REFNIL, LuaRuntime.CurrentStateIdentity); + + Assert.True(reference.IsResolved); + Assert.True(reference.IsCurrent); + } + + [Fact] + public void Releasing_a_stale_reference_marks_it_released_without_touching_lua() + { + LuaRef reference = new(); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + reference.Rebind(17, new LuaStateIdentity(identity.AttachEpoch + 1, identity.StateGeneration)); + + reference.Release(default); + reference.Release(default); + + Assert.False(reference.IsResolved); + Assert.False(reference.IsCurrent); + } + + [Fact] + public void Releasing_a_current_reference_with_no_state_only_forgets_it() + { + LuaRef reference = new(); + reference.Rebind(17, LuaRuntime.CurrentStateIdentity); + + reference.Release(default); + + Assert.False(reference.IsResolved); + } + + [Fact] + public void Dispose_while_detached_forgets_the_reference() + { + LuaRuntime.Detach(); + LuaRef reference = new(); + reference.Rebind(17, LuaRuntime.CurrentStateIdentity); + + reference.Dispose(); + + Assert.False(reference.IsResolved); + } + + [Fact] + public void Pushing_an_unresolved_or_null_reference_pushes_nothing() + { + // No state is needed: both checks fail before any C API call is made. + LuaState none = default; + + Assert.False(none.TryPushRef(new LuaRef())); + Assert.False(none.TryPushRef(null!)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs index e3c9d84c..afad9efb 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -10,199 +11,199 @@ namespace CheatEngine.SDK.Lua.Tests.References; [Trait("Category", "NativeLua")] public sealed class LuaRefTests { - [Fact] - public void Create_push_and_release_round_trip() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var L = LuaTest.View(state); - L.PushString("kept alive"u8); - - var reference = L.CreateRef(); - - Assert.Equal(0, L.Top); - Assert.True(reference.IsResolved); - Assert.True(reference.IsCurrent); - Assert.Equal(LuaRuntime.Epoch, reference.Epoch); - Assert.True(reference.Reference > 0); // Slots belong to the private table, not the host registry. - - Assert.True(L.TryPushRef(reference)); - Assert.Equal("kept alive", LuaTest.ReadString(L, -1)); - L.Pop(1); - - reference.Release(L); - Assert.False(reference.IsResolved); - Assert.False(L.TryPushRef(reference)); - Assert.Equal(0, L.Top); - - // The slot went back to the registry's free list: the next reference reuses it. - L.PushInteger(1); - var next = L.CreateRef(); - Assert.Equal("LuaRef(unresolved)", reference.ToString()); - Assert.True(next.IsCurrent); - next.Release(L); - } - - [Fact] - public void Release_is_idempotent_and_does_not_free_the_slot_twice() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var L = LuaTest.View(state); - L.PushInteger(1); - var first = L.CreateRef(); - var slot = first.Reference; - - first.Release(L); - first.Release(L); - - // Two distinct new references must get two distinct slots; a doubly freed slot would be handed out twice. - L.PushInteger(2); - var a = L.CreateRef(); - L.PushInteger(3); - var b = L.CreateRef(); - Assert.Equal(slot, a.Reference); - Assert.NotEqual(a.Reference, b.Reference); - a.Release(L); - b.Release(L); - } - - [Fact] - public void A_reference_to_nil_pushes_nil() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushNil(); - - var reference = L.CreateRef(); - - Assert.Equal(LuaApi.LUA_REFNIL, reference.Reference); - Assert.True(L.TryPushRef(reference)); - Assert.True(L.IsNil(-1)); - reference.Release(L); - } - - [Fact] - public void References_are_invalidated_by_detach_and_reattach() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - LuaRef reference; - int epochBefore; - using (new RuntimeScope(state)) - { - epochBefore = LuaRuntime.Epoch; - L.PushString("epoch value"u8); - reference = L.CreateRef(); - Assert.True(reference.IsCurrent); - Assert.Equal(epochBefore, reference.Epoch); - } - - // Detached: the epoch is unchanged, the reference still counts as current. - Assert.True(reference.IsCurrent); - - using (new RuntimeScope(state)) - { - Assert.Equal(epochBefore + 1, LuaRuntime.Epoch); - Assert.True(reference.IsResolved); - Assert.False(reference.IsCurrent); - Assert.False(L.TryPushRef(reference)); - Assert.Equal(0, L.Top); - - // Releasing a stale reference never touches the (possibly foreign) registry slot; it is only forgotten. - reference.Release(L); - Assert.False(reference.IsResolved); - } - } - - [Fact] - public void A_reference_from_the_pre_reset_state_never_releases_a_current_generation_slot() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - L.PushString("old generation"u8); - var stale = L.CreateRef(); - var before = LuaRuntime.CurrentStateIdentity; - - using (LuaRuntime.BeginStateReset()) - { - } - - var after = LuaRuntime.CurrentStateIdentity; - Assert.Equal(before.AttachEpoch, after.AttachEpoch); - Assert.Equal(before.StateGeneration + 1, after.StateGeneration); - Assert.True(stale.IsResolved); - Assert.False(stale.IsCurrent); - Assert.False(L.TryPushRef(stale)); - Assert.Equal(0, L.Top); - - L.PushString("current generation"u8); - var current = L.CreateRef(); - Assert.True(current.IsCurrent); - - // Model a registry-slot number reused by the replacement state. This stale binding must not be unref'd merely - // because it shares the current attachment epoch: state generation is the distinguishing component. - LuaRef collidingStale = new(); - collidingStale.Rebind(current.Reference, before); - collidingStale.Release(L); - - Assert.False(collidingStale.IsResolved); - stale.Release(L); - - Assert.False(stale.IsResolved); - Assert.True(L.TryPushRef(current)); - Assert.Equal("current generation", LuaTest.ReadString(L, -1)); - L.Pop(1); - Assert.Equal(0, L.Top); - current.Release(L); - } - - [Fact] - public void Dispose_releases_through_the_attached_runtime() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - L.PushInteger(7); - var reference = L.CreateRef(); - var slot = reference.Reference; - - reference.Dispose(); - - Assert.False(reference.IsResolved); - L.PushInteger(8); - var reused = L.CreateRef(); - Assert.Equal(slot, reused.Reference); - reused.Dispose(); - } - - [Fact] - public void A_stale_reference_is_not_released_by_dispose_after_reattach() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - LuaRef stale; - using (new RuntimeScope(state)) - { - L.PushInteger(1); - stale = L.CreateRef(); - } - - using (new RuntimeScope(state)) - { - // The same slot is still occupied in this state (nothing freed it); a fresh reference must not receive it. - var staleSlot = stale.Reference; - stale.Dispose(); - L.PushInteger(2); - var fresh = L.CreateRef(); - Assert.NotEqual(staleSlot, fresh.Reference); - fresh.Release(L); - } - } + [Fact] + public void Create_push_and_release_round_trip() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString("kept alive"u8); + + LuaRef reference = L.CreateRef(); + + Assert.Equal(0, L.Top); + Assert.True(reference.IsResolved); + Assert.True(reference.IsCurrent); + Assert.Equal(LuaRuntime.Epoch, reference.Epoch); + Assert.True(reference.Reference > 0); // Slots belong to the private table, not the host registry. + + Assert.True(L.TryPushRef(reference)); + Assert.Equal("kept alive", LuaTest.ReadString(L, -1)); + L.Pop(1); + + reference.Release(L); + Assert.False(reference.IsResolved); + Assert.False(L.TryPushRef(reference)); + Assert.Equal(0, L.Top); + + // The slot went back to the registry's free list: the next reference reuses it. + L.PushInteger(1); + LuaRef next = L.CreateRef(); + Assert.Equal("LuaRef(unresolved)", reference.ToString()); + Assert.True(next.IsCurrent); + next.Release(L); + } + + [Fact] + public void Release_is_idempotent_and_does_not_free_the_slot_twice() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(1); + LuaRef first = L.CreateRef(); + int slot = first.Reference; + + first.Release(L); + first.Release(L); + + // Two distinct new references must get two distinct slots; a doubly freed slot would be handed out twice. + L.PushInteger(2); + LuaRef a = L.CreateRef(); + L.PushInteger(3); + LuaRef b = L.CreateRef(); + Assert.Equal(slot, a.Reference); + Assert.NotEqual(a.Reference, b.Reference); + a.Release(L); + b.Release(L); + } + + [Fact] + public void A_reference_to_nil_pushes_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushNil(); + + LuaRef reference = L.CreateRef(); + + Assert.Equal(LuaApi.LUA_REFNIL, reference.Reference); + Assert.True(L.TryPushRef(reference)); + Assert.True(L.IsNil(-1)); + reference.Release(L); + } + + [Fact] + public void References_are_invalidated_by_detach_and_reattach() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaRef reference; + int epochBefore; + using (new RuntimeScope(state)) + { + epochBefore = LuaRuntime.Epoch; + L.PushString("epoch value"u8); + reference = L.CreateRef(); + Assert.True(reference.IsCurrent); + Assert.Equal(epochBefore, reference.Epoch); + } + + // Detached: the epoch is unchanged, the reference still counts as current. + Assert.True(reference.IsCurrent); + + using (new RuntimeScope(state)) + { + Assert.Equal(epochBefore + 1, LuaRuntime.Epoch); + Assert.True(reference.IsResolved); + Assert.False(reference.IsCurrent); + Assert.False(L.TryPushRef(reference)); + Assert.Equal(0, L.Top); + + // Releasing a stale reference never touches the (possibly foreign) registry slot; it is only forgotten. + reference.Release(L); + Assert.False(reference.IsResolved); + } + } + + [Fact] + public void A_reference_from_the_pre_reset_state_never_releases_a_current_generation_slot() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + L.PushString("old generation"u8); + LuaRef stale = L.CreateRef(); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + + using (LuaRuntime.BeginStateReset()) + { + } + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.True(stale.IsResolved); + Assert.False(stale.IsCurrent); + Assert.False(L.TryPushRef(stale)); + Assert.Equal(0, L.Top); + + L.PushString("current generation"u8); + LuaRef current = L.CreateRef(); + Assert.True(current.IsCurrent); + + // Model a registry-slot number reused by the replacement state. This stale binding must not be unref'd merely + // because it shares the current attachment epoch: state generation is the distinguishing component. + LuaRef collidingStale = new(); + collidingStale.Rebind(current.Reference, before); + collidingStale.Release(L); + + Assert.False(collidingStale.IsResolved); + stale.Release(L); + + Assert.False(stale.IsResolved); + Assert.True(L.TryPushRef(current)); + Assert.Equal("current generation", LuaTest.ReadString(L, -1)); + L.Pop(1); + Assert.Equal(0, L.Top); + current.Release(L); + } + + [Fact] + public void Dispose_releases_through_the_attached_runtime() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + L.PushInteger(7); + LuaRef reference = L.CreateRef(); + int slot = reference.Reference; + + reference.Dispose(); + + Assert.False(reference.IsResolved); + L.PushInteger(8); + LuaRef reused = L.CreateRef(); + Assert.Equal(slot, reused.Reference); + reused.Dispose(); + } + + [Fact] + public void A_stale_reference_is_not_released_by_dispose_after_reattach() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaRef stale; + using (new RuntimeScope(state)) + { + L.PushInteger(1); + stale = L.CreateRef(); + } + + using (new RuntimeScope(state)) + { + // The same slot is still occupied in this state (nothing freed it); a fresh reference must not receive it. + int staleSlot = stale.Reference; + stale.Dispose(); + L.PushInteger(2); + LuaRef fresh = L.CreateRef(); + Assert.NotEqual(staleSlot, fresh.Reference); + fresh.Release(L); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs index 0c36a192..fcab5f7e 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs @@ -1,8 +1,10 @@ +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Tests.References; @@ -14,153 +16,176 @@ namespace CheatEngine.SDK.Lua.Tests.References; [Trait("Category", "NativeLua")] public sealed class ReferenceConcurrencyTests { - [Fact] - public async Task Concurrent_sdk_references_do_not_disturb_a_host_registry_reference() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(false); - var main = LuaTest.View(state); - using RuntimeScope scope = new(state); - using var first = RootedThread.Create(main); - using var second = RootedThread.Create(main); - using var host = RootedThread.Create(main); - using ManualResetEventSlim start = new(false); - using ManualResetEventSlim hostCreated = new(false); - using ManualResetEventSlim releaseSdkReferences = new(false); - using CountdownEvent sdkCreated = new(2); - using CountdownEvent sdkReleased = new(2); - - var firstSdk = StartWorker(() => CreateReadAndRelease(first.State, 101, start, hostCreated, sdkCreated, - releaseSdkReferences, sdkReleased, cancellationToken), cancellationToken); - var secondSdk = StartWorker(() => CreateReadAndRelease(second.State, 202, start, hostCreated, sdkCreated, - releaseSdkReferences, sdkReleased, cancellationToken), cancellationToken); - var hostWorker = StartWorker(() => CreateAndReadHostReference(host.State, start, hostCreated, sdkCreated, - releaseSdkReferences, sdkReleased, cancellationToken), cancellationToken); - - start.Set(); - var results = await Task.WhenAll(firstSdk, secondSdk, hostWorker) - .WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - - Assert.Equal(101, results[0]); - Assert.Equal(202, results[1]); - Assert.Equal(909, results[2]); - } - - private static Task StartWorker(Func work, CancellationToken cancellationToken) - { - return Task.Factory.StartNew(work, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); - } - - private static int CreateReadAndRelease(LuaState state, int expected, ManualResetEventSlim start, - ManualResetEventSlim hostCreated, CountdownEvent sdkCreated, ManualResetEventSlim releaseSdkReferences, - CountdownEvent sdkReleased, CancellationToken cancellationToken) - { - LuaRef? reference = null; - var created = false; - try - { - if (!start.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The worker start barrier timed out."); - if (!hostCreated.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The host did not create its reference."); - - state.PushInteger(expected); - reference = state.CreateRef(); - Assert.True(state.TryPushRef(reference)); - Assert.True(state.TryReadInteger(-1, out var actual)); - state.Pop(1); - Assert.Equal(expected, actual); - sdkCreated.Signal(); - created = true; - - if (!releaseSdkReferences.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The SDK reference release barrier timed out."); - - reference.Release(state); - reference = null; - return checked((int)actual); - } - finally - { - reference?.Release(state); - if (!created) sdkCreated.Signal(); - sdkReleased.Signal(); - } - } - - private static unsafe int CreateAndReadHostReference(LuaState state, ManualResetEventSlim start, - ManualResetEventSlim hostCreated, CountdownEvent sdkCreated, ManualResetEventSlim releaseSdkReferences, - CountdownEvent sdkReleased, CancellationToken cancellationToken) - { - var reference = LUA_NOREF; - try - { - if (!start.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The worker start barrier timed out."); - - state.PushInteger(909); - reference = luaL_ref(state.Pointer, LUA_REGISTRYINDEX); - - // Seed a host-owned free-list node after its live value. Old SDK references used this very table and would - // consume the node while they were live; private SDK references leave the host's registry untouched. - state.PushInteger(1); - var scratch = luaL_ref(state.Pointer, LUA_REGISTRYINDEX); - luaL_unref(state.Pointer, LUA_REGISTRYINDEX, scratch); - var freeHead = ReadRegistryInteger(state, 0); - hostCreated.Set(); - if (!sdkCreated.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The SDK did not create both references."); - - Assert.Equal(freeHead, ReadRegistryInteger(state, 0)); - - releaseSdkReferences.Set(); - if (!sdkReleased.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The SDK did not release both references."); - - _ = lua_rawgeti(state.Pointer, LUA_REGISTRYINDEX, reference); - Assert.True(state.TryReadInteger(-1, out var actual)); - state.Pop(1); - return checked((int)actual); - } - finally - { - hostCreated.Set(); - releaseSdkReferences.Set(); - if (reference != LUA_NOREF) luaL_unref(state.Pointer, LUA_REGISTRYINDEX, reference); - } - } - - private static unsafe long ReadRegistryInteger(LuaState state, int key) - { - _ = lua_rawgeti(state.Pointer, LUA_REGISTRYINDEX, key); - Assert.True(state.TryReadInteger(-1, out var value)); - state.Pop(1); - return value; - } - - private sealed unsafe class RootedThread : IDisposable - { - private readonly LuaRef _root; - - private RootedThread(LuaState state, LuaRef root) - { - State = state; - _root = root; - } - - public LuaState State { get; } - - public void Dispose() - { - _root.Release(LuaRuntime.AcquireState()); - } - - public static RootedThread Create(LuaState main) - { - var thread = lua_newthread(main.Pointer); - Assert.NotEqual(nint.Zero, (nint)thread); - return new RootedThread(new LuaState(thread), main.CreateRef()); - } - } + [Fact] + public async Task Concurrent_sdk_references_do_not_disturb_a_host_registry_reference() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(false); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using RootedThread first = RootedThread.Create(main); + using RootedThread second = RootedThread.Create(main); + using RootedThread host = RootedThread.Create(main); + using ManualResetEventSlim start = new(false); + using ManualResetEventSlim hostCreated = new(false); + using ManualResetEventSlim releaseSdkReferences = new(false); + using CountdownEvent sdkCreated = new(2); + using CountdownEvent sdkReleased = new(2); + + Task firstSdk = StartWorker(() => CreateReadAndRelease(first.State, 101, start, hostCreated, sdkCreated, + releaseSdkReferences, sdkReleased, cancellationToken), cancellationToken); + Task secondSdk = StartWorker(() => CreateReadAndRelease(second.State, 202, start, hostCreated, sdkCreated, + releaseSdkReferences, sdkReleased, cancellationToken), cancellationToken); + Task hostWorker = StartWorker(() => CreateAndReadHostReference(host.State, start, hostCreated, sdkCreated, + releaseSdkReferences, sdkReleased, cancellationToken), cancellationToken); + + start.Set(); + int[] results = await Task.WhenAll(firstSdk, secondSdk, hostWorker) + .WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + + Assert.Equal(101, results[0]); + Assert.Equal(202, results[1]); + Assert.Equal(909, results[2]); + } + + private static Task StartWorker(Func work, CancellationToken cancellationToken) + { + return Task.Factory.StartNew(work, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + private static int CreateReadAndRelease(LuaState state, int expected, ManualResetEventSlim start, + ManualResetEventSlim hostCreated, CountdownEvent sdkCreated, ManualResetEventSlim releaseSdkReferences, + CountdownEvent sdkReleased, CancellationToken cancellationToken) + { + LuaRef? reference = null; + bool created = false; + try + { + if (!start.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The worker start barrier timed out."); + } + + if (!hostCreated.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The host did not create its reference."); + } + + state.PushInteger(expected); + reference = state.CreateRef(); + Assert.True(state.TryPushRef(reference)); + Assert.True(state.TryReadInteger(-1, out long actual)); + state.Pop(1); + Assert.Equal(expected, actual); + sdkCreated.Signal(); + created = true; + + if (!releaseSdkReferences.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The SDK reference release barrier timed out."); + } + + reference.Release(state); + reference = null; + return checked((int) actual); + } + finally + { + reference?.Release(state); + if (!created) + { + sdkCreated.Signal(); + } + + sdkReleased.Signal(); + } + } + + private static unsafe int CreateAndReadHostReference(LuaState state, ManualResetEventSlim start, + ManualResetEventSlim hostCreated, CountdownEvent sdkCreated, ManualResetEventSlim releaseSdkReferences, + CountdownEvent sdkReleased, CancellationToken cancellationToken) + { + int reference = LUA_NOREF; + try + { + if (!start.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The worker start barrier timed out."); + } + + state.PushInteger(909); + reference = luaL_ref(state.Pointer, LUA_REGISTRYINDEX); + + // Seed a host-owned free-list node after its live value. Old SDK references used this very table and would + // consume the node while they were live; private SDK references leave the host's registry untouched. + state.PushInteger(1); + int scratch = luaL_ref(state.Pointer, LUA_REGISTRYINDEX); + luaL_unref(state.Pointer, LUA_REGISTRYINDEX, scratch); + long freeHead = ReadRegistryInteger(state, 0); + hostCreated.Set(); + if (!sdkCreated.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The SDK did not create both references."); + } + + Assert.Equal(freeHead, ReadRegistryInteger(state, 0)); + + releaseSdkReferences.Set(); + if (!sdkReleased.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The SDK did not release both references."); + } + + _ = lua_rawgeti(state.Pointer, LUA_REGISTRYINDEX, reference); + Assert.True(state.TryReadInteger(-1, out long actual)); + state.Pop(1); + return checked((int) actual); + } + finally + { + hostCreated.Set(); + releaseSdkReferences.Set(); + if (reference != LUA_NOREF) + { + luaL_unref(state.Pointer, LUA_REGISTRYINDEX, reference); + } + } + } + + private static unsafe long ReadRegistryInteger(LuaState state, int key) + { + _ = lua_rawgeti(state.Pointer, LUA_REGISTRYINDEX, key); + Assert.True(state.TryReadInteger(-1, out long value)); + state.Pop(1); + return value; + } + + private sealed unsafe class RootedThread : IDisposable + { + private readonly LuaRef _root; + + private RootedThread(LuaState state, LuaRef root) + { + State = state; + _root = root; + } + + public LuaState State + { + get; + } + + public void Dispose() + { + _root.Release(LuaRuntime.AcquireState()); + } + + public static RootedThread Create(LuaState main) + { + lua_State* thread = lua_newthread(main.Pointer); + Assert.NotEqual(nint.Zero, (nint) thread); + return new RootedThread(new LuaState(thread), main.CreateRef()); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs index fd790335..fb808a04 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs @@ -1,4 +1,3 @@ -using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Registration; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -12,181 +11,181 @@ namespace CheatEngine.SDK.Lua.Tests.Registration; [Trait("Category", "NativeLua")] public sealed class LuaRegistrationSetTests { - [Fact] - public void Default_outcomes_expose_an_empty_failure_list() - { - LuaRegistrationReleaseOutcome release = default; - LuaRegistrationResult registration = default; - - Assert.Empty(release.Failures); - Assert.Empty(registration.Rollback.Failures); - } - - [Fact] - public void Reject_existing_preflights_without_replacing_the_effective_global() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, "sdk009_collision = 42"u8); - - var result = LuaRegistrationSet.Register(L, [Entry("sdk009_collision")]); - - Assert.Equal(LuaRegistrationResultKind.Collision, result.Kind); - Assert.Equal("sdk009_collision", result.Failure?.Name); - Assert.Null(result.Lease); - Assert.Equal(42, ReadInteger(L, "return sdk009_collision"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Replace_existing_restores_the_prior_value_only_while_the_lease_still_owns_the_global() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, "sdk009_replace = 42"u8); - - var result = LuaRegistrationSet.Register(L, [Entry("sdk009_replace")], - LuaRegistrationCollisionPolicy.ReplaceExisting); - var lease = Assert.IsType(result.Lease); - Assert.True(result.IsSuccess); - - Assert.Equal(3, ReadInteger(L, "return sdk009_replace(1, 2)"u8)); - var released = lease.ReleaseWithOutcome(L); - Assert.Equal(LuaRegistrationReleaseKind.Released, released.Kind); - Assert.Equal(1, released.RestoredCount); - Assert.Equal(42, ReadInteger(L, "return sdk009_replace"u8)); - Assert.Equal(LuaRegistrationReleaseKind.AlreadyReleased, lease.ReleaseWithOutcome(L).Kind); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Release_preserves_a_later_replacement_and_reports_it_without_writing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - - var result = LuaRegistrationSet.Register(L, [Entry("sdk009_replaced")]); - var lease = Assert.IsType(result.Lease); - LuaTest.Run(L, "sdk009_replaced = function() return 99 end"u8); - - var released = lease.ReleaseWithOutcome(L); - - Assert.Equal(LuaRegistrationReleaseKind.Released, released.Kind); - Assert.Equal(1, released.ReplacementCount); - Assert.Equal(99, ReadInteger(L, "return sdk009_replaced()"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Publication_failure_rolls_back_later_entries_when_an_earlier_cleanup_fails() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, """ - local values = {} - setmetatable(_G, { - __index = function(_, key) return values[key] end, - __newindex = function(_, key, value) - if key == 'sdk009_third' and value ~= nil then error('third assignment rejected') end - if key == 'sdk009_first' and value == nil then error('first cleanup rejected') end - values[key] = value - end, - }) - """u8); - - var result = LuaRegistrationSet.Register(L, - [Entry("sdk009_first"), Entry("sdk009_second"), Entry("sdk009_third")]); - - Assert.Equal(LuaRegistrationResultKind.PublicationFailed, result.Kind); - Assert.Equal("sdk009_third", result.Failure?.Name); - Assert.Equal(LuaRegistrationReleaseKind.PartiallyReleased, result.Rollback.Kind); - Assert.Equal(1, result.Rollback.RemovedCount); - var failure = Assert.Single(result.Rollback.Failures); - Assert.Equal("sdk009_first", failure.Name); - Assert.NotNull(result.Lease); - Assert.Equal("function", ReadString(L, "return type(sdk009_first)"u8)); - Assert.Equal("nil", ReadString(L, "return type(sdk009_second)"u8)); - result.Lease.Dispose(); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Release_after_state_reset_is_stale_and_does_not_clear_a_new_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var result = LuaRegistrationSet.Register(L, [Entry("sdk009_stale")]); - var lease = Assert.IsType(result.Lease); - - using (LuaRuntime.BeginStateReset()) - { - } - - LuaTest.Run(L, "sdk009_stale = function() return 77 end"u8); - var released = lease.ReleaseWithOutcome(L); - - Assert.Equal(LuaRegistrationReleaseKind.Stale, released.Kind); - Assert.Equal(1, released.RemainingCount); - Assert.Equal(77, ReadInteger(L, "return sdk009_stale()"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Release_reports_an_unconfirmed_entry_when_its_protected_cleanup_fails() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, """ - local values = {} - setmetatable(_G, { - __index = function(_, key) return values[key] end, - __newindex = function(_, key, value) - if key == 'sdk009_release_failure' and value == nil then error('cleanup rejected') end - values[key] = value - end, - }) - """u8); - var result = LuaRegistrationSet.Register(L, [Entry("sdk009_release_failure")]); - var lease = Assert.IsType(result.Lease); - - var released = lease.ReleaseWithOutcome(L); - - Assert.Equal(LuaRegistrationReleaseKind.PartiallyReleased, released.Kind); - Assert.Equal(1, released.RemainingCount); - Assert.Equal("sdk009_release_failure", Assert.Single(released.Failures).Name); - Assert.Equal("function", ReadString(L, "return type(sdk009_release_failure)"u8)); - Assert.Equal(0, L.Top); - } - - private static LuaRegistrationEntry Entry(string name) - { - return new LuaRegistrationEntry(name, Thunks.Add); - } - - private static long ReadInteger(LuaState state, ReadOnlySpan source) - { - using LuaFrame frame = new(state); - LuaTest.Run(state, source, resultCount: 1); - Assert.True(state.TryReadInteger(-1, out var value)); - return value; - } - - private static string ReadString(LuaState state, ReadOnlySpan source) - { - using LuaFrame frame = new(state); - LuaTest.Run(state, source, resultCount: 1); - return LuaTest.ReadString(state, -1); - } + [Fact] + public void Default_outcomes_expose_an_empty_failure_list() + { + LuaRegistrationReleaseOutcome release = default; + LuaRegistrationResult registration = default; + + Assert.Empty(release.Failures); + Assert.Empty(registration.Rollback.Failures); + } + + [Fact] + public void Reject_existing_preflights_without_replacing_the_effective_global() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, "sdk009_collision = 42"u8); + + LuaRegistrationResult result = LuaRegistrationSet.Register(L, [Entry("sdk009_collision")]); + + Assert.Equal(LuaRegistrationResultKind.Collision, result.Kind); + Assert.Equal("sdk009_collision", result.Failure?.Name); + Assert.Null(result.Lease); + Assert.Equal(42, ReadInteger(L, "return sdk009_collision"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Replace_existing_restores_the_prior_value_only_while_the_lease_still_owns_the_global() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, "sdk009_replace = 42"u8); + + LuaRegistrationResult result = LuaRegistrationSet.Register(L, [Entry("sdk009_replace")], + LuaRegistrationCollisionPolicy.ReplaceExisting); + LuaRegistrationLease lease = Assert.IsType(result.Lease); + Assert.True(result.IsSuccess); + + Assert.Equal(3, ReadInteger(L, "return sdk009_replace(1, 2)"u8)); + LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(L); + Assert.Equal(LuaRegistrationReleaseKind.Released, released.Kind); + Assert.Equal(1, released.RestoredCount); + Assert.Equal(42, ReadInteger(L, "return sdk009_replace"u8)); + Assert.Equal(LuaRegistrationReleaseKind.AlreadyReleased, lease.ReleaseWithOutcome(L).Kind); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_preserves_a_later_replacement_and_reports_it_without_writing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + + LuaRegistrationResult result = LuaRegistrationSet.Register(L, [Entry("sdk009_replaced")]); + LuaRegistrationLease lease = Assert.IsType(result.Lease); + LuaTest.Run(L, "sdk009_replaced = function() return 99 end"u8); + + LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(L); + + Assert.Equal(LuaRegistrationReleaseKind.Released, released.Kind); + Assert.Equal(1, released.ReplacementCount); + Assert.Equal(99, ReadInteger(L, "return sdk009_replaced()"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Publication_failure_rolls_back_later_entries_when_an_earlier_cleanup_fails() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, """ + local values = {} + setmetatable(_G, { + __index = function(_, key) return values[key] end, + __newindex = function(_, key, value) + if key == 'sdk009_third' and value ~= nil then error('third assignment rejected') end + if key == 'sdk009_first' and value == nil then error('first cleanup rejected') end + values[key] = value + end, + }) + """u8); + + LuaRegistrationResult result = LuaRegistrationSet.Register(L, + [Entry("sdk009_first"), Entry("sdk009_second"), Entry("sdk009_third")]); + + Assert.Equal(LuaRegistrationResultKind.PublicationFailed, result.Kind); + Assert.Equal("sdk009_third", result.Failure?.Name); + Assert.Equal(LuaRegistrationReleaseKind.PartiallyReleased, result.Rollback.Kind); + Assert.Equal(1, result.Rollback.RemovedCount); + LuaRegistrationReleaseFailure failure = Assert.Single(result.Rollback.Failures); + Assert.Equal("sdk009_first", failure.Name); + Assert.NotNull(result.Lease); + Assert.Equal("function", ReadString(L, "return type(sdk009_first)"u8)); + Assert.Equal("nil", ReadString(L, "return type(sdk009_second)"u8)); + result.Lease.Dispose(); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_after_state_reset_is_stale_and_does_not_clear_a_new_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaRegistrationResult result = LuaRegistrationSet.Register(L, [Entry("sdk009_stale")]); + LuaRegistrationLease lease = Assert.IsType(result.Lease); + + using (LuaRuntime.BeginStateReset()) + { + } + + LuaTest.Run(L, "sdk009_stale = function() return 77 end"u8); + LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(L); + + Assert.Equal(LuaRegistrationReleaseKind.Stale, released.Kind); + Assert.Equal(1, released.RemainingCount); + Assert.Equal(77, ReadInteger(L, "return sdk009_stale()"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_reports_an_unconfirmed_entry_when_its_protected_cleanup_fails() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, """ + local values = {} + setmetatable(_G, { + __index = function(_, key) return values[key] end, + __newindex = function(_, key, value) + if key == 'sdk009_release_failure' and value == nil then error('cleanup rejected') end + values[key] = value + end, + }) + """u8); + LuaRegistrationResult result = LuaRegistrationSet.Register(L, [Entry("sdk009_release_failure")]); + LuaRegistrationLease lease = Assert.IsType(result.Lease); + + LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(L); + + Assert.Equal(LuaRegistrationReleaseKind.PartiallyReleased, released.Kind); + Assert.Equal(1, released.RemainingCount); + Assert.Equal("sdk009_release_failure", Assert.Single(released.Failures).Name); + Assert.Equal("function", ReadString(L, "return type(sdk009_release_failure)"u8)); + Assert.Equal(0, L.Top); + } + + private static LuaRegistrationEntry Entry(string name) + { + return new LuaRegistrationEntry(name, Thunks.Add); + } + + private static long ReadInteger(LuaState state, ReadOnlySpan source) + { + using LuaFrame frame = new(state); + LuaTest.Run(state, source, 1); + Assert.True(state.TryReadInteger(-1, out long value)); + return value; + } + + private static string ReadString(LuaState state, ReadOnlySpan source) + { + using LuaFrame frame = new(state); + LuaTest.Run(state, source, 1); + return LuaTest.ReadString(state, -1); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeDetachedTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeDetachedTests.cs index 2da82c76..e37f1aea 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeDetachedTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeDetachedTests.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; namespace CheatEngine.SDK.Lua.Tests.Runtime; @@ -6,92 +7,92 @@ namespace CheatEngine.SDK.Lua.Tests.Runtime; /// The runtime while no host is attached: every accessor fails cleanly. No Lua library involved. public sealed class LuaRuntimeDetachedTests { - [Fact] - public void Detached_runtime_reports_itself_as_such() - { - LuaRuntime.Detach(); - - Assert.False(LuaRuntime.IsAttached); - Assert.False(LuaRuntime.IsMainThread); - Assert.Equal(default, LuaRuntime.CurrentBinding); - Assert.False(LuaRuntime.TryAcquireState(out var state)); - Assert.True(state.IsNull); - } - - [Fact] - public void Detach_is_idempotent_and_does_not_move_the_epoch() - { - LuaRuntime.Detach(); - var epoch = LuaRuntime.Epoch; - - LuaRuntime.Detach(); - LuaRuntime.Detach(); - - Assert.Equal(epoch, LuaRuntime.Epoch); - } - - [Fact] - public void AcquireState_throws_while_detached() - { - LuaRuntime.Detach(); - - var exception = Assert.Throws(() => LuaRuntime.AcquireState()); - Assert.Contains("not enabled", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void BeginStateReset_throws_while_detached() - { - LuaRuntime.Detach(); - var identity = LuaRuntime.CurrentStateIdentity; - - Assert.Throws(BeginStateReset); - - Assert.Equal(identity, LuaRuntime.CurrentStateIdentity); - } - - private static void BeginStateReset() - { - using var reset = LuaRuntime.BeginStateReset(); - } - - [Fact] - public void PushHostObject_throws_while_detached() - { - LuaRuntime.Detach(); - - Assert.Throws(() => LuaRuntime.PushHostObject(default, 0x1234)); - } - - [Fact] - public void Attach_rejects_a_binding_without_a_state_provider() - { - LuaRuntime.Detach(); - LuaHostBinding invalid = new(0, HostDouble.PusherAddress, Environment.CurrentManagedThreadId); - var epoch = LuaRuntime.Epoch; - - var exception = Assert.Throws(() => LuaRuntime.Attach(in invalid)); - - Assert.Equal("binding", exception.ParamName); - Assert.False(invalid.IsValid); - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(epoch, LuaRuntime.Epoch); - } - - [Fact] - public void Binding_equality_covers_all_three_fields() - { - LuaHostBinding a = new(0x10, 0x20, 7); - LuaHostBinding b = new(0x10, 0x20, 7); - - Assert.Equal(a, b); - Assert.True(a == b); - Assert.True(a != new LuaHostBinding(0x10, 0x20, 8)); - Assert.True(a != new LuaHostBinding(0x10, 0x21, 7)); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - Assert.True(a.IsValid); - Assert.Equal(0x10, a.StateProvider); - Assert.Equal(0x20, a.HostObjectPusher); - Assert.Equal(7, a.MainThreadId); - } + [Fact] + public void Detached_runtime_reports_itself_as_such() + { + LuaRuntime.Detach(); + + Assert.False(LuaRuntime.IsAttached); + Assert.False(LuaRuntime.IsMainThread); + Assert.Equal(default, LuaRuntime.CurrentBinding); + Assert.False(LuaRuntime.TryAcquireState(out LuaState state)); + Assert.True(state.IsNull); + } + + [Fact] + public void Detach_is_idempotent_and_does_not_move_the_epoch() + { + LuaRuntime.Detach(); + int epoch = LuaRuntime.Epoch; + + LuaRuntime.Detach(); + LuaRuntime.Detach(); + + Assert.Equal(epoch, LuaRuntime.Epoch); + } + + [Fact] + public void AcquireState_throws_while_detached() + { + LuaRuntime.Detach(); + + InvalidOperationException exception = Assert.Throws(() => LuaRuntime.AcquireState()); + Assert.Contains("not enabled", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void BeginStateReset_throws_while_detached() + { + LuaRuntime.Detach(); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + + Assert.Throws(BeginStateReset); + + Assert.Equal(identity, LuaRuntime.CurrentStateIdentity); + } + + private static void BeginStateReset() + { + using LuaStateResetTransition reset = LuaRuntime.BeginStateReset(); + } + + [Fact] + public void PushHostObject_throws_while_detached() + { + LuaRuntime.Detach(); + + Assert.Throws(() => LuaRuntime.PushHostObject(default, 0x1234)); + } + + [Fact] + public void Attach_rejects_a_binding_without_a_state_provider() + { + LuaRuntime.Detach(); + LuaHostBinding invalid = new(0, HostDouble.PusherAddress, Environment.CurrentManagedThreadId); + int epoch = LuaRuntime.Epoch; + + ArgumentException exception = Assert.Throws(() => LuaRuntime.Attach(in invalid)); + + Assert.Equal("binding", exception.ParamName); + Assert.False(invalid.IsValid); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(epoch, LuaRuntime.Epoch); + } + + [Fact] + public void Binding_equality_covers_all_three_fields() + { + LuaHostBinding a = new(0x10, 0x20, 7); + LuaHostBinding b = new(0x10, 0x20, 7); + + Assert.Equal(a, b); + Assert.True(a == b); + Assert.True(a != new LuaHostBinding(0x10, 0x20, 8)); + Assert.True(a != new LuaHostBinding(0x10, 0x21, 7)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.True(a.IsValid); + Assert.Equal(0x10, a.StateProvider); + Assert.Equal(0x20, a.HostObjectPusher); + Assert.Equal(7, a.MainThreadId); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs index 2cca33f1..4419e023 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -11,296 +12,299 @@ namespace CheatEngine.SDK.Lua.Tests.Runtime; [Trait("Category", "NativeLua")] public sealed class LuaRuntimeTests { - [Fact] - public void Attach_publishes_the_binding_and_advances_the_attach_epoch_once() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - LuaRuntime.Detach(); - var identity = LuaRuntime.CurrentStateIdentity; - - using (RuntimeScope scope = new(state)) - { - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(identity.AttachEpoch + 1, LuaRuntime.Epoch); - Assert.Equal(identity.StateGeneration, LuaRuntime.StateGeneration); - Assert.Equal(LuaRuntime.Epoch, LuaRuntime.CurrentStateIdentity.AttachEpoch); - Assert.Equal(LuaRuntime.StateGeneration, LuaRuntime.CurrentStateIdentity.StateGeneration); - Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); - Assert.Equal(HostDouble.ProviderAddress, LuaRuntime.CurrentBinding.StateProvider); - Assert.Equal(HostDouble.PusherAddress, LuaRuntime.CurrentBinding.HostObjectPusher); - Assert.True(LuaRuntime.IsMainThread); - } - - Assert.False(LuaRuntime.IsAttached); - Assert.Equal(identity.AttachEpoch + 1, LuaRuntime.Epoch); - Assert.False(LuaRuntime.IsMainThread); - } - - [Fact] - public void BeginStateReset_advances_only_the_state_generation_while_the_host_remains_attached() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - using RuntimeScope scope = new(state); - var before = LuaRuntime.CurrentStateIdentity; - - using (LuaRuntime.BeginStateReset()) - { - } - - var after = LuaRuntime.CurrentStateIdentity; - Assert.True(LuaRuntime.IsAttached); - Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); - Assert.Equal(before.AttachEpoch, after.AttachEpoch); - Assert.Equal(before.StateGeneration + 1, after.StateGeneration); - Assert.Equal(before.AttachEpoch, LuaRuntime.Epoch); - Assert.Equal(after.StateGeneration, LuaRuntime.StateGeneration); - } - - [Fact] - public void AcquireOperation_calls_the_provider_once_per_operation_and_returns_its_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - using RuntimeScope scope = new(state); - - using var firstOperation = LuaRuntime.AcquireOperation(); - using var secondOperation = LuaRuntime.AcquireOperation(); - Assert.True(LuaRuntime.TryAcquireOperation(out var thirdOperation)); - using (thirdOperation) - { - var first = firstOperation.State; - var second = secondOperation.State; - var third = thirdOperation.State; - - Assert.Equal(state.Pointer, first.Handle); - Assert.Equal(first, second); - Assert.Equal(first, third); - Assert.Equal(3, HostDouble.ProviderCalls); - - first.PushInteger(11); - Assert.Equal(1, LuaTest.View(state).Top); - } - } - - [Fact] - public unsafe void A_provider_that_returns_no_state_is_reported() - { - LuaTest.RequireNativeLua(); - LuaRuntime.Detach(); - var binding = HostDouble.CreateBinding(null); - LuaRuntime.Attach(in binding); - try - { - Assert.False(LuaRuntime.TryAcquireOperation(out var operation)); - operation.Dispose(); - var exception = Assert.Throws(() => LuaRuntime.AcquireOperation()); - Assert.Contains("no Lua state", exception.Message, StringComparison.Ordinal); - - // A failed provider acquisition must return its admission before a transition attempts to drain it. - using (LuaRuntime.BeginStateReset()) - { - } - } - finally - { - LuaRuntime.Detach(); - } - } - - [Fact] - public void IsMainThread_is_false_on_another_thread() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - using RuntimeScope scope = new(state); - var onMain = LuaRuntime.IsMainThread; - var onWorker = true; - - Thread worker = new(() => onWorker = LuaRuntime.IsMainThread); - worker.Start(); - worker.Join(); - - Assert.True(onMain); - Assert.False(onWorker); - } - - [Fact] - public void PushHostObject_uses_the_protected_bridge_to_call_the_pusher_with_the_object_pointer() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - using RuntimeScope scope = new(state); - using var operation = LuaRuntime.AcquireOperation(); - var L = operation.State; - - LuaRuntime.PushHostObject(L, 0xBEEF); - - Assert.Equal(1, HostDouble.PusherCalls); - Assert.Equal(0xBEEF, HostDouble.LastPushedObject); - Assert.Equal(1, L.Top); - Assert.True(L.IsLightUserdata(-1)); - Assert.Equal(0xBEEF, L.ToUserdata(-1)); - } - - [Fact] - public void PushHostObject_without_a_pusher_throws_instead_of_jumping_to_zero() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - using RuntimeScope scope = new(state, false); - using var operation = LuaRuntime.AcquireOperation(); - var L = operation.State; - - Assert.Equal(0, scope.Binding.HostObjectPusher); - Assert.Throws(() => LuaRuntime.PushHostObject(L, 1)); - Assert.Equal(0, L.Top); - } - - [Fact] - public unsafe void Attach_while_attached_replaces_the_binding_and_advances_the_epoch_again() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - LuaRuntime.Detach(); - var epoch = LuaRuntime.Epoch; - var first = HostDouble.CreateBinding(state.L, false); - LuaRuntime.Attach(in first); - try - { - var second = HostDouble.CreateBinding(state.L); - LuaRuntime.Attach(in second); - - Assert.Equal(epoch + 2, LuaRuntime.Epoch); - Assert.Equal(second, LuaRuntime.CurrentBinding); - Assert.NotEqual(first, second); - } - finally - { - LuaRuntime.Detach(); - } - } - - [Fact] - public async Task BeginStateReset_rejects_new_operations_and_waits_for_an_admitted_operation_to_leave() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(openLibraries: false); - using RuntimeScope scope = new(state); - using ManualResetEventSlim workerAdmitted = new(initialState: false); - using ManualResetEventSlim releaseWorker = new(initialState: false); - using ManualResetEventSlim admissionClosed = new(initialState: false); - var before = LuaRuntime.CurrentStateIdentity; - LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; - - try - { - var worker = Task.Factory.StartNew(() => - { - using var operation = LuaRuntime.AcquireOperation(); - workerAdmitted.Set(); - if (!releaseWorker.Wait(TimeSpan.FromSeconds(5), cancellationToken)) - throw new TimeoutException("The operation-lease barrier timed out."); - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(workerAdmitted.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The worker did not acquire a Lua operation admission."); - - var reset = Task.Factory.StartNew(() => - { - using var transition = LuaRuntime.BeginStateReset(); - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The reset did not close Lua operation admission."); - Assert.False(LuaRuntime.TryAcquireOperation(out var rejected)); - rejected.Dispose(); - Assert.False(reset.IsCompleted, "Reset completed before the admitted operation released its lease."); - - releaseWorker.Set(); - await worker.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - await reset.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - - var after = LuaRuntime.CurrentStateIdentity; - Assert.Equal(before.AttachEpoch, after.AttachEpoch); - Assert.Equal(before.StateGeneration + 1, after.StateGeneration); - Assert.True(LuaRuntime.TryAcquireOperation(out var next)); - next.Dispose(); - } - finally - { - LuaRuntime.OperationAdmissionClosedForTesting = null; - releaseWorker.Set(); - } - } - - [Fact] - public void AcquireOperation_with_a_host_supplied_state_admits_without_recalling_the_provider() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - using RuntimeScope scope = new(state); - - using var outer = LuaRuntime.AcquireOperation(); - var callsBeforeSuppliedLease = HostDouble.ProviderCalls; - using var supplied = LuaRuntime.AcquireOperation(outer.State); - - Assert.Equal(outer.State, supplied.State); - Assert.Equal(callsBeforeSuppliedLease, HostDouble.ProviderCalls); - } - - [Fact] - public void BeginStateReset_rejects_reentrancy_from_an_admitted_operation() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - using RuntimeScope scope = new(state); - var before = LuaRuntime.CurrentStateIdentity; - using var operation = LuaRuntime.AcquireOperation(); - - var exception = Assert.Throws(StartAndCompleteStateReset); - - Assert.Contains("cannot start", exception.Message, StringComparison.Ordinal); - Assert.Equal(before, LuaRuntime.CurrentStateIdentity); - } - - [Fact] - public async Task Detach_rejects_a_nested_transition_before_waiting_for_another_transition_lock() - { - LuaTest.RequireNativeLua(); - var cancellationToken = TestContext.Current.CancellationToken; - using NativeLuaState state = new(openLibraries: false); - using RuntimeScope scope = new(state); - using ManualResetEventSlim admissionClosed = new(initialState: false); - LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; - Task? competingDetach = null; - - try - { - using (LuaRuntime.AcquireOperation()) - { - competingDetach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), - "The competing detach did not close operation admission."); - - var exception = Assert.Throws(LuaRuntime.Detach); - - Assert.Contains("cannot start", exception.Message, StringComparison.Ordinal); - Assert.False(competingDetach.IsCompleted, - "The competing detach completed while the admitted operation was still active."); - } - - await competingDetach!.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); - } - finally - { - LuaRuntime.OperationAdmissionClosedForTesting = null; - } - } - - private static void StartAndCompleteStateReset() - { - using var transition = LuaRuntime.BeginStateReset(); - } + [Fact] + public void Attach_publishes_the_binding_and_advances_the_attach_epoch_once() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaRuntime.Detach(); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + + using (RuntimeScope scope = new(state)) + { + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(identity.AttachEpoch + 1, LuaRuntime.Epoch); + Assert.Equal(identity.StateGeneration, LuaRuntime.StateGeneration); + Assert.Equal(LuaRuntime.Epoch, LuaRuntime.CurrentStateIdentity.AttachEpoch); + Assert.Equal(LuaRuntime.StateGeneration, LuaRuntime.CurrentStateIdentity.StateGeneration); + Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); + Assert.Equal(HostDouble.ProviderAddress, LuaRuntime.CurrentBinding.StateProvider); + Assert.Equal(HostDouble.PusherAddress, LuaRuntime.CurrentBinding.HostObjectPusher); + Assert.True(LuaRuntime.IsMainThread); + } + + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(identity.AttachEpoch + 1, LuaRuntime.Epoch); + Assert.False(LuaRuntime.IsMainThread); + } + + [Fact] + public void BeginStateReset_advances_only_the_state_generation_while_the_host_remains_attached() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + + using (LuaRuntime.BeginStateReset()) + { + } + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.True(LuaRuntime.IsAttached); + Assert.Equal(scope.Binding, LuaRuntime.CurrentBinding); + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.Equal(before.AttachEpoch, LuaRuntime.Epoch); + Assert.Equal(after.StateGeneration, LuaRuntime.StateGeneration); + } + + [Fact] + public void AcquireOperation_calls_the_provider_once_per_operation_and_returns_its_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + + using LuaRuntimeOperation firstOperation = LuaRuntime.AcquireOperation(); + using LuaRuntimeOperation secondOperation = LuaRuntime.AcquireOperation(); + Assert.True(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation thirdOperation)); + using (thirdOperation) + { + LuaState first = firstOperation.State; + LuaState second = secondOperation.State; + LuaState third = thirdOperation.State; + + Assert.Equal(state.Pointer, first.Handle); + Assert.Equal(first, second); + Assert.Equal(first, third); + Assert.Equal(3, HostDouble.ProviderCalls); + + first.PushInteger(11); + Assert.Equal(1, LuaTest.View(state).Top); + } + } + + [Fact] + public unsafe void A_provider_that_returns_no_state_is_reported() + { + LuaTest.RequireNativeLua(); + LuaRuntime.Detach(); + LuaHostBinding binding = HostDouble.CreateBinding(null); + LuaRuntime.Attach(in binding); + try + { + Assert.False(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)); + operation.Dispose(); + InvalidOperationException exception = + Assert.Throws(() => LuaRuntime.AcquireOperation()); + Assert.Contains("no Lua state", exception.Message, StringComparison.Ordinal); + + // A failed provider acquisition must return its admission before a transition attempts to drain it. + using (LuaRuntime.BeginStateReset()) + { + } + } + finally + { + LuaRuntime.Detach(); + } + } + + [Fact] + public void IsMainThread_is_false_on_another_thread() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + bool onMain = LuaRuntime.IsMainThread; + bool onWorker = true; + + Thread worker = new(() => onWorker = LuaRuntime.IsMainThread); + worker.Start(); + worker.Join(); + + Assert.True(onMain); + Assert.False(onWorker); + } + + [Fact] + public void PushHostObject_uses_the_protected_bridge_to_call_the_pusher_with_the_object_pointer() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState L = operation.State; + + LuaRuntime.PushHostObject(L, 0xBEEF); + + Assert.Equal(1, HostDouble.PusherCalls); + Assert.Equal(0xBEEF, HostDouble.LastPushedObject); + Assert.Equal(1, L.Top); + Assert.True(L.IsLightUserdata(-1)); + Assert.Equal(0xBEEF, L.ToUserdata(-1)); + } + + [Fact] + public void PushHostObject_without_a_pusher_throws_instead_of_jumping_to_zero() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state, false); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState L = operation.State; + + Assert.Equal(0, scope.Binding.HostObjectPusher); + Assert.Throws(() => LuaRuntime.PushHostObject(L, 1)); + Assert.Equal(0, L.Top); + } + + [Fact] + public unsafe void Attach_while_attached_replaces_the_binding_and_advances_the_epoch_again() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaRuntime.Detach(); + int epoch = LuaRuntime.Epoch; + LuaHostBinding first = HostDouble.CreateBinding(state.L, false); + LuaRuntime.Attach(in first); + try + { + LuaHostBinding second = HostDouble.CreateBinding(state.L); + LuaRuntime.Attach(in second); + + Assert.Equal(epoch + 2, LuaRuntime.Epoch); + Assert.Equal(second, LuaRuntime.CurrentBinding); + Assert.NotEqual(first, second); + } + finally + { + LuaRuntime.Detach(); + } + } + + [Fact] + public async Task BeginStateReset_rejects_new_operations_and_waits_for_an_admitted_operation_to_leave() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + using ManualResetEventSlim workerAdmitted = new(false); + using ManualResetEventSlim releaseWorker = new(false); + using ManualResetEventSlim admissionClosed = new(false); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; + + try + { + Task worker = Task.Factory.StartNew(() => + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + workerAdmitted.Set(); + if (!releaseWorker.Wait(TimeSpan.FromSeconds(5), cancellationToken)) + { + throw new TimeoutException("The operation-lease barrier timed out."); + } + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(workerAdmitted.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The worker did not acquire a Lua operation admission."); + + Task reset = Task.Factory.StartNew(() => + { + using LuaStateResetTransition transition = LuaRuntime.BeginStateReset(); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The reset did not close Lua operation admission."); + Assert.False(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation rejected)); + rejected.Dispose(); + Assert.False(reset.IsCompleted, "Reset completed before the admitted operation released its lease."); + + releaseWorker.Set(); + await worker.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + await reset.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.True(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation next)); + next.Dispose(); + } + finally + { + LuaRuntime.OperationAdmissionClosedForTesting = null; + releaseWorker.Set(); + } + } + + [Fact] + public void AcquireOperation_with_a_host_supplied_state_admits_without_recalling_the_provider() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + + using LuaRuntimeOperation outer = LuaRuntime.AcquireOperation(); + int callsBeforeSuppliedLease = HostDouble.ProviderCalls; + using LuaRuntimeOperation supplied = LuaRuntime.AcquireOperation(outer.State); + + Assert.Equal(outer.State, supplied.State); + Assert.Equal(callsBeforeSuppliedLease, HostDouble.ProviderCalls); + } + + [Fact] + public void BeginStateReset_rejects_reentrancy_from_an_admitted_operation() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + + InvalidOperationException exception = Assert.Throws(StartAndCompleteStateReset); + + Assert.Contains("cannot start", exception.Message, StringComparison.Ordinal); + Assert.Equal(before, LuaRuntime.CurrentStateIdentity); + } + + [Fact] + public async Task Detach_rejects_a_nested_transition_before_waiting_for_another_transition_lock() + { + LuaTest.RequireNativeLua(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using NativeLuaState state = new(false); + using RuntimeScope scope = new(state); + using ManualResetEventSlim admissionClosed = new(false); + LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; + Task? competingDetach = null; + + try + { + using (LuaRuntime.AcquireOperation()) + { + competingDetach = Task.Factory.StartNew(LuaRuntime.Detach, cancellationToken, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + Assert.True(admissionClosed.Wait(TimeSpan.FromSeconds(5), cancellationToken), + "The competing detach did not close operation admission."); + + InvalidOperationException exception = Assert.Throws(LuaRuntime.Detach); + + Assert.Contains("cannot start", exception.Message, StringComparison.Ordinal); + Assert.False(competingDetach.IsCompleted, + "The competing detach completed while the admitted operation was still active."); + } + + await competingDetach!.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + } + finally + { + LuaRuntime.OperationAdmissionClosedForTesting = null; + } + } + + private static void StartAndCompleteStateReset() + { + using LuaStateResetTransition transition = LuaRuntime.BeginStateReset(); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs index f7620bfe..1ae785e9 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs @@ -1,10 +1,15 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; using CheatEngine.SDK.Tests.Shared.NativeLua; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Tests.Runtime; @@ -16,242 +21,296 @@ namespace CheatEngine.SDK.Lua.Tests.Runtime; [Trait("Category", "NativeLua")] public sealed class LuaUniverseQualificationTests { - private static unsafe LuaNativeFunction ReturnOneFunction => new(&ReturnOne); - - [Fact] - public void First_worker_acquisition_rejects_a_missing_state_then_uses_a_distinct_coroutine_in_the_same_universe() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var main = LuaTest.View(state); - using RuntimeScope scope = new(state); - main.PushInteger(719); - Assert.True(main.TrySetGlobal("sdk012_shared_universe"u8).IsOk); - main.PushString("shared private registry"u8); - var sharedReference = main.CreateRef(); - using var worker = RootedCoroutine.Create(main); - var identity = LuaRuntime.CurrentStateIdentity; - var observation = ObserveFirstWorker(worker, sharedReference); - - Assert.Null(observation.Failure); - Assert.True(observation.FirstAcquisitionWasRejected); - Assert.Equal(2, HostDouble.ProviderCalls); - Assert.NotEqual(main.Handle, observation.WorkerState); - Assert.Equal(identity, observation.WorkerIdentity); - Assert.Equal(719L, observation.GlobalValue); - Assert.Equal("shared private registry", observation.ReferenceValue); - Assert.Equal(0, main.Top); - - sharedReference.Release(main); - } - - [Fact] - public void State_reset_generation_invalidates_shared_registry_references_and_callbacks_before_a_fresh_worker_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var main = LuaTest.View(state); - using RuntimeScope scope = new(state); - using var worker = RootedCoroutine.Create(main); - using NativeLuaState replacementState = new(); - var replacementMain = LuaTest.View(replacementState); - using var replacementWorker = RootedCoroutine.Create(replacementMain); - main.PushString("before reset"u8); - var reference = main.CreateRef(); - Assert.True(LuaCallback.TryCreate(main, ReturnOneFunction, new object(), out var callback).IsOk); - Assert.NotNull(callback); - Assert.True(callback.TryRegister(main, "sdk012_reset_callback"u8).IsOk); - var before = LuaRuntime.CurrentStateIdentity; - - using (LuaRuntime.BeginStateReset()) - { - HostDouble.SetStateForCurrentThread(replacementWorker.State.Handle); - } - - var after = LuaRuntime.CurrentStateIdentity; - Assert.Equal(before.AttachEpoch, after.AttachEpoch); - Assert.Equal(before.StateGeneration + 1, after.StateGeneration); - Assert.True(reference.IsResolved); - Assert.False(reference.IsCurrent); - Assert.True(callback.IsReleased); - Assert.False(callback.IsCurrent); - var observation = ObservePostResetWorker(replacementWorker, reference, callback); - - Assert.Null(observation.Failure); - Assert.NotEqual(worker.State.Handle, observation.WorkerState); - Assert.Equal(replacementWorker.State.Handle, observation.WorkerState); - Assert.False(observation.ReferencePushed); - Assert.False(observation.CallbackPushed); - Assert.True(observation.CallbackStatus.IsOk); - Assert.Contains("nil value", observation.CallbackError, StringComparison.Ordinal); - - reference.Release(default); - } - - private static FirstWorkerObservation ObserveFirstWorker(RootedCoroutine worker, LuaRef sharedReference) - { - FirstWorkerObservation observation = new(); - Thread thread = new(() => ObserveFirstWorkerCore(worker, sharedReference, observation)); - thread.Start(); - Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "The first worker-state acquisition did not return."); - return observation; - } - - private static void ObserveFirstWorkerCore( - RootedCoroutine worker, - LuaRef sharedReference, - FirstWorkerObservation observation) - { - try - { - HostDouble.ClearStateForCurrentThread(); - observation.FirstAcquisitionWasRejected = !LuaRuntime.TryAcquireOperation(out var unavailable); - unavailable.Dispose(); - HostDouble.SetStateForCurrentThread(worker.State.Handle); - using var operation = LuaRuntime.AcquireOperation(); - var workerLua = operation.State; - observation.WorkerState = workerLua.Handle; - observation.WorkerIdentity = LuaRuntime.CurrentStateIdentity; - Assert.True(workerLua.TryGetGlobal("sdk012_shared_universe"u8).IsOk); - Assert.True(workerLua.TryReadInteger(-1, out var globalValue)); - observation.GlobalValue = globalValue; - workerLua.Pop(1); - Assert.True(workerLua.TryPushRef(sharedReference)); - Assert.True(workerLua.TryReadString(-1, out var referenceValue)); - observation.ReferenceValue = referenceValue; - workerLua.Pop(1); - Assert.Equal(0, workerLua.Top); - } - catch (Exception exception) - { - observation.Failure = exception; - } - } - - private static PostResetWorkerObservation ObservePostResetWorker( - RootedCoroutine worker, - LuaRef reference, - LuaCallback callback) - { - PostResetWorkerObservation observation = new(); - Thread thread = new(() => ObservePostResetWorkerCore(worker, reference, callback, observation)); - thread.Start(); - Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "The post-reset worker did not return."); - return observation; - } - - private static void ObservePostResetWorkerCore( - RootedCoroutine worker, - LuaRef reference, - LuaCallback callback, - PostResetWorkerObservation observation) - { - try - { - HostDouble.SetStateForCurrentThread(worker.State.Handle); - using var operation = LuaRuntime.AcquireOperation(); - var workerLua = operation.State; - observation.WorkerState = workerLua.Handle; - observation.ReferencePushed = workerLua.TryPushRef(reference); - observation.CallbackPushed = callback.TryPush(workerLua); - observation.CallbackStatus = workerLua.TryExecute( - "local ok, err = pcall(sdk012_reset_callback) return ok, err"u8, 2); - if (observation.CallbackStatus.IsOk) - { - Assert.False(workerLua.ToBoolean(-2)); - observation.CallbackError = LuaTest.ReadString(workerLua, -1); - workerLua.Pop(2); - } - - Assert.Equal(0, workerLua.Top); - } - catch (Exception exception) - { - observation.Failure = exception; - } - } - - private sealed class FirstWorkerObservation - { - public Exception? Failure { get; set; } - - public bool FirstAcquisitionWasRejected { get; set; } - - public long GlobalValue { get; set; } - - public string? ReferenceValue { get; set; } - - public LuaStateIdentity WorkerIdentity { get; set; } - - public nint WorkerState { get; set; } - } - - private sealed class PostResetWorkerObservation - { - public bool CallbackPushed { get; set; } = true; - - public string CallbackError { get; set; } = string.Empty; - - public LuaStatus CallbackStatus { get; set; } - - public Exception? Failure { get; set; } - - public bool ReferencePushed { get; set; } = true; - - public nint WorkerState { get; set; } - } - - [System.Runtime.InteropServices.UnmanagedCallersOnly( - CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] - private static int ReturnOne(nint pointer) - { - LuaState state = new(pointer); - try - { - state.PushInteger(1); - return 1; - } - catch (Exception exception) - { - return LuaThunk.Fail(state, exception); - } - } - - private sealed unsafe class RootedCoroutine : IDisposable - { - private readonly LuaRef _root; - - private RootedCoroutine(LuaState state, LuaRef root) - { - State = state; - _root = root; - } - - public LuaState State { get; } - - public void Dispose() - { - if (!LuaRuntime.TryAcquireOperation(out var operation)) - { - _root.Release(default); - return; - } - - using (operation) - { - _root.Release(operation.State); - } - } - - public static RootedCoroutine Create(LuaState main) - { - var status = main.TryExecute("return coroutine.create(function() end)"u8, 1); - Assert.True(status.IsOk); - var pointer = lua_tothread(main.Pointer, -1); - Assert.NotEqual(nint.Zero, (nint)pointer); - LuaState coroutine = new(pointer); - coroutine.Pop(1); - return new RootedCoroutine(coroutine, main.CreateRef()); - } - } + private static unsafe LuaNativeFunction ReturnOneFunction => new(&ReturnOne); + + [Fact] + public void First_worker_acquisition_rejects_a_missing_state_then_uses_a_distinct_coroutine_in_the_same_universe() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + main.PushInteger(719); + Assert.True(main.TrySetGlobal("sdk012_shared_universe"u8).IsOk); + main.PushString("shared private registry"u8); + LuaRef sharedReference = main.CreateRef(); + using RootedCoroutine worker = RootedCoroutine.Create(main); + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + FirstWorkerObservation observation = ObserveFirstWorker(worker, sharedReference); + + Assert.Null(observation.Failure); + Assert.True(observation.FirstAcquisitionWasRejected); + Assert.Equal(2, HostDouble.ProviderCalls); + Assert.NotEqual(main.Handle, observation.WorkerState); + Assert.Equal(identity, observation.WorkerIdentity); + Assert.Equal(719L, observation.GlobalValue); + Assert.Equal("shared private registry", observation.ReferenceValue); + Assert.Equal(0, main.Top); + + sharedReference.Release(main); + } + + [Fact] + public void + State_reset_generation_invalidates_shared_registry_references_and_callbacks_before_a_fresh_worker_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using RootedCoroutine worker = RootedCoroutine.Create(main); + using NativeLuaState replacementState = new(); + LuaState replacementMain = LuaTest.View(replacementState); + using RootedCoroutine replacementWorker = RootedCoroutine.Create(replacementMain); + main.PushString("before reset"u8); + LuaRef reference = main.CreateRef(); + Assert.True( + LuaCallback.TryCreate(main, ReturnOneFunction, new object(), out LuaCallback? callback).IsOk); + Assert.NotNull(callback); + Assert.True(callback.TryRegister(main, "sdk012_reset_callback"u8).IsOk); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + + using (LuaRuntime.BeginStateReset()) + { + HostDouble.SetStateForCurrentThread(replacementWorker.State.Handle); + } + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.True(reference.IsResolved); + Assert.False(reference.IsCurrent); + Assert.True(callback.IsReleased); + Assert.False(callback.IsCurrent); + PostResetWorkerObservation observation = ObservePostResetWorker(replacementWorker, reference, callback); + + Assert.Null(observation.Failure); + Assert.NotEqual(worker.State.Handle, observation.WorkerState); + Assert.Equal(replacementWorker.State.Handle, observation.WorkerState); + Assert.False(observation.ReferencePushed); + Assert.False(observation.CallbackPushed); + Assert.True(observation.CallbackStatus.IsOk); + Assert.Contains("nil value", observation.CallbackError, StringComparison.Ordinal); + + reference.Release(default); + } + + private static FirstWorkerObservation ObserveFirstWorker(RootedCoroutine worker, LuaRef sharedReference) + { + FirstWorkerObservation observation = new(); + Thread thread = new(() => ObserveFirstWorkerCore(worker, sharedReference, observation)); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "The first worker-state acquisition did not return."); + return observation; + } + + private static void ObserveFirstWorkerCore( + RootedCoroutine worker, + LuaRef sharedReference, + FirstWorkerObservation observation) + { + try + { + HostDouble.ClearStateForCurrentThread(); + observation.FirstAcquisitionWasRejected = + !LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation unavailable); + unavailable.Dispose(); + HostDouble.SetStateForCurrentThread(worker.State.Handle); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState workerLua = operation.State; + observation.WorkerState = workerLua.Handle; + observation.WorkerIdentity = LuaRuntime.CurrentStateIdentity; + Assert.True(workerLua.TryGetGlobal("sdk012_shared_universe"u8).IsOk); + Assert.True(workerLua.TryReadInteger(-1, out long globalValue)); + observation.GlobalValue = globalValue; + workerLua.Pop(1); + Assert.True(workerLua.TryPushRef(sharedReference)); + Assert.True(workerLua.TryReadString(-1, out string? referenceValue)); + observation.ReferenceValue = referenceValue; + workerLua.Pop(1); + Assert.Equal(0, workerLua.Top); + } + catch (Exception exception) + { + observation.Failure = exception; + } + } + + private static PostResetWorkerObservation ObservePostResetWorker( + RootedCoroutine worker, + LuaRef reference, + LuaCallback callback) + { + PostResetWorkerObservation observation = new(); + Thread thread = new(() => ObservePostResetWorkerCore(worker, reference, callback, observation)); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "The post-reset worker did not return."); + return observation; + } + + private static void ObservePostResetWorkerCore( + RootedCoroutine worker, + LuaRef reference, + LuaCallback callback, + PostResetWorkerObservation observation) + { + try + { + HostDouble.SetStateForCurrentThread(worker.State.Handle); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState workerLua = operation.State; + observation.WorkerState = workerLua.Handle; + observation.ReferencePushed = workerLua.TryPushRef(reference); + observation.CallbackPushed = callback.TryPush(workerLua); + observation.CallbackStatus = workerLua.TryExecute( + "local ok, err = pcall(sdk012_reset_callback) return ok, err"u8, 2); + if (observation.CallbackStatus.IsOk) + { + Assert.False(workerLua.ToBoolean(-2)); + observation.CallbackError = LuaTest.ReadString(workerLua, -1); + workerLua.Pop(2); + } + + Assert.Equal(0, workerLua.Top); + } + catch (Exception exception) + { + observation.Failure = exception; + } + } + + [UnmanagedCallersOnly( + CallConvs = [typeof(CallConvCdecl)])] + private static int ReturnOne(nint pointer) + { + LuaState state = new(pointer); + try + { + state.PushInteger(1); + return 1; + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + + private sealed class FirstWorkerObservation + { + public Exception? Failure + { + get; + set; + } + + public bool FirstAcquisitionWasRejected + { + get; + set; + } + + public long GlobalValue + { + get; + set; + } + + public string? ReferenceValue + { + get; + set; + } + + public LuaStateIdentity WorkerIdentity + { + get; + set; + } + + public nint WorkerState + { + get; + set; + } + } + + private sealed class PostResetWorkerObservation + { + public bool CallbackPushed + { + get; + set; + } = true; + + public string CallbackError + { + get; + set; + } = string.Empty; + + public LuaStatus CallbackStatus + { + get; + set; + } + + public Exception? Failure + { + get; + set; + } + + public bool ReferencePushed + { + get; + set; + } = true; + + public nint WorkerState + { + get; + set; + } + } + + private sealed unsafe class RootedCoroutine : IDisposable + { + private readonly LuaRef _root; + + private RootedCoroutine(LuaState state, LuaRef root) + { + State = state; + _root = root; + } + + public LuaState State + { + get; + } + + public void Dispose() + { + if (!LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)) + { + _root.Release(default); + return; + } + + using (operation) + { + _root.Release(operation.State); + } + } + + public static RootedCoroutine Create(LuaState main) + { + LuaStatus status = main.TryExecute("return coroutine.create(function() end)"u8, 1); + Assert.True(status.IsOk); + lua_State* pointer = lua_tothread(main.Pointer, -1); + Assert.NotEqual(nint.Zero, (nint) pointer); + LuaState coroutine = new(pointer); + coroutine.Pop(1); + return new RootedCoroutine(coroutine, main.CreateRef()); + } + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/State/LuaFrameTests.cs b/tests/CheatEngine.SDK.Lua.Tests/State/LuaFrameTests.cs index 8a9fa6c3..26df158a 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/State/LuaFrameTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/State/LuaFrameTests.cs @@ -9,103 +9,106 @@ namespace CheatEngine.SDK.Lua.Tests.State; [Trait("Category", "NativeLua")] public sealed class LuaFrameTests { - [Fact] - public void Restores_top_on_dispose() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushInteger(1); - - using (LuaFrame frame = new(L)) - { - Assert.Equal(1, frame.Top); - Assert.Equal(L, frame.State); - L.PushInteger(2); - L.PushInteger(3); - L.PushNil(); - Assert.Equal(3, frame.Count); - Assert.Equal(4, L.Top); - } - - Assert.Equal(1, L.Top); - Assert.True(L.TryReadInteger(1, out var kept)); - Assert.Equal(1, kept); - } - - [Fact] - public void Restores_top_on_early_return() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Assert.False(ReturnsEarly(L)); - - Assert.Equal(0, L.Top); - } - - [Fact] - public void Restores_top_when_an_exception_passes_through() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Assert.Throws(() => Throws(L)); - - Assert.Equal(0, L.Top); - } - - [Fact] - public void Restores_top_after_a_failed_protected_call_left_an_error_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - - using (LuaFrame frame = new(L)) - { - var status = L.TryExecute("error('boom')"u8, 0); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(1, frame.Count); - Assert.Contains("boom", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - } - - Assert.Equal(0, L.Top); - } - - [Fact] - public void Disposing_twice_is_harmless_and_a_balanced_frame_asserts_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - LuaFrame frame = new(L); - L.PushInteger(1); - L.Pop(1); - frame.AssertBalanced(); - - frame.Dispose(); - frame.Dispose(); - - Assert.Equal(0, L.Top); - } - - private static bool ReturnsEarly(LuaState L) - { - using LuaFrame frame = new(L); - L.PushInteger(42); - L.PushString("x"u8); - if (L.Top > 0) return false; - - return true; - } - - private static void Throws(LuaState L) - { - using LuaFrame frame = new(L); - L.PushInteger(42); - throw new InvalidOperationException("managed failure inside a frame"); - } + [Fact] + public void Restores_top_on_dispose() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(1); + + using (LuaFrame frame = new(L)) + { + Assert.Equal(1, frame.Top); + Assert.Equal(L, frame.State); + L.PushInteger(2); + L.PushInteger(3); + L.PushNil(); + Assert.Equal(3, frame.Count); + Assert.Equal(4, L.Top); + } + + Assert.Equal(1, L.Top); + Assert.True(L.TryReadInteger(1, out long kept)); + Assert.Equal(1, kept); + } + + [Fact] + public void Restores_top_on_early_return() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Assert.False(ReturnsEarly(L)); + + Assert.Equal(0, L.Top); + } + + [Fact] + public void Restores_top_when_an_exception_passes_through() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Assert.Throws(() => Throws(L)); + + Assert.Equal(0, L.Top); + } + + [Fact] + public void Restores_top_after_a_failed_protected_call_left_an_error_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + + using (LuaFrame frame = new(L)) + { + LuaStatus status = L.TryExecute("error('boom')"u8, 0); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(1, frame.Count); + Assert.Contains("boom", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + } + + Assert.Equal(0, L.Top); + } + + [Fact] + public void Disposing_twice_is_harmless_and_a_balanced_frame_asserts_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaFrame frame = new(L); + L.PushInteger(1); + L.Pop(1); + frame.AssertBalanced(); + + frame.Dispose(); + frame.Dispose(); + + Assert.Equal(0, L.Top); + } + + private static bool ReturnsEarly(LuaState L) + { + using LuaFrame frame = new(L); + L.PushInteger(42); + L.PushString("x"u8); + if (L.Top > 0) + { + return false; + } + + return true; + } + + private static void Throws(LuaState L) + { + using LuaFrame frame = new(L); + L.PushInteger(42); + throw new InvalidOperationException("managed failure inside a frame"); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStackTests.cs index 7e234686..68076141 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStackTests.cs @@ -10,367 +10,373 @@ namespace CheatEngine.SDK.Lua.Tests.State; [Trait("Category", "NativeLua")] public sealed class LuaStateStackTests { - [Fact] - public void Push_and_type_tests_agree() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - L.PushNil(); - L.PushBoolean(true); - L.PushLightUserdata(0x1234); - L.PushInteger(7); - L.PushNumber(7.5); - L.PushString("s"u8); - L.CreateTable(); - L.PushGlobalTable(); - - Assert.Equal(8, L.Top); - Assert.Equal(LuaType.Nil, L.TypeOf(1)); - Assert.Equal(LuaType.Boolean, L.TypeOf(2)); - Assert.Equal(LuaType.LightUserdata, L.TypeOf(3)); - Assert.Equal(LuaType.Number, L.TypeOf(4)); - Assert.Equal(LuaType.Number, L.TypeOf(5)); - Assert.Equal(LuaType.String, L.TypeOf(6)); - Assert.Equal(LuaType.Table, L.TypeOf(7)); - Assert.Equal(LuaType.Table, L.TypeOf(8)); - Assert.Equal(LuaType.None, L.TypeOf(9)); - - Assert.True(L.IsNil(1)); - Assert.True(L.IsNoneOrNil(1)); - Assert.True(L.IsNoneOrNil(9)); - Assert.True(L.IsNone(9)); - Assert.False(L.IsNone(8)); - Assert.True(L.IsLightUserdata(3)); - Assert.False(L.IsUserdata(3)); - Assert.True(L.IsInteger(4)); - Assert.False(L.IsInteger(5)); - Assert.True(L.IsNumberConvertible(5)); - Assert.False(L.IsNumberConvertible(6)); - Assert.True(L.IsTable(7)); - Assert.False(L.IsFunction(7)); - Assert.Equal(0x1234, L.ToUserdata(3)); - Assert.Equal(0, L.ToUserdata(4)); - Assert.NotEqual(0, L.ToPointer(7)); - Assert.True(L.ToBoolean(2)); - Assert.False(L.ToBoolean(1)); - Assert.True(L.ToBoolean(4)); - } - - [Fact] - public void TypeName_gives_lua_own_names_without_allocating() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushNil(); - L.PushBoolean(false); - L.PushLightUserdata(0); - L.PushInteger(1); - L.PushString("s"u8); - L.CreateTable(); - L.PushUncheckedFunction(Thunks.Add); - L.NewUserdata(1); - - Assert.True(L.TypeName(1).SequenceEqual("nil"u8)); - Assert.True(L.TypeName(2).SequenceEqual("boolean"u8)); - Assert.True(L.TypeName(3).SequenceEqual("userdata"u8)); - Assert.True(L.TypeName(4).SequenceEqual("number"u8)); - Assert.True(L.TypeName(5).SequenceEqual("string"u8)); - Assert.True(L.TypeName(6).SequenceEqual("table"u8)); - Assert.True(L.TypeName(7).SequenceEqual("function"u8)); - Assert.True(L.TypeName(8).SequenceEqual("userdata"u8)); - Assert.True(L.TypeName(9).SequenceEqual("no value"u8)); - Assert.True(L.TypeName(LuaType.Thread).SequenceEqual("thread"u8)); - Assert.True(L.TypeName(LuaType.None).SequenceEqual("no value"u8)); - Assert.Equal(8, L.Top); - - long sink = 0; - AllocationGate.AssertZero(() => sink += L.TypeName(4).Length + L.TypeName(LuaType.Nil).Length); - Assert.NotEqual(0, sink); - } - - [Fact] - public void Insert_remove_replace_copy_rotate_and_absolute_index() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - for (var i = 1; i <= 4; i++) L.PushInteger(i); - - // [1 2 3 4] -> insert top at 1 -> [4 1 2 3] - L.Insert(1); - Assert.Equal(4, Read(L, 1)); - Assert.Equal(3, Read(L, 4)); - - // remove index 2 (the 1) -> [4 2 3] - L.Remove(2); - Assert.Equal(3, L.Top); - Assert.Equal(2, Read(L, 2)); - - // push 9, replace index 1 -> [9 2 3] - L.PushInteger(9); - L.Replace(1); - Assert.Equal(3, L.Top); - Assert.Equal(9, Read(L, 1)); - - // copy 1 -> 3 : [9 2 9] - L.Copy(1, 3); - Assert.Equal(9, Read(L, 3)); - - // rotate whole stack by 1 : [9 9 2] - L.Rotate(1, 1); - Assert.Equal(9, Read(L, 1)); - Assert.Equal(2, Read(L, 3)); - - Assert.Equal(3, L.AbsoluteIndex(-1)); - Assert.Equal(1, L.AbsoluteIndex(-3)); - Assert.Equal(LuaState.RegistryIndex, L.AbsoluteIndex(LuaState.RegistryIndex)); - - L.PushValue(1); - Assert.Equal(4, L.Top); - Assert.Equal(9, Read(L, 4)); - L.Pop(4); - Assert.Equal(0, L.Top); - L.SetTop(2); - Assert.Equal(2, L.Top); - Assert.True(L.IsNil(1)); - } - - [Fact] - public void EnsureStack_grows_within_limits_and_refuses_absurd_requests() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - Assert.True(L.TryEnsureStack(10_000)); - Assert.False(L.TryEnsureStack(int.MaxValue)); - } - - [Fact] - public void PushUncheckedFunction_reserves_the_light_C_function_slot_before_each_push() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var L = LuaTest.View(state); - var initialTop = L.Top; - const int functionCount = 64; - - // CE's pinned lapi.c makes lua_pushcclosure(..., 0) a light C function: it only writes one already-reserved - // stack slot. Do not reserve here: every production call must make the immediate lua_checkstack(L, 1) reservation - // itself before it takes the direct fast path. Crossing the initial free-slot boundary proves the method retains - // that precondition instead of relying on a caller's incidental reservation. - for (var i = 0; i < functionCount; i++) - { - L.PushUncheckedFunction(Thunks.Add); - Assert.Equal(initialTop + i + 1, L.Top); - Assert.Equal(LuaType.Function, L.TypeOf(-1)); - } - - L.SetTop(initialTop); - Assert.Equal(initialTop, L.Top); - } - - [Fact] - public void Raw_table_access_bypasses_metamethods() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, "return setmetatable({ real = 1, 10, 20 }, { __index = function() error('never') end })"u8, 1); - - Assert.Equal(LuaType.Number, L.RawGetIndex(1, 2)); - Assert.Equal(20, Read(L, -1)); - L.Pop(1); - - L.PushString("real"u8); - Assert.Equal(LuaType.Number, L.RawGet(1)); - Assert.Equal(1, Read(L, -1)); - L.Pop(1); - - L.PushString("missing"u8); - Assert.Equal(LuaType.Nil, L.RawGet(1)); - L.Pop(1); - - L.PushInteger(30); - L.RawSetIndex(1, 3); - Assert.Equal((nuint)3, L.RawLength(1)); - - L.PushString("key"u8); - L.PushBoolean(true); - Assert.True(L.TryRawSet(1)); - L.PushString("key"u8); - Assert.Equal(LuaType.Boolean, L.RawGet(1)); - L.Pop(1); - - L.PushInteger(99); - L.RawSetPointer(1, 0x77); - Assert.Equal(LuaType.Number, L.RawGetPointer(1, 0x77)); - Assert.Equal(LuaType.Nil, L.RawGetPointer(1, 0x78)); - L.Pop(2); - - Assert.True(L.TryGetMetatable(1)); - Assert.True(L.IsTable(-1)); - L.Pop(1); - L.PushNil(); - L.SetMetatable(1); - Assert.False(L.TryGetMetatable(1)); - Assert.Equal(1, L.Top); - } - - [Fact] - public void PushByteTable_creates_an_ordered_one_based_byte_sequence_in_one_stack_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(openLibraries: false); - var L = LuaTest.View(state); - ReadOnlySpan bytes = [0, 1, 127, byte.MaxValue]; - - L.PushByteTable(bytes); - - Assert.Equal(1, L.Top); - Assert.True(L.IsTable(-1)); - Assert.Equal((nuint)bytes.Length, L.RawLength(-1)); - for (var index = 0; index < bytes.Length; index++) - { - Assert.Equal(LuaType.Number, L.RawGetIndex(-1, index + 1L)); - Assert.True(L.TryReadInteger(-1, out var value)); - Assert.Equal(bytes[index], value); - L.Pop(1); - } - - L.Pop(1); - L.PushByteTable([]); - Assert.Equal(1, L.Top); - Assert.True(L.IsTable(-1)); - Assert.Equal((nuint)0, L.RawLength(-1)); - } - - [Fact] - public void TryNext_walks_a_table_and_raw_equality_is_primitive() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); // TryNext uses the base library's next, like TryToString uses tostring - var L = LuaTest.View(state); - L.CreateTable(3); - for (var i = 1; i <= 3; i++) - { - L.PushInteger(i * 10); - L.RawSetIndex(1, i); - } - - long sum = 0; - var count = 0; - L.PushNil(); - while (true) - { - Assert.True(L.TryNext(1, out var hasNext).IsOk); - if (!hasNext) break; - - Assert.True(L.TryReadInteger(-1, out var value)); - sum += value; - count++; - L.Pop(1); - } - - Assert.Equal(3, count); - Assert.Equal(60, sum); - Assert.Equal(1, L.Top); - - L.PushInteger(5); - L.PushInteger(5); - L.PushNumber(5.0); - Assert.True(L.RawEquals(2, 3)); - Assert.True(L.RawEquals(2, 4)); - Assert.False(L.RawEquals(1, 2)); - } - - [Fact] - public void TryNext_with_a_key_that_is_not_in_the_table_is_a_runtime_error_not_a_raise() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, "return { a = 1 }"u8, 1); - - // lua_next raises "invalid key to 'next'" for a key the table never had; the protected form reports it. - L.PushString("never"u8); - var status = L.TryNext(1, out var hasNext); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.False(hasNext); - Assert.Equal(2, L.Top); - Assert.Contains("next", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - // A key that is in the table, given as a relative index below the key. - L.PushString("a"u8); - Assert.True(L.TryNext(-2, out hasNext).IsOk); - Assert.False(hasNext); - Assert.Equal(1, L.Top); - } - - [Fact] - public void TryRawSet_refuses_nil_and_nan_keys_instead_of_letting_lua_raise() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.CreateTable(); - - L.PushNil(); - L.PushInteger(1); - Assert.False(L.TryRawSet(1)); - Assert.Equal(1, L.Top); - - L.PushNumber(double.NaN); - L.PushInteger(1); - Assert.False(L.TryRawSet(1)); - Assert.Equal(1, L.Top); - - // A float key with an integral value is normalised by Lua and is fine; so is any other key. - L.PushNumber(2.0); - L.PushInteger(20); - Assert.True(L.TryRawSet(1)); - L.PushNumber(2.5); - L.PushInteger(25); - Assert.True(L.TryRawSet(1)); - L.PushBoolean(true); - L.PushInteger(1); - Assert.True(L.TryRawSet(1)); - - Assert.Equal(LuaType.Number, L.RawGetIndex(1, 2)); - Assert.True(L.TryReadInteger(-1, out var two)); - Assert.Equal(20, two); - L.PushNumber(2.5); - Assert.Equal(LuaType.Number, L.RawGet(1)); - L.PushBoolean(true); - Assert.Equal(LuaType.Number, L.RawGet(1)); - L.PushNil(); - Assert.Equal(LuaType.Nil, L.RawGet(1)); - Assert.Equal(5, L.Top); - } - - [Fact] - public void NewUserdata_gives_a_stable_block_owned_by_lua() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - var block = L.NewUserdata(16); - - Assert.NotEqual(0, block); - Assert.True(L.IsUserdata(-1)); - Assert.Equal(block, L.ToUserdata(-1)); - Assert.Equal((nuint)16, L.RawLength(-1)); - } - - private static long Read(LuaState L, int index) - { - Assert.True(L.TryReadInteger(index, out var value)); - return value; - } + [Fact] + public void Push_and_type_tests_agree() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + L.PushNil(); + L.PushBoolean(true); + L.PushLightUserdata(0x1234); + L.PushInteger(7); + L.PushNumber(7.5); + L.PushString("s"u8); + L.CreateTable(); + L.PushGlobalTable(); + + Assert.Equal(8, L.Top); + Assert.Equal(LuaType.Nil, L.TypeOf(1)); + Assert.Equal(LuaType.Boolean, L.TypeOf(2)); + Assert.Equal(LuaType.LightUserdata, L.TypeOf(3)); + Assert.Equal(LuaType.Number, L.TypeOf(4)); + Assert.Equal(LuaType.Number, L.TypeOf(5)); + Assert.Equal(LuaType.String, L.TypeOf(6)); + Assert.Equal(LuaType.Table, L.TypeOf(7)); + Assert.Equal(LuaType.Table, L.TypeOf(8)); + Assert.Equal(LuaType.None, L.TypeOf(9)); + + Assert.True(L.IsNil(1)); + Assert.True(L.IsNoneOrNil(1)); + Assert.True(L.IsNoneOrNil(9)); + Assert.True(L.IsNone(9)); + Assert.False(L.IsNone(8)); + Assert.True(L.IsLightUserdata(3)); + Assert.False(L.IsUserdata(3)); + Assert.True(L.IsInteger(4)); + Assert.False(L.IsInteger(5)); + Assert.True(L.IsNumberConvertible(5)); + Assert.False(L.IsNumberConvertible(6)); + Assert.True(L.IsTable(7)); + Assert.False(L.IsFunction(7)); + Assert.Equal(0x1234, L.ToUserdata(3)); + Assert.Equal(0, L.ToUserdata(4)); + Assert.NotEqual(0, L.ToPointer(7)); + Assert.True(L.ToBoolean(2)); + Assert.False(L.ToBoolean(1)); + Assert.True(L.ToBoolean(4)); + } + + [Fact] + public void TypeName_gives_lua_own_names_without_allocating() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushNil(); + L.PushBoolean(false); + L.PushLightUserdata(0); + L.PushInteger(1); + L.PushString("s"u8); + L.CreateTable(); + L.PushUncheckedFunction(Thunks.Add); + L.NewUserdata(1); + + Assert.True(L.TypeName(1).SequenceEqual("nil"u8)); + Assert.True(L.TypeName(2).SequenceEqual("boolean"u8)); + Assert.True(L.TypeName(3).SequenceEqual("userdata"u8)); + Assert.True(L.TypeName(4).SequenceEqual("number"u8)); + Assert.True(L.TypeName(5).SequenceEqual("string"u8)); + Assert.True(L.TypeName(6).SequenceEqual("table"u8)); + Assert.True(L.TypeName(7).SequenceEqual("function"u8)); + Assert.True(L.TypeName(8).SequenceEqual("userdata"u8)); + Assert.True(L.TypeName(9).SequenceEqual("no value"u8)); + Assert.True(L.TypeName(LuaType.Thread).SequenceEqual("thread"u8)); + Assert.True(L.TypeName(LuaType.None).SequenceEqual("no value"u8)); + Assert.Equal(8, L.Top); + + long sink = 0; + AllocationGate.AssertZero(() => sink += L.TypeName(4).Length + L.TypeName(LuaType.Nil).Length); + Assert.NotEqual(0, sink); + } + + [Fact] + public void Insert_remove_replace_copy_rotate_and_absolute_index() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + for (int i = 1; i <= 4; i++) + { + L.PushInteger(i); + } + + // [1 2 3 4] -> insert top at 1 -> [4 1 2 3] + L.Insert(1); + Assert.Equal(4, Read(L, 1)); + Assert.Equal(3, Read(L, 4)); + + // remove index 2 (the 1) -> [4 2 3] + L.Remove(2); + Assert.Equal(3, L.Top); + Assert.Equal(2, Read(L, 2)); + + // push 9, replace index 1 -> [9 2 3] + L.PushInteger(9); + L.Replace(1); + Assert.Equal(3, L.Top); + Assert.Equal(9, Read(L, 1)); + + // copy 1 -> 3 : [9 2 9] + L.Copy(1, 3); + Assert.Equal(9, Read(L, 3)); + + // rotate whole stack by 1 : [9 9 2] + L.Rotate(1, 1); + Assert.Equal(9, Read(L, 1)); + Assert.Equal(2, Read(L, 3)); + + Assert.Equal(3, L.AbsoluteIndex(-1)); + Assert.Equal(1, L.AbsoluteIndex(-3)); + Assert.Equal(LuaState.RegistryIndex, L.AbsoluteIndex(LuaState.RegistryIndex)); + + L.PushValue(1); + Assert.Equal(4, L.Top); + Assert.Equal(9, Read(L, 4)); + L.Pop(4); + Assert.Equal(0, L.Top); + L.SetTop(2); + Assert.Equal(2, L.Top); + Assert.True(L.IsNil(1)); + } + + [Fact] + public void EnsureStack_grows_within_limits_and_refuses_absurd_requests() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + Assert.True(L.TryEnsureStack(10_000)); + Assert.False(L.TryEnsureStack(int.MaxValue)); + } + + [Fact] + public void PushUncheckedFunction_reserves_the_light_C_function_slot_before_each_push() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + int initialTop = L.Top; + const int functionCount = 64; + + // CE's pinned lapi.c makes lua_pushcclosure(..., 0) a light C function: it only writes one already-reserved + // stack slot. Do not reserve here: every production call must make the immediate lua_checkstack(L, 1) reservation + // itself before it takes the direct fast path. Crossing the initial free-slot boundary proves the method retains + // that precondition instead of relying on a caller's incidental reservation. + for (int i = 0; i < functionCount; i++) + { + L.PushUncheckedFunction(Thunks.Add); + Assert.Equal(initialTop + i + 1, L.Top); + Assert.Equal(LuaType.Function, L.TypeOf(-1)); + } + + L.SetTop(initialTop); + Assert.Equal(initialTop, L.Top); + } + + [Fact] + public void Raw_table_access_bypasses_metamethods() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return setmetatable({ real = 1, 10, 20 }, { __index = function() error('never') end })"u8, 1); + + Assert.Equal(LuaType.Number, L.RawGetIndex(1, 2)); + Assert.Equal(20, Read(L, -1)); + L.Pop(1); + + L.PushString("real"u8); + Assert.Equal(LuaType.Number, L.RawGet(1)); + Assert.Equal(1, Read(L, -1)); + L.Pop(1); + + L.PushString("missing"u8); + Assert.Equal(LuaType.Nil, L.RawGet(1)); + L.Pop(1); + + L.PushInteger(30); + L.RawSetIndex(1, 3); + Assert.Equal((nuint) 3, L.RawLength(1)); + + L.PushString("key"u8); + L.PushBoolean(true); + Assert.True(L.TryRawSet(1)); + L.PushString("key"u8); + Assert.Equal(LuaType.Boolean, L.RawGet(1)); + L.Pop(1); + + L.PushInteger(99); + L.RawSetPointer(1, 0x77); + Assert.Equal(LuaType.Number, L.RawGetPointer(1, 0x77)); + Assert.Equal(LuaType.Nil, L.RawGetPointer(1, 0x78)); + L.Pop(2); + + Assert.True(L.TryGetMetatable(1)); + Assert.True(L.IsTable(-1)); + L.Pop(1); + L.PushNil(); + L.SetMetatable(1); + Assert.False(L.TryGetMetatable(1)); + Assert.Equal(1, L.Top); + } + + [Fact] + public void PushByteTable_creates_an_ordered_one_based_byte_sequence_in_one_stack_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + ReadOnlySpan bytes = [0, 1, 127, byte.MaxValue]; + + L.PushByteTable(bytes); + + Assert.Equal(1, L.Top); + Assert.True(L.IsTable(-1)); + Assert.Equal((nuint) bytes.Length, L.RawLength(-1)); + for (int index = 0; index < bytes.Length; index++) + { + Assert.Equal(LuaType.Number, L.RawGetIndex(-1, index + 1L)); + Assert.True(L.TryReadInteger(-1, out long value)); + Assert.Equal(bytes[index], value); + L.Pop(1); + } + + L.Pop(1); + L.PushByteTable([]); + Assert.Equal(1, L.Top); + Assert.True(L.IsTable(-1)); + Assert.Equal((nuint) 0, L.RawLength(-1)); + } + + [Fact] + public void TryNext_walks_a_table_and_raw_equality_is_primitive() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); // TryNext uses the base library's next, like TryToString uses tostring + LuaState L = LuaTest.View(state); + L.CreateTable(3); + for (int i = 1; i <= 3; i++) + { + L.PushInteger(i * 10); + L.RawSetIndex(1, i); + } + + long sum = 0; + int count = 0; + L.PushNil(); + while (true) + { + Assert.True(L.TryNext(1, out bool hasNext).IsOk); + if (!hasNext) + { + break; + } + + Assert.True(L.TryReadInteger(-1, out long value)); + sum += value; + count++; + L.Pop(1); + } + + Assert.Equal(3, count); + Assert.Equal(60, sum); + Assert.Equal(1, L.Top); + + L.PushInteger(5); + L.PushInteger(5); + L.PushNumber(5.0); + Assert.True(L.RawEquals(2, 3)); + Assert.True(L.RawEquals(2, 4)); + Assert.False(L.RawEquals(1, 2)); + } + + [Fact] + public void TryNext_with_a_key_that_is_not_in_the_table_is_a_runtime_error_not_a_raise() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return { a = 1 }"u8, 1); + + // lua_next raises "invalid key to 'next'" for a key the table never had; the protected form reports it. + L.PushString("never"u8); + LuaStatus status = L.TryNext(1, out bool hasNext); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.False(hasNext); + Assert.Equal(2, L.Top); + Assert.Contains("next", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + // A key that is in the table, given as a relative index below the key. + L.PushString("a"u8); + Assert.True(L.TryNext(-2, out hasNext).IsOk); + Assert.False(hasNext); + Assert.Equal(1, L.Top); + } + + [Fact] + public void TryRawSet_refuses_nil_and_nan_keys_instead_of_letting_lua_raise() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.CreateTable(); + + L.PushNil(); + L.PushInteger(1); + Assert.False(L.TryRawSet(1)); + Assert.Equal(1, L.Top); + + L.PushNumber(double.NaN); + L.PushInteger(1); + Assert.False(L.TryRawSet(1)); + Assert.Equal(1, L.Top); + + // A float key with an integral value is normalised by Lua and is fine; so is any other key. + L.PushNumber(2.0); + L.PushInteger(20); + Assert.True(L.TryRawSet(1)); + L.PushNumber(2.5); + L.PushInteger(25); + Assert.True(L.TryRawSet(1)); + L.PushBoolean(true); + L.PushInteger(1); + Assert.True(L.TryRawSet(1)); + + Assert.Equal(LuaType.Number, L.RawGetIndex(1, 2)); + Assert.True(L.TryReadInteger(-1, out long two)); + Assert.Equal(20, two); + L.PushNumber(2.5); + Assert.Equal(LuaType.Number, L.RawGet(1)); + L.PushBoolean(true); + Assert.Equal(LuaType.Number, L.RawGet(1)); + L.PushNil(); + Assert.Equal(LuaType.Nil, L.RawGet(1)); + Assert.Equal(5, L.Top); + } + + [Fact] + public void NewUserdata_gives_a_stable_block_owned_by_lua() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + IntPtr block = L.NewUserdata(16); + + Assert.NotEqual(0, block); + Assert.True(L.IsUserdata(-1)); + Assert.Equal(block, L.ToUserdata(-1)); + Assert.Equal((nuint) 16, L.RawLength(-1)); + } + + private static long Read(LuaState L, int index) + { + Assert.True(L.TryReadInteger(index, out long value)); + return value; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStringTests.cs b/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStringTests.cs index 473efd23..eb7ada9a 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStringTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/State/LuaStateStringTests.cs @@ -1,4 +1,6 @@ using System.Text; + +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Lua.Tests.Support; @@ -10,142 +12,142 @@ namespace CheatEngine.SDK.Lua.Tests.State; [Trait("Category", "NativeLua")] public sealed class LuaStateStringTests { - [Fact] - public void Utf8_bytes_with_embedded_nul_round_trip_exactly() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - var bytes = "ab\0cd\0"u8; - - Utf8Marshaller.Push(L, bytes); - - Assert.True(Utf8Marshaller.TryRead(L, -1, out var read)); - Assert.True(read.SequenceEqual(bytes)); - Assert.Equal((nuint)6, L.RawLength(-1)); - Assert.True(L.TryReadString(-1, out var text)); - Assert.Equal("ab\0cd\0", text); - } - - [Fact] - public void Non_ascii_text_round_trips_through_utf8() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - const string Text = "Cheat Engine \u00E9\u00E8 \u20AC \u4F60\u597D \uD83D\uDE00"; - - StringMarshaller.Push(L, Text); - - Assert.True(L.TryReadUtf8(-1, out var utf8)); - Assert.True(utf8.SequenceEqual(Encoding.UTF8.GetBytes(Text))); - Assert.True(StringMarshaller.TryRead(L, -1, out var read)); - Assert.Equal(Text, read); - } - - [Fact] - public void Lua_sees_the_bytes_the_sdk_pushed() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, "return function(s) return #s, s:byte(1), s:byte(-1) end"u8, 1); - - L.PushString("\u00E9x"); - var status = L.TryCall(1, 3); - - Assert.True(status.IsOk); - Assert.True(L.TryReadInteger(-3, out var length)); - Assert.Equal(3, length); - Assert.True(L.TryReadInteger(-2, out var first)); - Assert.Equal(0xC3, first); - Assert.True(L.TryReadInteger(-1, out var last)); - Assert.Equal('x', last); - } - - [Fact] - public void A_string_longer_than_the_stack_buffer_is_pushed_through_the_pool() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - var text = string.Concat(Enumerable.Repeat("\u00E9\u20AC-", 1_000)); - - L.PushString(text); - - Assert.Equal((nuint)Encoding.UTF8.GetByteCount(text), L.RawLength(-1)); - Assert.True(L.TryReadString(-1, out var read)); - Assert.Equal(text, read); - } - - [Fact] - public void Lone_surrogates_are_replaced_and_never_throw() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - L.PushString("a\uD800b"); - - Assert.True(L.TryReadUtf8(-1, out var utf8)); - Assert.True(utf8.SequenceEqual(new byte[] { 0x61, 0xEF, 0xBF, 0xBD, 0x62 })); - } - - [Fact] - public void Empty_strings_round_trip() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - L.PushString(ReadOnlySpan.Empty); - L.PushString(string.Empty); - - Assert.Equal(LuaType.String, L.TypeOf(1)); - Assert.Equal(LuaType.String, L.TypeOf(2)); - Assert.True(L.TryReadUtf8(1, out var a)); - Assert.True(a.IsEmpty); - Assert.True(L.TryReadString(2, out var b)); - Assert.Equal(string.Empty, b); - } - - [Fact] - public void String_reads_are_strict_a_number_is_not_converted_in_place() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushInteger(42); - - Assert.False(L.TryReadUtf8(-1, out var utf8)); - Assert.True(utf8.IsEmpty); - Assert.False(L.TryReadString(-1, out var text)); - Assert.Null(text); - Assert.Equal(LuaType.Number, L.TypeOf(-1)); - } - - [Fact] - public void Null_string_is_pushed_as_nil() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - StringMarshaller.Push(L, null); - - Assert.True(L.IsNil(-1)); - } - - [Fact] - public void Invalid_utf8_from_lua_decodes_with_replacement_characters() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - L.PushString(new byte[] { 0x61, 0xFF, 0x62 }); - - Assert.True(L.TryReadString(-1, out var text)); - Assert.Equal("a\uFFFDb", text); - } + [Fact] + public void Utf8_bytes_with_embedded_nul_round_trip_exactly() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + ReadOnlySpan bytes = "ab\0cd\0"u8; + + Utf8Marshaller.Push(L, bytes); + + Assert.True(Utf8Marshaller.TryRead(L, -1, out ReadOnlySpan read)); + Assert.True(read.SequenceEqual(bytes)); + Assert.Equal((nuint) 6, L.RawLength(-1)); + Assert.True(L.TryReadString(-1, out string? text)); + Assert.Equal("ab\0cd\0", text); + } + + [Fact] + public void Non_ascii_text_round_trips_through_utf8() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + const string Text = "Cheat Engine \u00E9\u00E8 \u20AC \u4F60\u597D \uD83D\uDE00"; + + StringMarshaller.Push(L, Text); + + Assert.True(L.TryReadUtf8(-1, out ReadOnlySpan utf8)); + Assert.True(utf8.SequenceEqual(Encoding.UTF8.GetBytes(Text))); + Assert.True(StringMarshaller.TryRead(L, -1, out string? read)); + Assert.Equal(Text, read); + } + + [Fact] + public void Lua_sees_the_bytes_the_sdk_pushed() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return function(s) return #s, s:byte(1), s:byte(-1) end"u8, 1); + + L.PushString("\u00E9x"); + LuaStatus status = L.TryCall(1, 3); + + Assert.True(status.IsOk); + Assert.True(L.TryReadInteger(-3, out long length)); + Assert.Equal(3, length); + Assert.True(L.TryReadInteger(-2, out long first)); + Assert.Equal(0xC3, first); + Assert.True(L.TryReadInteger(-1, out long last)); + Assert.Equal('x', last); + } + + [Fact] + public void A_string_longer_than_the_stack_buffer_is_pushed_through_the_pool() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + string text = string.Concat(Enumerable.Repeat("\u00E9\u20AC-", 1_000)); + + L.PushString(text); + + Assert.Equal((nuint) Encoding.UTF8.GetByteCount(text), L.RawLength(-1)); + Assert.True(L.TryReadString(-1, out string? read)); + Assert.Equal(text, read); + } + + [Fact] + public void Lone_surrogates_are_replaced_and_never_throw() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + L.PushString("a\uD800b"); + + Assert.True(L.TryReadUtf8(-1, out ReadOnlySpan utf8)); + Assert.True(utf8.SequenceEqual(new byte[] { 0x61, 0xEF, 0xBF, 0xBD, 0x62 })); + } + + [Fact] + public void Empty_strings_round_trip() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + L.PushString(ReadOnlySpan.Empty); + L.PushString(string.Empty); + + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(LuaType.String, L.TypeOf(2)); + Assert.True(L.TryReadUtf8(1, out ReadOnlySpan a)); + Assert.True(a.IsEmpty); + Assert.True(L.TryReadString(2, out string? b)); + Assert.Equal(string.Empty, b); + } + + [Fact] + public void String_reads_are_strict_a_number_is_not_converted_in_place() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(42); + + Assert.False(L.TryReadUtf8(-1, out ReadOnlySpan utf8)); + Assert.True(utf8.IsEmpty); + Assert.False(L.TryReadString(-1, out string? text)); + Assert.Null(text); + Assert.Equal(LuaType.Number, L.TypeOf(-1)); + } + + [Fact] + public void Null_string_is_pushed_as_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + StringMarshaller.Push(L, null); + + Assert.True(L.IsNil(-1)); + } + + [Fact] + public void Invalid_utf8_from_lua_decodes_with_replacement_characters() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + L.PushString(new byte[] { 0x61, 0xFF, 0x62 }); + + Assert.True(L.TryReadString(-1, out string? text)); + Assert.Equal("a\uFFFDb", text); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/State/LuaTypeTests.cs b/tests/CheatEngine.SDK.Lua.Tests/State/LuaTypeTests.cs index 0d7f5064..140e0009 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/State/LuaTypeTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/State/LuaTypeTests.cs @@ -10,60 +10,60 @@ namespace CheatEngine.SDK.Lua.Tests.State; /// public sealed class LuaTypeTests { - [Fact] - public void Type_tags_match_the_c_api() - { - Assert.Equal(LuaApi.LUA_TNONE, (int)LuaType.None); - Assert.Equal(LuaApi.LUA_TNIL, (int)LuaType.Nil); - Assert.Equal(LuaApi.LUA_TBOOLEAN, (int)LuaType.Boolean); - Assert.Equal(LuaApi.LUA_TLIGHTUSERDATA, (int)LuaType.LightUserdata); - Assert.Equal(LuaApi.LUA_TNUMBER, (int)LuaType.Number); - Assert.Equal(LuaApi.LUA_TSTRING, (int)LuaType.String); - Assert.Equal(LuaApi.LUA_TTABLE, (int)LuaType.Table); - Assert.Equal(LuaApi.LUA_TFUNCTION, (int)LuaType.Function); - Assert.Equal(LuaApi.LUA_TUSERDATA, (int)LuaType.Userdata); - Assert.Equal(LuaApi.LUA_TTHREAD, (int)LuaType.Thread); - } + [Fact] + public void Type_tags_match_the_c_api() + { + Assert.Equal(LuaApi.LUA_TNONE, (int) LuaType.None); + Assert.Equal(LuaApi.LUA_TNIL, (int) LuaType.Nil); + Assert.Equal(LuaApi.LUA_TBOOLEAN, (int) LuaType.Boolean); + Assert.Equal(LuaApi.LUA_TLIGHTUSERDATA, (int) LuaType.LightUserdata); + Assert.Equal(LuaApi.LUA_TNUMBER, (int) LuaType.Number); + Assert.Equal(LuaApi.LUA_TSTRING, (int) LuaType.String); + Assert.Equal(LuaApi.LUA_TTABLE, (int) LuaType.Table); + Assert.Equal(LuaApi.LUA_TFUNCTION, (int) LuaType.Function); + Assert.Equal(LuaApi.LUA_TUSERDATA, (int) LuaType.Userdata); + Assert.Equal(LuaApi.LUA_TTHREAD, (int) LuaType.Thread); + } - [Fact] - public void Constants_match_the_c_api() - { - Assert.Equal(LuaApi.LUA_MULTRET, LuaState.MultipleResults); - Assert.Equal(LuaApi.LUA_MINSTACK, LuaState.MinimumFreeSlots); - Assert.Equal(LuaApi.LUA_REGISTRYINDEX, LuaState.RegistryIndex); - Assert.Equal(-1001000, LuaState.RegistryIndex); - } + [Fact] + public void Constants_match_the_c_api() + { + Assert.Equal(LuaApi.LUA_MULTRET, LuaState.MultipleResults); + Assert.Equal(LuaApi.LUA_MINSTACK, LuaState.MinimumFreeSlots); + Assert.Equal(LuaApi.LUA_REGISTRYINDEX, LuaState.RegistryIndex); + Assert.Equal(-1001000, LuaState.RegistryIndex); + } - [Fact] - public void Comparison_values_match_the_c_api() - { - Assert.Equal(LuaApi.LUA_OPEQ, (int)LuaComparison.Equal); - Assert.Equal(LuaApi.LUA_OPLT, (int)LuaComparison.Less); - Assert.Equal(LuaApi.LUA_OPLE, (int)LuaComparison.LessOrEqual); - } + [Fact] + public void Comparison_values_match_the_c_api() + { + Assert.Equal(LuaApi.LUA_OPEQ, (int) LuaComparison.Equal); + Assert.Equal(LuaApi.LUA_OPLT, (int) LuaComparison.Less); + Assert.Equal(LuaApi.LUA_OPLE, (int) LuaComparison.LessOrEqual); + } - [Fact] - public void Default_state_view_is_null_and_states_compare_by_pointer() - { - LuaState none = default; - Assert.True(none.IsNull); - Assert.Equal(0, none.Handle); - Assert.Equal(new LuaState(0), none); - LuaState first = new(0x1000); - LuaState second = new(0x1000); - Assert.True(first == second); - Assert.True(first != new LuaState(0x2000)); - Assert.Equal(new LuaState(0x1000).GetHashCode(), new LuaState(0x1000).GetHashCode()); - Assert.Equal("lua_State@0x1000", new LuaState(0x1000).ToString()); - } + [Fact] + public void Default_state_view_is_null_and_states_compare_by_pointer() + { + LuaState none = default; + Assert.True(none.IsNull); + Assert.Equal(0, none.Handle); + Assert.Equal(new LuaState(0), none); + LuaState first = new(0x1000); + LuaState second = new(0x1000); + Assert.True(first == second); + Assert.True(first != new LuaState(0x2000)); + Assert.Equal(new LuaState(0x1000).GetHashCode(), new LuaState(0x1000).GetHashCode()); + Assert.Equal("lua_State@0x1000", new LuaState(0x1000).ToString()); + } - [Fact] - public void Relative_indices_shift_absolute_and_pseudo_indices_do_not() - { - Assert.Equal(-3, LuaState.Shift(-1, 2)); - Assert.Equal(-2, LuaState.Shift(-1, 1)); - Assert.Equal(3, LuaState.Shift(3, 2)); - Assert.Equal(LuaApi.LUA_REGISTRYINDEX, LuaState.Shift(LuaApi.LUA_REGISTRYINDEX, 2)); - Assert.Equal(LuaApi.lua_upvalueindex(1), LuaState.Shift(LuaApi.lua_upvalueindex(1), 2)); - } + [Fact] + public void Relative_indices_shift_absolute_and_pseudo_indices_do_not() + { + Assert.Equal(-3, LuaState.Shift(-1, 2)); + Assert.Equal(-2, LuaState.Shift(-1, 1)); + Assert.Equal(3, LuaState.Shift(3, 2)); + Assert.Equal(LuaApi.LUA_REGISTRYINDEX, LuaState.Shift(LuaApi.LUA_REGISTRYINDEX, 2)); + Assert.Equal(LuaApi.lua_upvalueindex(1), LuaState.Shift(LuaApi.lua_upvalueindex(1), 2)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/State/ProtectedOperationTests.cs b/tests/CheatEngine.SDK.Lua.Tests/State/ProtectedOperationTests.cs index 6c983ae1..e8367be7 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/State/ProtectedOperationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/State/ProtectedOperationTests.cs @@ -12,361 +12,361 @@ namespace CheatEngine.SDK.Lua.Tests.State; [Trait("Category", "NativeLua")] public sealed class ProtectedOperationTests { - private static ReadOnlySpan RaisingTable => """ - local t = setmetatable({}, { - __index = function(_, k) error('index raised for ' .. tostring(k)) end, - __newindex = function(_, k) error('newindex raised for ' .. tostring(k)) end, - __len = function() error('len raised') end, - __tostring = function() error('tostring raised') end, - __eq = function() error('eq raised') end, - __lt = function() error('lt raised') end, - }) - return t - """u8; - - [Fact] - public void Globals_are_read_and_written_under_protection() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - L.PushInteger(42); - Assert.True(L.TrySetGlobal("answer"u8).IsOk); - Assert.Equal(0, L.Top); - - Assert.True(L.TryGetGlobal("answer"u8).IsOk); - Assert.True(L.TryReadInteger(-1, out var value)); - Assert.Equal(42, value); - - Assert.True(L.TryGetGlobal("undefined"u8).IsOk); - Assert.True(L.IsNil(-1)); - Assert.Equal(2, L.Top); - } - - [Fact] - public void A_raising_index_metamethod_on_the_globals_table_becomes_a_status() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, - "setmetatable(_G, { __index = function(_, k) error('no global ' .. k) end, __newindex = function(_, k) error('read-only ' .. k) end })"u8); - - var getStatus = L.TryGetGlobal("missing"u8); - Assert.Equal(LuaStatus.RuntimeError, getStatus); - Assert.Equal(1, L.Top); - Assert.Contains("no global missing", LuaError.FromStack(L, getStatus).Message, StringComparison.Ordinal); - L.Pop(1); - - L.PushInteger(1); - var setStatus = L.TrySetGlobal("newGlobal"u8); - Assert.Equal(LuaStatus.RuntimeError, setStatus); - Assert.Equal(1, L.Top); - Assert.Contains("read-only newGlobal", LuaError.FromStack(L, setStatus).Message, StringComparison.Ordinal); - } - - [Fact] - public void Fields_are_read_and_written_through_metamethods_under_protection() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, - "return setmetatable({}, { __index = function(_, k) return 'got ' .. k end, __newindex = function(t, k, v) rawset(t, k, v * 2) end })"u8, - 1); - - Assert.True(L.TryGetField(1, "name"u8).IsOk); - Assert.Equal("got name", LuaTest.ReadString(L, -1)); - L.Pop(1); - - // Relative index, with the value already on top. - L.PushInteger(21); - Assert.True(L.TrySetField(-2, "doubled"u8).IsOk); - Assert.Equal(1, L.Top); - L.PushString("doubled"u8); - Assert.Equal(LuaType.Number, L.RawGet(1)); - Assert.True(L.TryReadInteger(-1, out var doubled)); - Assert.Equal(42, doubled); - L.Pop(1); - - Assert.True(L.TryGetField(-1, "other"u8).IsOk); - Assert.Equal("got other", LuaTest.ReadString(L, -1)); - Assert.Equal(2, L.Top); - } - - [Fact] - public void A_table_whose_index_raises_surfaces_as_a_status_not_a_crash() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, RaisingTable, 1); - - var status = L.TryGetField(1, "prop"u8); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(2, L.Top); - Assert.Contains("index raised for prop", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - L.PushInteger(5); - status = L.TrySetField(1, "prop"u8); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(2, L.Top); - Assert.Contains("newindex raised for prop", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - status = L.TryGetIndex(1, 3); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("index raised for 3", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - L.PushBoolean(true); - status = L.TrySetIndex(1, 3); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("newindex raised for 3", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - L.PushString("k"u8); - status = L.TryGetTable(1); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(2, L.Top); - L.Pop(1); - - L.PushString("k"u8); - L.PushInteger(1); - status = L.TrySetTable(1); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(2, L.Top); - } - - [Fact] - public void A_table_whose_len_tostring_or_lt_raises_surfaces_as_a_status_not_a_crash() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, RaisingTable, 1); - - var status = L.TryLength(1); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("len raised", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - status = L.TryToString(1); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("tostring raised", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - - L.PushValue(1); - status = L.TryCompare(1, 2, LuaComparison.Less, out var less); - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.False(less); - Assert.Contains("lt raised", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - L.Pop(1); - Assert.Equal(2, L.Top); - } - - [Fact] - public void Table_index_and_integer_access_work_on_plain_tables() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.CreateTable(); - - L.PushString("key"u8); - L.PushInteger(7); - Assert.True(L.TrySetTable(1).IsOk); - Assert.Equal(1, L.Top); - - L.PushString("key"u8); - Assert.True(L.TryGetTable(1).IsOk); - Assert.True(L.TryReadInteger(-1, out var value)); - Assert.Equal(7, value); - L.Pop(1); - - L.PushString("first"u8); - Assert.True(L.TrySetIndex(1, 1).IsOk); - L.PushString("second"u8); - Assert.True(L.TrySetIndex(-2, 2).IsOk); - Assert.True(L.TryGetIndex(1, 2).IsOk); - Assert.Equal("second", LuaTest.ReadString(L, -1)); - L.Pop(1); - - Assert.True(L.TryLength(1).IsOk); - Assert.True(L.TryReadInteger(-1, out var length)); - Assert.Equal(2, length); - L.Pop(1); - - Assert.True(L.TryGetIndex(1, 99).IsOk); - Assert.True(L.IsNil(-1)); - Assert.Equal(2, L.Top); - } - - [Fact] - public void Indexing_a_value_that_cannot_be_indexed_is_a_status() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - L.PushInteger(5); - - var status = L.TryGetField(1, "x"u8); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Contains("index", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - } - - [Fact] - public void ToString_length_and_compare_honour_metamethods() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, - "local mt = { __tostring = function(t) return 'obj#' .. t.id end, __len = function() return 99 end, __eq = function(a, b) return a.id == b.id end, __lt = function(a, b) return a.id < b.id end, __le = function(a, b) return a.id <= b.id end }\nreturn setmetatable({ id = 1 }, mt), setmetatable({ id = 1 }, mt), setmetatable({ id = 2 }, mt)"u8, - 3); - - Assert.True(L.TryToString(1).IsOk); - Assert.Equal("obj#1", LuaTest.ReadString(L, -1)); - L.Pop(1); - - Assert.True(L.TryLength(1).IsOk); - Assert.True(L.TryReadInteger(-1, out var length)); - Assert.Equal(99, length); - L.Pop(1); - - Assert.True(L.TryCompare(1, 2, LuaComparison.Equal, out var equal).IsOk); - Assert.True(equal); - Assert.True(L.TryCompare(1, 3, LuaComparison.Equal, out equal).IsOk); - Assert.False(equal); - Assert.True(L.TryCompare(1, 3, LuaComparison.Less, out var less).IsOk); - Assert.True(less); - Assert.True(L.TryCompare(-1, -3, LuaComparison.Less, out less).IsOk); - Assert.False(less); - Assert.True(L.TryCompare(1, 2, LuaComparison.LessOrEqual, out var lessOrEqual).IsOk); - Assert.True(lessOrEqual); - Assert.Equal(3, L.Top); - - Assert.True(L.TryToString(-1).IsOk); - Assert.Equal("obj#2", LuaTest.ReadString(L, -1)); - } - - [Fact] - public void Load_reports_syntax_errors_and_execute_runs_chunks() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - var syntax = L.TryLoad("return +"u8, "=chunk"u8); - Assert.Equal(LuaStatus.SyntaxError, syntax); - Assert.StartsWith("chunk:1:", LuaError.FromStack(L, syntax).Message, StringComparison.Ordinal); - L.Pop(1); - - Assert.True(L.TryLoad("return 6 * 7"u8).IsOk); - Assert.True(L.IsFunction(-1)); - Assert.True(L.TryCall(0, 1).IsOk); - Assert.True(L.TryReadInteger(-1, out var product)); - Assert.Equal(42, product); - L.Pop(1); - - Assert.True(L.TryExecute("return 1, 2, 3"u8, LuaState.MultipleResults).IsOk); - Assert.Equal(3, L.Top); - L.Pop(3); - - var runtime = L.TryExecute("local t = nil; return t.x"u8, 1, "=script"u8); - Assert.Equal(LuaStatus.RuntimeError, runtime); - Assert.StartsWith("script:1:", LuaError.FromStack(L, runtime).Message, StringComparison.Ordinal); - Assert.Equal(1, L.Top); - } - - [Fact] - public void Binary_chunks_are_refused() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(false); - var L = LuaTest.View(state); - - var status = L.TryLoad("Lua"u8); - - Assert.Equal(LuaStatus.SyntaxError, status); - } - - [Fact] - public void Message_handler_variant_transforms_the_error_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, "return function(message) return 'handled: ' .. message end"u8, 1); - Assert.True(L.TryLoad("error('inner')"u8, "=t"u8).IsOk); - - var status = L.TryCall(0, 0, 1); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.StartsWith("handled: t:1: inner", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); - Assert.Equal(2, L.Top); - } - - [Fact] - public void Error_values_that_are_not_strings_are_described_not_converted() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - - var tableStatus = - L.TryExecute("error(setmetatable({}, { __tostring = function() error('never call me') end }))"u8, 0); - Assert.Equal(LuaStatus.RuntimeError, tableStatus); - Assert.Equal("(error object is a table value)", LuaError.FromStack(L, tableStatus).Message); - L.Pop(1); - - // Lua 5.3's error() tests lua_isstring, which a number passes: level 1 would prefix the position and turn the - // value into a string. Level 0 keeps the number, which FromStack renders itself. - var numberStatus = L.TryExecute("error(404, 0)"u8, 0); - Assert.Equal(LuaType.Number, L.TypeOf(-1)); - Assert.Equal("404", LuaError.FromStack(L, numberStatus).Message); - L.Pop(1); - - var nilStatus = L.TryExecute("error()"u8, 0); - Assert.Equal("(error object is a nil value)", LuaError.FromStack(L, nilStatus).Message); - } - - [Fact] - public void Throwing_form_raises_a_lua_exception_and_leaves_the_error_for_the_frame() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - - var exception = Assert.Throws(() => Run(L)); - - Assert.Equal(LuaStatus.RuntimeError, exception.Status); - Assert.Contains("thrown from lua", exception.Message, StringComparison.Ordinal); - Assert.Equal(0, L.Top); - - static void Run(LuaState L) - { - using LuaFrame frame = new(L); - L.TryExecute("error('thrown from lua')"u8, 0).ThrowIfFailed(L); - } - } - - [Fact] - public void Helpers_survive_a_script_that_redefines_error_and_tostring() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - // Force the helpers in before the redefinition, as a plugin would when it makes its first protected call. - Assert.True(L.TryGetGlobal("print"u8).IsOk); - L.Pop(1); - LuaTest.Run(L, "error = nil; tostring = function() return 'hijacked' end; return 5"u8, 1); - - Assert.True(L.TryToString(1).IsOk); - Assert.Equal("5", LuaTest.ReadString(L, -1)); - } + private static ReadOnlySpan RaisingTable => """ + local t = setmetatable({}, { + __index = function(_, k) error('index raised for ' .. tostring(k)) end, + __newindex = function(_, k) error('newindex raised for ' .. tostring(k)) end, + __len = function() error('len raised') end, + __tostring = function() error('tostring raised') end, + __eq = function() error('eq raised') end, + __lt = function() error('lt raised') end, + }) + return t + """u8; + + [Fact] + public void Globals_are_read_and_written_under_protection() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + L.PushInteger(42); + Assert.True(L.TrySetGlobal("answer"u8).IsOk); + Assert.Equal(0, L.Top); + + Assert.True(L.TryGetGlobal("answer"u8).IsOk); + Assert.True(L.TryReadInteger(-1, out long value)); + Assert.Equal(42, value); + + Assert.True(L.TryGetGlobal("undefined"u8).IsOk); + Assert.True(L.IsNil(-1)); + Assert.Equal(2, L.Top); + } + + [Fact] + public void A_raising_index_metamethod_on_the_globals_table_becomes_a_status() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, + "setmetatable(_G, { __index = function(_, k) error('no global ' .. k) end, __newindex = function(_, k) error('read-only ' .. k) end })"u8); + + LuaStatus getStatus = L.TryGetGlobal("missing"u8); + Assert.Equal(LuaStatus.RuntimeError, getStatus); + Assert.Equal(1, L.Top); + Assert.Contains("no global missing", LuaError.FromStack(L, getStatus).Message, StringComparison.Ordinal); + L.Pop(1); + + L.PushInteger(1); + LuaStatus setStatus = L.TrySetGlobal("newGlobal"u8); + Assert.Equal(LuaStatus.RuntimeError, setStatus); + Assert.Equal(1, L.Top); + Assert.Contains("read-only newGlobal", LuaError.FromStack(L, setStatus).Message, StringComparison.Ordinal); + } + + [Fact] + public void Fields_are_read_and_written_through_metamethods_under_protection() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, + "return setmetatable({}, { __index = function(_, k) return 'got ' .. k end, __newindex = function(t, k, v) rawset(t, k, v * 2) end })"u8, + 1); + + Assert.True(L.TryGetField(1, "name"u8).IsOk); + Assert.Equal("got name", LuaTest.ReadString(L, -1)); + L.Pop(1); + + // Relative index, with the value already on top. + L.PushInteger(21); + Assert.True(L.TrySetField(-2, "doubled"u8).IsOk); + Assert.Equal(1, L.Top); + L.PushString("doubled"u8); + Assert.Equal(LuaType.Number, L.RawGet(1)); + Assert.True(L.TryReadInteger(-1, out long doubled)); + Assert.Equal(42, doubled); + L.Pop(1); + + Assert.True(L.TryGetField(-1, "other"u8).IsOk); + Assert.Equal("got other", LuaTest.ReadString(L, -1)); + Assert.Equal(2, L.Top); + } + + [Fact] + public void A_table_whose_index_raises_surfaces_as_a_status_not_a_crash() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, RaisingTable, 1); + + LuaStatus status = L.TryGetField(1, "prop"u8); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(2, L.Top); + Assert.Contains("index raised for prop", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + L.PushInteger(5); + status = L.TrySetField(1, "prop"u8); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(2, L.Top); + Assert.Contains("newindex raised for prop", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + status = L.TryGetIndex(1, 3); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("index raised for 3", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + L.PushBoolean(true); + status = L.TrySetIndex(1, 3); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("newindex raised for 3", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + L.PushString("k"u8); + status = L.TryGetTable(1); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(2, L.Top); + L.Pop(1); + + L.PushString("k"u8); + L.PushInteger(1); + status = L.TrySetTable(1); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(2, L.Top); + } + + [Fact] + public void A_table_whose_len_tostring_or_lt_raises_surfaces_as_a_status_not_a_crash() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, RaisingTable, 1); + + LuaStatus status = L.TryLength(1); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("len raised", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + status = L.TryToString(1); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("tostring raised", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + + L.PushValue(1); + status = L.TryCompare(1, 2, LuaComparison.Less, out bool less); + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.False(less); + Assert.Contains("lt raised", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + L.Pop(1); + Assert.Equal(2, L.Top); + } + + [Fact] + public void Table_index_and_integer_access_work_on_plain_tables() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.CreateTable(); + + L.PushString("key"u8); + L.PushInteger(7); + Assert.True(L.TrySetTable(1).IsOk); + Assert.Equal(1, L.Top); + + L.PushString("key"u8); + Assert.True(L.TryGetTable(1).IsOk); + Assert.True(L.TryReadInteger(-1, out long value)); + Assert.Equal(7, value); + L.Pop(1); + + L.PushString("first"u8); + Assert.True(L.TrySetIndex(1, 1).IsOk); + L.PushString("second"u8); + Assert.True(L.TrySetIndex(-2, 2).IsOk); + Assert.True(L.TryGetIndex(1, 2).IsOk); + Assert.Equal("second", LuaTest.ReadString(L, -1)); + L.Pop(1); + + Assert.True(L.TryLength(1).IsOk); + Assert.True(L.TryReadInteger(-1, out long length)); + Assert.Equal(2, length); + L.Pop(1); + + Assert.True(L.TryGetIndex(1, 99).IsOk); + Assert.True(L.IsNil(-1)); + Assert.Equal(2, L.Top); + } + + [Fact] + public void Indexing_a_value_that_cannot_be_indexed_is_a_status() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(5); + + LuaStatus status = L.TryGetField(1, "x"u8); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Contains("index", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + } + + [Fact] + public void ToString_length_and_compare_honour_metamethods() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, + "local mt = { __tostring = function(t) return 'obj#' .. t.id end, __len = function() return 99 end, __eq = function(a, b) return a.id == b.id end, __lt = function(a, b) return a.id < b.id end, __le = function(a, b) return a.id <= b.id end }\nreturn setmetatable({ id = 1 }, mt), setmetatable({ id = 1 }, mt), setmetatable({ id = 2 }, mt)"u8, + 3); + + Assert.True(L.TryToString(1).IsOk); + Assert.Equal("obj#1", LuaTest.ReadString(L, -1)); + L.Pop(1); + + Assert.True(L.TryLength(1).IsOk); + Assert.True(L.TryReadInteger(-1, out long length)); + Assert.Equal(99, length); + L.Pop(1); + + Assert.True(L.TryCompare(1, 2, LuaComparison.Equal, out bool equal).IsOk); + Assert.True(equal); + Assert.True(L.TryCompare(1, 3, LuaComparison.Equal, out equal).IsOk); + Assert.False(equal); + Assert.True(L.TryCompare(1, 3, LuaComparison.Less, out bool less).IsOk); + Assert.True(less); + Assert.True(L.TryCompare(-1, -3, LuaComparison.Less, out less).IsOk); + Assert.False(less); + Assert.True(L.TryCompare(1, 2, LuaComparison.LessOrEqual, out bool lessOrEqual).IsOk); + Assert.True(lessOrEqual); + Assert.Equal(3, L.Top); + + Assert.True(L.TryToString(-1).IsOk); + Assert.Equal("obj#2", LuaTest.ReadString(L, -1)); + } + + [Fact] + public void Load_reports_syntax_errors_and_execute_runs_chunks() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + LuaStatus syntax = L.TryLoad("return +"u8, "=chunk"u8); + Assert.Equal(LuaStatus.SyntaxError, syntax); + Assert.StartsWith("chunk:1:", LuaError.FromStack(L, syntax).Message, StringComparison.Ordinal); + L.Pop(1); + + Assert.True(L.TryLoad("return 6 * 7"u8).IsOk); + Assert.True(L.IsFunction(-1)); + Assert.True(L.TryCall(0, 1).IsOk); + Assert.True(L.TryReadInteger(-1, out long product)); + Assert.Equal(42, product); + L.Pop(1); + + Assert.True(L.TryExecute("return 1, 2, 3"u8, LuaState.MultipleResults).IsOk); + Assert.Equal(3, L.Top); + L.Pop(3); + + LuaStatus runtime = L.TryExecute("local t = nil; return t.x"u8, 1, "=script"u8); + Assert.Equal(LuaStatus.RuntimeError, runtime); + Assert.StartsWith("script:1:", LuaError.FromStack(L, runtime).Message, StringComparison.Ordinal); + Assert.Equal(1, L.Top); + } + + [Fact] + public void Binary_chunks_are_refused() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + LuaStatus status = L.TryLoad("Lua"u8); + + Assert.Equal(LuaStatus.SyntaxError, status); + } + + [Fact] + public void Message_handler_variant_transforms_the_error_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, "return function(message) return 'handled: ' .. message end"u8, 1); + Assert.True(L.TryLoad("error('inner')"u8, "=t"u8).IsOk); + + LuaStatus status = L.TryCall(0, 0, 1); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.StartsWith("handled: t:1: inner", LuaError.FromStack(L, status).Message, StringComparison.Ordinal); + Assert.Equal(2, L.Top); + } + + [Fact] + public void Error_values_that_are_not_strings_are_described_not_converted() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + + LuaStatus tableStatus = + L.TryExecute("error(setmetatable({}, { __tostring = function() error('never call me') end }))"u8, 0); + Assert.Equal(LuaStatus.RuntimeError, tableStatus); + Assert.Equal("(error object is a table value)", LuaError.FromStack(L, tableStatus).Message); + L.Pop(1); + + // Lua 5.3's error() tests lua_isstring, which a number passes: level 1 would prefix the position and turn the + // value into a string. Level 0 keeps the number, which FromStack renders itself. + LuaStatus numberStatus = L.TryExecute("error(404, 0)"u8, 0); + Assert.Equal(LuaType.Number, L.TypeOf(-1)); + Assert.Equal("404", LuaError.FromStack(L, numberStatus).Message); + L.Pop(1); + + LuaStatus nilStatus = L.TryExecute("error()"u8, 0); + Assert.Equal("(error object is a nil value)", LuaError.FromStack(L, nilStatus).Message); + } + + [Fact] + public void Throwing_form_raises_a_lua_exception_and_leaves_the_error_for_the_frame() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + + LuaException exception = Assert.Throws(() => Run(L)); + + Assert.Equal(LuaStatus.RuntimeError, exception.Status); + Assert.Contains("thrown from lua", exception.Message, StringComparison.Ordinal); + Assert.Equal(0, L.Top); + + static void Run(LuaState L) + { + using LuaFrame frame = new(L); + L.TryExecute("error('thrown from lua')"u8, 0).ThrowIfFailed(L); + } + } + + [Fact] + public void Helpers_survive_a_script_that_redefines_error_and_tostring() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + // Force the helpers in before the redefinition, as a plugin would when it makes its first protected call. + Assert.True(L.TryGetGlobal("print"u8).IsOk); + L.Pop(1); + LuaTest.Run(L, "error = nil; tostring = function() return 'hijacked' end; return 5"u8, 1); + + Assert.True(L.TryToString(1).IsOk); + Assert.Equal("5", LuaTest.ReadString(L, -1)); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Support/AllocationGate.cs b/tests/CheatEngine.SDK.Lua.Tests/Support/AllocationGate.cs index 29a4e132..bcf38c88 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Support/AllocationGate.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Support/AllocationGate.cs @@ -9,16 +9,22 @@ namespace CheatEngine.SDK.Lua.Tests.Support; /// internal static class AllocationGate { - public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) - { - for (var i = 0; i < warmUp; i++) body(); + public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) + { + for (int i = 0; i < warmUp; i++) + { + body(); + } - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < iterations; i++) body(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + body(); + } - var allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(allocated == 0, - string.Create(CultureInfo.InvariantCulture, - $"{allocated} bytes were allocated over {iterations} iterations ({(double)allocated / iterations:F1} per call).")); - } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated == 0, + string.Create(CultureInfo.InvariantCulture, + $"{allocated} bytes were allocated over {iterations} iterations ({(double) allocated / iterations:F1} per call).")); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Support/HostDouble.cs b/tests/CheatEngine.SDK.Lua.Tests/Support/HostDouble.cs index 0b87ad6e..83a13571 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Support/HostDouble.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Support/HostDouble.cs @@ -1,9 +1,9 @@ -using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Threading; + using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; namespace CheatEngine.SDK.Lua.Tests.Support; @@ -17,84 +17,84 @@ namespace CheatEngine.SDK.Lua.Tests.Support; /// internal static unsafe class HostDouble { - private static readonly Lock SStateGate = new(); - private static readonly Dictionary SStatesByThread = new(); - private static nint s_defaultState; - private static int s_providerCalls; - private static int s_pusherCalls; - private static nint s_lastPushedObject; + private static readonly Lock SStateGate = new(); + private static readonly Dictionary SStatesByThread = new(); + private static nint s_defaultState; + private static int s_providerCalls; + private static int s_pusherCalls; + private static nint s_lastPushedObject; - public static int ProviderCalls => Volatile.Read(ref s_providerCalls); + public static int ProviderCalls => Volatile.Read(ref s_providerCalls); - public static int PusherCalls => Volatile.Read(ref s_pusherCalls); + public static int PusherCalls => Volatile.Read(ref s_pusherCalls); - public static nint LastPushedObject => Volatile.Read(ref s_lastPushedObject); + public static nint LastPushedObject => Volatile.Read(ref s_lastPushedObject); - public static nint ProviderAddress => (nint)(delegate* unmanaged[Stdcall])&Provide; + public static nint ProviderAddress => (nint) (delegate* unmanaged[Stdcall]) &Provide; - public static nint PusherAddress => (nint)(delegate* unmanaged[Stdcall])&PushObject; + public static nint PusherAddress => (nint) (delegate* unmanaged[Stdcall]) &PushObject; - /// - /// Points the provider at for the calling thread (null makes it return no state) and - /// builds a binding for the calling thread as main thread. An unmapped worker receives this default state; - /// tests can use or to model - /// an unavailable state or a rooted worker coroutine. - /// - public static LuaHostBinding CreateBinding(lua_State* state, bool withPusher = true) - { - lock (SStateGate) - { - SStatesByThread.Clear(); - s_defaultState = (nint)state; - SStatesByThread.Add(Environment.CurrentManagedThreadId, (nint)state); - } + /// + /// Points the provider at for the calling thread (null makes it return no state) and + /// builds a binding for the calling thread as main thread. An unmapped worker receives this default state; + /// tests can use or to model + /// an unavailable state or a rooted worker coroutine. + /// + public static LuaHostBinding CreateBinding(lua_State* state, bool withPusher = true) + { + lock (SStateGate) + { + SStatesByThread.Clear(); + s_defaultState = (nint) state; + SStatesByThread.Add(Environment.CurrentManagedThreadId, (nint) state); + } - s_providerCalls = 0; - s_pusherCalls = 0; - s_lastPushedObject = 0; - delegate* unmanaged[Stdcall] provider = &Provide; - delegate* unmanaged[Stdcall] pusher = withPusher ? &PushObject : null; - return new LuaHostBinding(provider, pusher, Environment.CurrentManagedThreadId); - } + s_providerCalls = 0; + s_pusherCalls = 0; + s_lastPushedObject = 0; + delegate* unmanaged[Stdcall] provider = &Provide; + delegate* unmanaged[Stdcall] pusher = withPusher ? &PushObject : null; + return new LuaHostBinding(provider, pusher, Environment.CurrentManagedThreadId); + } - /// - /// Sets the state the provider returns for the current worker. This is a test seam for the host contract that - /// creates one Lua coroutine for each OS thread; it does not turn distinct pointers into independent Lua heaps. - /// - public static void SetStateForCurrentThread(nint state) - { - lock (SStateGate) - { - SStatesByThread[Environment.CurrentManagedThreadId] = state; - } - } + /// + /// Sets the state the provider returns for the current worker. This is a test seam for the host contract that + /// creates one Lua coroutine for each OS thread; it does not turn distinct pointers into independent Lua heaps. + /// + public static void SetStateForCurrentThread(nint state) + { + lock (SStateGate) + { + SStatesByThread[Environment.CurrentManagedThreadId] = state; + } + } - /// - /// Makes the state provider report no state for the current worker only. The default host state remains - /// available to every other worker. - /// - public static void ClearStateForCurrentThread() - { - SetStateForCurrentThread(nint.Zero); - } + /// + /// Makes the state provider report no state for the current worker only. The default host state remains + /// available to every other worker. + /// + public static void ClearStateForCurrentThread() + { + SetStateForCurrentThread(nint.Zero); + } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* Provide() - { - Interlocked.Increment(ref s_providerCalls); - lock (SStateGate) - { - return (void*)(SStatesByThread.TryGetValue(Environment.CurrentManagedThreadId, out var state) - ? state - : s_defaultState); - } - } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* Provide() + { + Interlocked.Increment(ref s_providerCalls); + lock (SStateGate) + { + return (void*) (SStatesByThread.TryGetValue(Environment.CurrentManagedThreadId, out IntPtr state) + ? state + : s_defaultState); + } + } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void PushObject(void* L, void* nativeObject) - { - Interlocked.Increment(ref s_pusherCalls); - Volatile.Write(ref s_lastPushedObject, (nint)nativeObject); - lua_pushlightuserdata((lua_State*)L, nativeObject); - } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void PushObject(void* L, void* nativeObject) + { + Interlocked.Increment(ref s_pusherCalls); + Volatile.Write(ref s_lastPushedObject, (nint) nativeObject); + lua_pushlightuserdata((lua_State*) L, nativeObject); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Support/LuaTest.cs b/tests/CheatEngine.SDK.Lua.Tests/Support/LuaTest.cs index 13cd805e..37e1acb4 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Support/LuaTest.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Support/LuaTest.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -8,35 +9,35 @@ namespace CheatEngine.SDK.Lua.Tests.Support; /// What the NativeLua tests share: the skip guard, chunk execution through the public API and string reading. internal static class LuaTest { - /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. - public static void RequireNativeLua() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - } + /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. + public static void RequireNativeLua() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + } - /// Wraps the fixture's state in the SDK view. - public static LuaState View(NativeLuaState state) - { - return new LuaState(state.Pointer); - } + /// Wraps the fixture's state in the SDK view. + public static LuaState View(NativeLuaState state) + { + return new LuaState(state.Pointer); + } - /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. - public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) - { - var status = L.TryExecute(source, resultCount, "=test"u8); - if (!status.IsOk) - { - var error = LuaError.FromStack(L, status); - Assert.Fail("The chunk failed: " + error); - } - } + /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. + public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) + { + LuaStatus status = L.TryExecute(source, resultCount, "=test"u8); + if (!status.IsOk) + { + LuaError error = LuaError.FromStack(L, status); + Assert.Fail("The chunk failed: " + error); + } + } - /// Reads the string at , or fails the test when the value is not a string. - public static string ReadString(LuaState L, int index) - { - Assert.True(L.TryReadString(index, out var value), - string.Create(CultureInfo.InvariantCulture, - $"The value at {index} is a {L.TypeOf(index)}, not a string.")); - return value; - } + /// Reads the string at , or fails the test when the value is not a string. + public static string ReadString(LuaState L, int index) + { + Assert.True(L.TryReadString(index, out string? value), + string.Create(CultureInfo.InvariantCulture, + $"The value at {index} is a {L.TypeOf(index)}, not a string.")); + return value; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs b/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs index 1b130e0b..9302f2ff 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs @@ -9,16 +9,19 @@ namespace CheatEngine.SDK.Lua.Tests.Support; /// internal sealed unsafe class RuntimeScope : IDisposable { - public RuntimeScope(NativeLuaState state, bool withPusher = true) - { - Binding = HostDouble.CreateBinding(state.L, withPusher); - LuaRuntime.Attach(Binding); - } + public RuntimeScope(NativeLuaState state, bool withPusher = true) + { + Binding = HostDouble.CreateBinding(state.L, withPusher); + LuaRuntime.Attach(Binding); + } - public LuaHostBinding Binding { get; } + public LuaHostBinding Binding + { + get; + } - public void Dispose() - { - LuaRuntime.Detach(); - } + public void Dispose() + { + LuaRuntime.Detach(); + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchTests.cs index 2f71aea8..5a421dd9 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Text/Utf8ScratchTests.cs @@ -1,4 +1,5 @@ using System.Text; + using CheatEngine.SDK.Lua.Text; namespace CheatEngine.SDK.Lua.Tests.Text; @@ -6,85 +7,85 @@ namespace CheatEngine.SDK.Lua.Tests.Text; /// The transcoder that turns managed text into the bytes Lua stores. No Lua library involved. public sealed class Utf8ScratchTests { - [Fact] - public void Ascii_fits_the_stack_buffer_and_is_copied_byte_for_byte() - { - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode("readInteger", scratch); - - Assert.False(utf8.IsPooled); - Assert.True(utf8.Bytes.SequenceEqual("readInteger"u8)); - } - - [Fact] - public void Non_ascii_text_is_encoded_as_utf8() - { - // Two 2-byte sequences, one 3-byte sequence and one 4-byte sequence (a surrogate pair in UTF-16). - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode("caf\u00E9 \u00FC \u20AC \uD83D\uDE00", scratch); - - byte[] expected = - [0x63, 0x61, 0x66, 0xC3, 0xA9, 0x20, 0xC3, 0xBC, 0x20, 0xE2, 0x82, 0xAC, 0x20, 0xF0, 0x9F, 0x98, 0x80]; - Assert.True(utf8.Bytes.SequenceEqual(expected)); - } - - [Fact] - public void Lone_surrogates_become_the_replacement_character_and_never_throw() - { - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var high = Utf8Scratch.Encode("a\uD800b", scratch); - Assert.True(high.Bytes.SequenceEqual(new byte[] { 0x61, 0xEF, 0xBF, 0xBD, 0x62 })); - - Span scratch2 = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var low = Utf8Scratch.Encode("\uDC00", scratch2); - Assert.True(low.Bytes.SequenceEqual(new byte[] { 0xEF, 0xBF, 0xBD })); - } - - [Fact] - public void Empty_text_gives_empty_bytes_without_pooling() - { - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode(ReadOnlySpan.Empty, scratch); - - Assert.True(utf8.Bytes.IsEmpty); - Assert.False(utf8.IsPooled); - } - - [Fact] - public void Text_whose_worst_case_exceeds_the_buffer_but_whose_real_size_fits_stays_on_the_stack() - { - // 200 ASCII characters: worst case 603 bytes > 512, exact 200 bytes <= 512. - string text = new('x', 200); - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode(text, scratch); - - Assert.False(utf8.IsPooled); - Assert.Equal(200, utf8.Bytes.Length); - } - - [Fact] - public void Text_above_the_stack_threshold_is_transcoded_through_the_pool_and_matches_encoding_utf8() - { - var text = string.Concat(Enumerable.Repeat("\u00E9\u20AC\uD83D\uDE00x", 300)); - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - using var utf8 = Utf8Scratch.Encode(text, scratch); - - Assert.True(utf8.IsPooled); - Assert.True(utf8.Bytes.SequenceEqual(Encoding.UTF8.GetBytes(text))); - } - - [Fact] - public void Dispose_is_idempotent_and_clears_the_bytes() - { - string text = new('y', 5_000); - Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; - var utf8 = Utf8Scratch.Encode(text, scratch); - Assert.True(utf8.IsPooled); - - utf8.Dispose(); - utf8.Dispose(); - - Assert.False(utf8.IsPooled); - Assert.True(utf8.Bytes.IsEmpty); - } + [Fact] + public void Ascii_fits_the_stack_buffer_and_is_copied_byte_for_byte() + { + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode("readInteger", scratch); + + Assert.False(utf8.IsPooled); + Assert.True(utf8.Bytes.SequenceEqual("readInteger"u8)); + } + + [Fact] + public void Non_ascii_text_is_encoded_as_utf8() + { + // Two 2-byte sequences, one 3-byte sequence and one 4-byte sequence (a surrogate pair in UTF-16). + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode("caf\u00E9 \u00FC \u20AC \uD83D\uDE00", scratch); + + byte[] expected = + [0x63, 0x61, 0x66, 0xC3, 0xA9, 0x20, 0xC3, 0xBC, 0x20, 0xE2, 0x82, 0xAC, 0x20, 0xF0, 0x9F, 0x98, 0x80]; + Assert.True(utf8.Bytes.SequenceEqual(expected)); + } + + [Fact] + public void Lone_surrogates_become_the_replacement_character_and_never_throw() + { + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch high = Utf8Scratch.Encode("a\uD800b", scratch); + Assert.True(high.Bytes.SequenceEqual(new byte[] { 0x61, 0xEF, 0xBF, 0xBD, 0x62 })); + + Span scratch2 = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch low = Utf8Scratch.Encode("\uDC00", scratch2); + Assert.True(low.Bytes.SequenceEqual(new byte[] { 0xEF, 0xBF, 0xBD })); + } + + [Fact] + public void Empty_text_gives_empty_bytes_without_pooling() + { + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(ReadOnlySpan.Empty, scratch); + + Assert.True(utf8.Bytes.IsEmpty); + Assert.False(utf8.IsPooled); + } + + [Fact] + public void Text_whose_worst_case_exceeds_the_buffer_but_whose_real_size_fits_stays_on_the_stack() + { + // 200 ASCII characters: worst case 603 bytes > 512, exact 200 bytes <= 512. + string text = new('x', 200); + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(text, scratch); + + Assert.False(utf8.IsPooled); + Assert.Equal(200, utf8.Bytes.Length); + } + + [Fact] + public void Text_above_the_stack_threshold_is_transcoded_through_the_pool_and_matches_encoding_utf8() + { + string text = string.Concat(Enumerable.Repeat("\u00E9\u20AC\uD83D\uDE00x", 300)); + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + using Utf8Scratch utf8 = Utf8Scratch.Encode(text, scratch); + + Assert.True(utf8.IsPooled); + Assert.True(utf8.Bytes.SequenceEqual(Encoding.UTF8.GetBytes(text))); + } + + [Fact] + public void Dispose_is_idempotent_and_clears_the_bytes() + { + string text = new('y', 5_000); + Span scratch = stackalloc byte[Utf8Scratch.StackBufferSize]; + Utf8Scratch utf8 = Utf8Scratch.Encode(text, scratch); + Assert.True(utf8.IsPooled); + + utf8.Dispose(); + utf8.Dispose(); + + Assert.False(utf8.IsPooled); + Assert.True(utf8.Bytes.IsEmpty); + } } diff --git a/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExportNames.cs b/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExportNames.cs index 7e497190..02b30d84 100644 --- a/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExportNames.cs +++ b/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExportNames.cs @@ -3,12 +3,15 @@ namespace CheatEngine.SDK.NativeAotLibraryProbe; /// Names exported only by the inert NativeAOT library-analysis fixture. public static class NativeAotLibraryProbeExportNames { - /// A name that the loader harness may query after mapping the fixture. - public const string NameQuery = "CheatEngineSdkNativeAotProbe_NameQuery"; + /// A name that the loader harness may query after mapping the fixture. + public const string NameQuery = "CheatEngineSdkNativeAotProbe_NameQuery"; - /// A name that records that the fixture has no plugin activation entry point. - public const string LoadOnly = "CheatEngineSdkNativeAotProbe_LoadOnly"; + /// A name that records that the fixture has no plugin activation entry point. + public const string LoadOnly = "CheatEngineSdkNativeAotProbe_LoadOnly"; - /// The complete set of names required by the loader harness. - public static IReadOnlyList Required { get; } = [NameQuery, LoadOnly]; + /// The complete set of names required by the loader harness. + public static IReadOnlyList Required + { + get; + } = [NameQuery, LoadOnly]; } diff --git a/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExports.cs b/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExports.cs index c3bbbf40..1f7d1a4a 100644 --- a/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExports.cs +++ b/tests/CheatEngine.SDK.NativeAotLibraryProbe/NativeAotLibraryProbeExports.cs @@ -6,13 +6,19 @@ namespace CheatEngine.SDK.NativeAotLibraryProbe; /// Inert functions whose names make the generated shared library inspectable by the bounded harness. public static class NativeAotLibraryProbeExports { - /// Returns a fixture sentinel if a native inspection tool ever needs an invocation contract. - [UnmanagedCallersOnly(EntryPoint = NativeAotLibraryProbeExportNames.NameQuery, - CallConvs = [typeof(CallConvStdcall)])] - public static int NameQuery() => 1; + /// Returns a fixture sentinel if a native inspection tool ever needs an invocation contract. + [UnmanagedCallersOnly(EntryPoint = NativeAotLibraryProbeExportNames.NameQuery, + CallConvs = [typeof(CallConvStdcall)])] + public static int NameQuery() + { + return 1; + } - /// Returns a fixture sentinel and does not create, enable, or disable a plugin. - [UnmanagedCallersOnly(EntryPoint = NativeAotLibraryProbeExportNames.LoadOnly, - CallConvs = [typeof(CallConvStdcall)])] - public static int LoadOnly() => 1; + /// Returns a fixture sentinel and does not create, enable, or disable a plugin. + [UnmanagedCallersOnly(EntryPoint = NativeAotLibraryProbeExportNames.LoadOnly, + CallConvs = [typeof(CallConvStdcall)])] + public static int LoadOnly() + { + return 1; + } } diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/PortableExecutableExportReader.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness/PortableExecutableExportReader.cs index eb627812..c87820ac 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness/PortableExecutableExportReader.cs +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/PortableExecutableExportReader.cs @@ -1,99 +1,125 @@ using System.Buffers.Binary; using System.Reflection.PortableExecutable; +using System.Text; namespace CheatEngine.SDK.NativeAotLoaderHarness; /// Reads a PE export directory from file bytes without asking the operating-system loader to map the file. internal static class PortableExecutableExportReader { - private const int ExportDirectorySize = 40; - private const int NumberOfNamesOffset = 24; - private const int AddressOfNamesOffset = 32; - - /// Returns the PE32+ AMD64 export names in the supplied file. - public static List ReadExportNames(byte[] image) - { - using var imageStream = new MemoryStream(image, writable: false); - using var peReader = new PEReader(imageStream); - - PEHeaders headers = peReader.PEHeaders; - PEHeader? peHeader = headers.PEHeader; - if (peHeader is null) - throw new InvalidOperationException("The file has no portable executable header."); - - if (peHeader.Magic != PEMagic.PE32Plus) - throw new InvalidOperationException("The file is not a PE32+ image."); - - if (headers.CoffHeader.Machine != Machine.Amd64) - throw new InvalidOperationException("The file is not an AMD64 image."); - - int exportDirectoryRva = peHeader.ExportTableDirectory.RelativeVirtualAddress; - if (exportDirectoryRva == 0) - throw new InvalidOperationException("The file has no export directory."); - - int exportDirectoryOffset = ResolveFileOffset(headers, exportDirectoryRva); - RequireBytes(image, exportDirectoryOffset, ExportDirectorySize); - - uint numberOfNames = ReadUInt32(image, exportDirectoryOffset + NumberOfNamesOffset); - uint namesRva = ReadUInt32(image, exportDirectoryOffset + AddressOfNamesOffset); - if (numberOfNames == 0 || namesRva == 0) - throw new InvalidOperationException("The export directory has no named exports."); - - var names = new List(); - int namesOffset = ResolveFileOffset(headers, checked((int)namesRva)); - - for (uint index = 0; index < numberOfNames; index++) - { - int nameRvaOffset = checked(namesOffset + checked((int)(index * sizeof(uint)))); - uint nameRva = ReadUInt32(image, nameRvaOffset); - names.Add(ReadAsciiZeroTerminated(image, ResolveFileOffset(headers, checked((int)nameRva)))); - } - - return names; - } - - private static int ResolveFileOffset(PEHeaders headers, int relativeVirtualAddress) - { - foreach (SectionHeader section in headers.SectionHeaders) - { - int sectionLength = Math.Max(section.VirtualSize, section.SizeOfRawData); - long sectionStart = section.VirtualAddress; - long sectionEnd = sectionStart + sectionLength; - - if (relativeVirtualAddress < sectionStart || relativeVirtualAddress >= sectionEnd) continue; - - long fileOffset = section.PointerToRawData + ((long)relativeVirtualAddress - sectionStart); - if (fileOffset < 0 || fileOffset > int.MaxValue) - throw new InvalidOperationException("The export directory resolves outside the file."); - - return (int)fileOffset; - } - - throw new InvalidOperationException("The export directory does not map to a PE section."); - } - - private static uint ReadUInt32(byte[] image, int offset) - { - RequireBytes(image, offset, sizeof(uint)); - return BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(offset, sizeof(uint))); - } - - private static string ReadAsciiZeroTerminated(byte[] image, int offset) - { - if (offset < 0 || offset >= image.Length) - throw new InvalidOperationException("An export name resolves outside the file."); - - int end = offset; - while (end < image.Length && image[end] != 0) end++; - if (end == image.Length) - throw new InvalidOperationException("An export name is not zero terminated."); - - return System.Text.Encoding.ASCII.GetString(image, offset, end - offset); - } - - private static void RequireBytes(byte[] image, int offset, int count) - { - if (offset < 0 || count < 0 || image.Length - offset < count) - throw new InvalidOperationException("The PE export data is truncated."); - } + private const int ExportDirectorySize = 40; + private const int NumberOfNamesOffset = 24; + private const int AddressOfNamesOffset = 32; + + /// Returns the PE32+ AMD64 export names in the supplied file. + public static List ReadExportNames(byte[] image) + { + using MemoryStream imageStream = new(image, false); + using PEReader peReader = new(imageStream); + + PEHeaders headers = peReader.PEHeaders; + PEHeader? peHeader = headers.PEHeader; + if (peHeader is null) + { + throw new InvalidOperationException("The file has no portable executable header."); + } + + if (peHeader.Magic != PEMagic.PE32Plus) + { + throw new InvalidOperationException("The file is not a PE32+ image."); + } + + if (headers.CoffHeader.Machine != Machine.Amd64) + { + throw new InvalidOperationException("The file is not an AMD64 image."); + } + + int exportDirectoryRva = peHeader.ExportTableDirectory.RelativeVirtualAddress; + if (exportDirectoryRva == 0) + { + throw new InvalidOperationException("The file has no export directory."); + } + + int exportDirectoryOffset = ResolveFileOffset(headers, exportDirectoryRva); + RequireBytes(image, exportDirectoryOffset, ExportDirectorySize); + + uint numberOfNames = ReadUInt32(image, exportDirectoryOffset + NumberOfNamesOffset); + uint namesRva = ReadUInt32(image, exportDirectoryOffset + AddressOfNamesOffset); + if (numberOfNames == 0 || namesRva == 0) + { + throw new InvalidOperationException("The export directory has no named exports."); + } + + List names = new(); + int namesOffset = ResolveFileOffset(headers, checked((int) namesRva)); + + for (uint index = 0; index < numberOfNames; index++) + { + int nameRvaOffset = checked(namesOffset + checked((int) (index * sizeof(uint)))); + uint nameRva = ReadUInt32(image, nameRvaOffset); + names.Add(ReadAsciiZeroTerminated(image, ResolveFileOffset(headers, checked((int) nameRva)))); + } + + return names; + } + + private static int ResolveFileOffset(PEHeaders headers, int relativeVirtualAddress) + { + foreach (SectionHeader section in headers.SectionHeaders) + { + int sectionLength = Math.Max(section.VirtualSize, section.SizeOfRawData); + long sectionStart = section.VirtualAddress; + long sectionEnd = sectionStart + sectionLength; + + if (relativeVirtualAddress < sectionStart || relativeVirtualAddress >= sectionEnd) + { + continue; + } + + long fileOffset = section.PointerToRawData + (relativeVirtualAddress - sectionStart); + if (fileOffset < 0 || fileOffset > int.MaxValue) + { + throw new InvalidOperationException("The export directory resolves outside the file."); + } + + return (int) fileOffset; + } + + throw new InvalidOperationException("The export directory does not map to a PE section."); + } + + private static uint ReadUInt32(byte[] image, int offset) + { + RequireBytes(image, offset, sizeof(uint)); + return BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(offset, sizeof(uint))); + } + + private static string ReadAsciiZeroTerminated(byte[] image, int offset) + { + if (offset < 0 || offset >= image.Length) + { + throw new InvalidOperationException("An export name resolves outside the file."); + } + + int end = offset; + while (end < image.Length && image[end] != 0) + { + end++; + } + + if (end == image.Length) + { + throw new InvalidOperationException("An export name is not zero terminated."); + } + + return Encoding.ASCII.GetString(image, offset, end - offset); + } + + private static void RequireBytes(byte[] image, int offset, int count) + { + if (offset < 0 || count < 0 || image.Length - offset < count) + { + throw new InvalidOperationException("The PE export data is truncated."); + } + } } diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs index 45d081fd..01179740 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; + using CheatEngine.SDK.NativeAotLibraryProbe; namespace CheatEngine.SDK.NativeAotLoaderHarness; @@ -7,164 +8,191 @@ namespace CheatEngine.SDK.NativeAotLoaderHarness; /// Runs bounded file analysis or process-resident name queries for the SDK-006 NativeAOT library fixture. internal static class Program { - private const string AnalyzeMode = "--analyze"; - private const string LoadMode = "--load"; - private const string AcknowledgeProcessResidentLoad = "--acknowledge-process-resident-load"; - private const string NativePluginPrefix = "CEPlugin_"; - private const string FixtureFileName = "CheatEngine.SDK.NativeAotLibraryProbe.dll"; - - /// Runs the requested bounded observation. - public static int Main(string[] arguments) - { - try - { - return Run(arguments); - } - catch (Exception exception) - { - Console.Error.WriteLine($"harness.error={exception.Message}"); - return 1; - } - } - - private static int Run(string[] arguments) - { - if (!OperatingSystem.IsWindows()) - throw new PlatformNotSupportedException("The SDK-006 NativeAOT library profile is Windows x64 only."); - - if (arguments.Length == 2 && string.Equals(arguments[0], AnalyzeMode, StringComparison.Ordinal)) - { - Inspect(arguments[1]); - return 0; - } - - if (arguments.Length == 2 && string.Equals(arguments[0], LoadMode, StringComparison.Ordinal) && - string.Equals(arguments[1], AcknowledgeProcessResidentLoad, StringComparison.Ordinal)) - { - LoadAndQueryNames(); - return 0; - } - - throw new ArgumentException( - "Usage: --analyze | --load --acknowledge-process-resident-load", - nameof(arguments)); - } - - private static void Inspect(string libraryPath) - { - using LibraryInspection inspection = ReadAndValidateProbe(libraryPath); - Console.WriteLine("mode=byte-only-analysis"); - Console.WriteLine($"library.path={inspection.Path}"); - Console.WriteLine($"library.sha256={inspection.Sha256}"); - Console.WriteLine("library.mapped=false"); - - foreach (string exportName in NativeAotLibraryProbeExportNames.Required) - Console.WriteLine($"export.name.{exportName}=present"); - } - - private static void LoadAndQueryNames() - { - string fixturePath = Path.Combine(AppContext.BaseDirectory, FixtureFileName); - using LibraryInspection inspection = ReadAndValidateProbe(fixturePath); - Console.WriteLine("mode=process-resident-name-query"); - Console.WriteLine($"library.path={inspection.Path}"); - Console.WriteLine($"library.sha256={inspection.Sha256}"); - Console.WriteLine("library.identity=profile-adjacent-fixture"); - Console.WriteLine("library.activation=not-attempted"); - - nint module = NativeLibrary.Load(inspection.Path); - Console.WriteLine("library.mapped=true"); - - foreach (string exportName in NativeAotLibraryProbeExportNames.Required) - { - if (!NativeLibrary.TryGetExport(module, exportName, out nint address)) - throw new InvalidOperationException($"The mapped fixture does not expose '{exportName}'."); - - if (address == 0) - throw new InvalidOperationException($"The mapped fixture resolved '{exportName}' to a null address."); - - Console.WriteLine($"export.query.{exportName}=present"); - } - - // NativeAOT shared-library unload is unsupported. This dedicated process exits after the observation instead. - Console.WriteLine("library.unload=not-attempted"); - } - - private static LibraryInspection ReadAndValidateProbe(string libraryPath) - { - string fullPath = Path.GetFullPath(libraryPath); - if (!File.Exists(fullPath)) - throw new FileNotFoundException("The NativeAOT library probe does not exist.", fullPath); - - var fileLock = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); - byte[] bytes; - try - { - if (fileLock.Length > int.MaxValue) - throw new InvalidOperationException("The NativeAOT library probe is too large to inspect safely."); - - bytes = new byte[(int)fileLock.Length]; - fileLock.ReadExactly(bytes); - } - catch - { - fileLock.Dispose(); - throw; - } - - string sha256 = Convert.ToHexString(SHA256.HashData(bytes)); - List exportNames; - try - { - exportNames = PortableExecutableExportReader.ReadExportNames(bytes); - EnsureNoNativePluginExports(exportNames); - EnsureExpectedExports(exportNames); - } - catch - { - fileLock.Dispose(); - throw; - } - - return new LibraryInspection(fullPath, sha256, fileLock); - } - - private static void EnsureNoNativePluginExports(IReadOnlyList exportNames) - { - foreach (string exportName in exportNames) - { - if (exportName.StartsWith(NativePluginPrefix, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "The harness refuses a DLL that exposes a Cheat Engine native-plugin entry point."); - } - } - } - - private static void EnsureExpectedExports(IReadOnlyList exportNames) - { - foreach (string requiredName in NativeAotLibraryProbeExportNames.Required) - { - bool found = false; - foreach (string exportName in exportNames) - { - if (!string.Equals(exportName, requiredName, StringComparison.Ordinal)) continue; - - found = true; - break; - } - - if (!found) - throw new InvalidOperationException($"The file does not expose required fixture export '{requiredName}'."); - } - } - - private sealed class LibraryInspection(string path, string sha256, FileStream fileLock) : IDisposable - { - public string Path { get; } = path; - - public string Sha256 { get; } = sha256; - - public void Dispose() => fileLock.Dispose(); - } + private const string AnalyzeMode = "--analyze"; + private const string LoadMode = "--load"; + private const string AcknowledgeProcessResidentLoad = "--acknowledge-process-resident-load"; + private const string NativePluginPrefix = "CEPlugin_"; + private const string FixtureFileName = "CheatEngine.SDK.NativeAotLibraryProbe.dll"; + + /// Runs the requested bounded observation. + public static int Main(string[] arguments) + { + try + { + return Run(arguments); + } + catch (Exception exception) + { + Console.Error.WriteLine($"harness.error={exception.Message}"); + return 1; + } + } + + private static int Run(string[] arguments) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("The SDK-006 NativeAOT library profile is Windows x64 only."); + } + + if (arguments.Length == 2 && string.Equals(arguments[0], AnalyzeMode, StringComparison.Ordinal)) + { + Inspect(arguments[1]); + return 0; + } + + if (arguments.Length == 2 && string.Equals(arguments[0], LoadMode, StringComparison.Ordinal) && + string.Equals(arguments[1], AcknowledgeProcessResidentLoad, StringComparison.Ordinal)) + { + LoadAndQueryNames(); + return 0; + } + + throw new ArgumentException( + "Usage: --analyze | --load --acknowledge-process-resident-load", + nameof(arguments)); + } + + private static void Inspect(string libraryPath) + { + using LibraryInspection inspection = ReadAndValidateProbe(libraryPath); + Console.WriteLine("mode=byte-only-analysis"); + Console.WriteLine($"library.path={inspection.Path}"); + Console.WriteLine($"library.sha256={inspection.Sha256}"); + Console.WriteLine("library.mapped=false"); + + foreach (string exportName in NativeAotLibraryProbeExportNames.Required) + { + Console.WriteLine($"export.name.{exportName}=present"); + } + } + + private static void LoadAndQueryNames() + { + string fixturePath = Path.Combine(AppContext.BaseDirectory, FixtureFileName); + using LibraryInspection inspection = ReadAndValidateProbe(fixturePath); + Console.WriteLine("mode=process-resident-name-query"); + Console.WriteLine($"library.path={inspection.Path}"); + Console.WriteLine($"library.sha256={inspection.Sha256}"); + Console.WriteLine("library.identity=profile-adjacent-fixture"); + Console.WriteLine("library.activation=not-attempted"); + + nint module = NativeLibrary.Load(inspection.Path); + Console.WriteLine("library.mapped=true"); + + foreach (string exportName in NativeAotLibraryProbeExportNames.Required) + { + if (!NativeLibrary.TryGetExport(module, exportName, out nint address)) + { + throw new InvalidOperationException($"The mapped fixture does not expose '{exportName}'."); + } + + if (address == 0) + { + throw new InvalidOperationException($"The mapped fixture resolved '{exportName}' to a null address."); + } + + Console.WriteLine($"export.query.{exportName}=present"); + } + + // NativeAOT shared-library unload is unsupported. This dedicated process exits after the observation instead. + Console.WriteLine("library.unload=not-attempted"); + } + + private static LibraryInspection ReadAndValidateProbe(string libraryPath) + { + string fullPath = Path.GetFullPath(libraryPath); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException("The NativeAOT library probe does not exist.", fullPath); + } + + FileStream fileLock = new(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + byte[] bytes; + try + { + if (fileLock.Length > int.MaxValue) + { + throw new InvalidOperationException("The NativeAOT library probe is too large to inspect safely."); + } + + bytes = new byte[(int) fileLock.Length]; + fileLock.ReadExactly(bytes); + } + catch + { + fileLock.Dispose(); + throw; + } + + string sha256 = Convert.ToHexString(SHA256.HashData(bytes)); + List exportNames; + try + { + exportNames = PortableExecutableExportReader.ReadExportNames(bytes); + EnsureNoNativePluginExports(exportNames); + EnsureExpectedExports(exportNames); + } + catch + { + fileLock.Dispose(); + throw; + } + + return new LibraryInspection(fullPath, sha256, fileLock); + } + + private static void EnsureNoNativePluginExports(IReadOnlyList exportNames) + { + foreach (string exportName in exportNames) + { + if (exportName.StartsWith(NativePluginPrefix, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The harness refuses a DLL that exposes a Cheat Engine native-plugin entry point."); + } + } + } + + private static void EnsureExpectedExports(IReadOnlyList exportNames) + { + foreach (string requiredName in NativeAotLibraryProbeExportNames.Required) + { + bool found = false; + foreach (string exportName in exportNames) + { + if (!string.Equals(exportName, requiredName, StringComparison.Ordinal)) + { + continue; + } + + found = true; + break; + } + + if (!found) + { + throw new InvalidOperationException( + $"The file does not expose required fixture export '{requiredName}'."); + } + } + } + + private sealed class LibraryInspection(string path, string sha256, FileStream fileLock) : IDisposable + { + public string Path + { + get; + } = path; + + public string Sha256 + { + get; + } = sha256; + + public void Dispose() + { + fileLock.Dispose(); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/MemoryScalarsEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/MemoryScalarsEndToEndTests.cs index a0739453..ae629414 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/MemoryScalarsEndToEndTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/MemoryScalarsEndToEndTests.cs @@ -17,159 +17,165 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.EndToEnd; [Trait("Category", "NativeLua")] public sealed class MemoryScalarsEndToEndTests(RoslynFixture roslyn) : IClassFixture { - private const string BindingsType = "Demo.EndToEnd.MemoryScalars"; - - private static ReadOnlySpan StandIns => """ - local mem32 = {} - local mem64 = {} - -- Cheat Engine returns unsigned 32-bit values unless its optional signed flag is true. - -- Preserve that contract here so the generated binding must supply the flag. - function readInteger(address, signed) - local value = mem32[address] - if value == nil or signed then return value end - return value < 0 and value + 4294967296 or value - end - function writeInteger(address, value) mem32[address] = value; return true end - function readQword(address) return mem64[address] end - function writeQword(address, value) mem64[address] = value; return true end - beeps = 0 - function beep() beeps = beeps + 1 end - """u8; - - [Fact] - public void Write_then_read_round_trips_signed_32_bit_boundaries_and_a_missing_address_reads_as_false() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var write = assembly.Delegate(BindingsType, "WriteInt32"); - var tryRead = assembly.Delegate(BindingsType, "TryReadInt32"); - - Assert.True(write(0x1000, 42)); - Assert.True(tryRead(0x1000, out var value)); - Assert.Equal(42, value); - - foreach (var expected in new[] { int.MinValue, -7, -1 }) - { - Assert.True(write(0x1000, expected)); - Assert.True(tryRead(0x1000, out var actual)); - Assert.Equal(expected, actual); - } - - Assert.False(tryRead(0x2000, out var missing)); - Assert.Equal(0, missing); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Write_then_read_round_trips_a_64_bit_value_that_does_not_fit_32_bits() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var write = assembly.Delegate(BindingsType, "WriteInt64"); - var tryRead = assembly.Delegate(BindingsType, "TryReadInt64"); - - const long Large = 0x1_0000_0000L; // does not fit an int, proving the 64-bit wrapper reads it whole - Assert.True(write(0x3000, Large)); - Assert.True(tryRead(0x3000, out var value)); - Assert.Equal(Large, value); - Assert.Equal(0, L.Top); - } - - [Fact] - public void The_two_scalar_widths_use_independent_storage() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var write32 = assembly.Delegate(BindingsType, "WriteInt32"); - var tryRead64 = assembly.Delegate(BindingsType, "TryReadInt64"); - - Assert.True(write32(0x4000, 1)); - Assert.False(tryRead64(0x4000, out var value)); // never written to the 64-bit table - Assert.Equal(0, value); - } - - [Fact] - public void The_void_throwing_form_calls_the_global() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var beep = LoadSuite(roslyn).Delegate(BindingsType, "Beep"); - - beep(); - beep(); - beep(); - - using LuaFrame frame = new(L); - Assert.True(L.TryExecute("return beeps"u8, 1, "=test"u8).IsOk); - Assert.True(L.TryReadInteger(-1, out var beeps)); - Assert.Equal(3, beeps); - } - - [Fact] - public void Wrappers_throw_while_the_runtime_is_detached() - { - LuaTest.RequireNativeLua(); - LuaRuntime.Detach(); - var tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); - - Assert.Throws(() => tryRead(0x1000, out _)); - } - - [Fact] - public void Warm_round_trip_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var write = assembly.Delegate(BindingsType, "WriteInt32"); - var tryRead = assembly.Delegate(BindingsType, "TryReadInt32"); - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!write(0x5000, 99)) throw new InvalidOperationException("write failed"); - - if (!tryRead(0x5000, out var value) || value != 99) throw new InvalidOperationException("wrong value"); - - sink += value; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) - { - return GeneratedAssembly.Load(roslyn.Run("end-to-end.cheatengine-sdk-api.txt", SpecSources.EndToEnd)); - } - - // CheatEngine.SDK.Engine.Values.Address here is Infrastructure/Address.cs, not the real CheatEngine.SDK.Engine (see its own doc - // comment): the public wrapper's address-typed surface (Emit/EngineApiFileEmitter.cs, EmitAddressTypedWrapper). - private delegate bool TryReadInt32Delegate(Address address, out int value); - - private delegate bool WriteInt32Delegate(Address address, int value); - - private delegate bool TryReadInt64Delegate(Address address, out long value); - - private delegate bool WriteInt64Delegate(Address address, long value); - - private delegate void BeepDelegate(); + private const string BindingsType = "Demo.EndToEnd.MemoryScalars"; + + private static ReadOnlySpan StandIns => """ + local mem32 = {} + local mem64 = {} + -- Cheat Engine returns unsigned 32-bit values unless its optional signed flag is true. + -- Preserve that contract here so the generated binding must supply the flag. + function readInteger(address, signed) + local value = mem32[address] + if value == nil or signed then return value end + return value < 0 and value + 4294967296 or value + end + function writeInteger(address, value) mem32[address] = value; return true end + function readQword(address) return mem64[address] end + function writeQword(address, value) mem64[address] = value; return true end + beeps = 0 + function beep() beeps = beeps + 1 end + """u8; + + [Fact] + public void Write_then_read_round_trips_signed_32_bit_boundaries_and_a_missing_address_reads_as_false() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + WriteInt32Delegate write = assembly.Delegate(BindingsType, "WriteInt32"); + TryReadInt32Delegate tryRead = assembly.Delegate(BindingsType, "TryReadInt32"); + + Assert.True(write(0x1000, 42)); + Assert.True(tryRead(0x1000, out int value)); + Assert.Equal(42, value); + + foreach (int expected in new[] { int.MinValue, -7, -1 }) + { + Assert.True(write(0x1000, expected)); + Assert.True(tryRead(0x1000, out int actual)); + Assert.Equal(expected, actual); + } + + Assert.False(tryRead(0x2000, out int missing)); + Assert.Equal(0, missing); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Write_then_read_round_trips_a_64_bit_value_that_does_not_fit_32_bits() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + WriteInt64Delegate write = assembly.Delegate(BindingsType, "WriteInt64"); + TryReadInt64Delegate tryRead = assembly.Delegate(BindingsType, "TryReadInt64"); + + const long Large = 0x1_0000_0000L; // does not fit an int, proving the 64-bit wrapper reads it whole + Assert.True(write(0x3000, Large)); + Assert.True(tryRead(0x3000, out long value)); + Assert.Equal(Large, value); + Assert.Equal(0, L.Top); + } + + [Fact] + public void The_two_scalar_widths_use_independent_storage() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + WriteInt32Delegate write32 = assembly.Delegate(BindingsType, "WriteInt32"); + TryReadInt64Delegate tryRead64 = assembly.Delegate(BindingsType, "TryReadInt64"); + + Assert.True(write32(0x4000, 1)); + Assert.False(tryRead64(0x4000, out long value)); // never written to the 64-bit table + Assert.Equal(0, value); + } + + [Fact] + public void The_void_throwing_form_calls_the_global() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + BeepDelegate beep = LoadSuite(roslyn).Delegate(BindingsType, "Beep"); + + beep(); + beep(); + beep(); + + using LuaFrame frame = new(L); + Assert.True(L.TryExecute("return beeps"u8, 1, "=test"u8).IsOk); + Assert.True(L.TryReadInteger(-1, out long beeps)); + Assert.Equal(3, beeps); + } + + [Fact] + public void Wrappers_throw_while_the_runtime_is_detached() + { + LuaTest.RequireNativeLua(); + LuaRuntime.Detach(); + TryReadInt32Delegate tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); + + Assert.Throws(() => tryRead(0x1000, out _)); + } + + [Fact] + public void Warm_round_trip_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + WriteInt32Delegate write = assembly.Delegate(BindingsType, "WriteInt32"); + TryReadInt32Delegate tryRead = assembly.Delegate(BindingsType, "TryReadInt32"); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!write(0x5000, 99)) + { + throw new InvalidOperationException("write failed"); + } + + if (!tryRead(0x5000, out int value) || value != 99) + { + throw new InvalidOperationException("wrong value"); + } + + sink += value; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run("end-to-end.cheatengine-sdk-api.txt", SpecSources.EndToEnd)); + } + + // CheatEngine.SDK.Engine.Values.Address here is Infrastructure/Address.cs, not the real CheatEngine.SDK.Engine (see its own doc + // comment): the public wrapper's address-typed surface (Emit/EngineApiFileEmitter.cs, EmitAddressTypedWrapper). + private delegate bool TryReadInt32Delegate(Address address, out int value); + + private delegate bool WriteInt32Delegate(Address address, int value); + + private delegate bool TryReadInt64Delegate(Address address, out long value); + + private delegate bool WriteInt64Delegate(Address address, long value); + + private delegate void BeepDelegate(); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs index 8b215ed2..8c3ecfa1 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs @@ -1,5 +1,7 @@ using System.Globalization; + using CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Generator; @@ -7,148 +9,153 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Generator; /// Regression tests for diagnostics that point at malformed or conflicting additional spec files. public sealed class DiagnosticsTests(RoslynFixture roslyn) : IClassFixture { - /// Malformed input reports its additional-file line and does not hide a separately valid wrapper. - [Fact] - public void An_invalid_entry_reports_its_additional_file_line_and_column_while_a_valid_sibling_is_emitted() - { - const string Text = - "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: Bad\nform: try\nresult: value:int32\ndoc: bad.\nextra: value\n\nglobal: readQword\nmethod: Good\nform: try\nresult: value:int64\ndoc: good.\n"; - const string Path = "Specs/diagnostics.cheatengine-sdk-api.txt"; - - var run = roslyn.Run(Path, Text); - - Assert.Null(run.Result.Exception); - Assert.Single(run.GeneratedSources); - var diagnostic = Assert.Single(run.GeneratorDiagnostics); - Assert.Equal("CESDK3001", diagnostic.Id); - Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); - Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); - var span = diagnostic.Location.GetLineSpan(); - Assert.Equal(Path, span.Path); - Assert.Equal(8, span.StartLinePosition.Line); - Assert.Equal(0, span.StartLinePosition.Character); - Assert.Contains("Unknown entry key 'extra'", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - Assert.Contains("Good", run.SingleGeneratedText, StringComparison.Ordinal); - } - - /// Every ce77 contract field is validated as an additional-file diagnostic at its value, not as a C# error. - [Fact] - public void An_invalid_ce77_nil_contract_reports_the_exact_additional_file_value_location() - { - const string Path = "Specs/invalid-contract.cheatengine-sdk-api.txt"; - const string Text = - "namespace: Demo\ntype: Contract\ncontract: ce77\nprovenance: ExactInstalledFile: CE fixture\nminimum-ce: 7.7.0.10621\narchitecture: x64\nthread: unknown\nownership: none\n\nglobal: readInteger\nmethod: Read\nform: try\nresult: value:int32\nnil: ambiguous\ndoc: Reads an integer.\n"; - - var run = roslyn.Run(Path, Text); - - run.AssertNoGeneratedSource(); - var diagnostic = Assert.Single(run.GeneratorDiagnostics); - Assert.Equal("CESDK3001", diagnostic.Id); - var span = diagnostic.Location.GetLineSpan(); - Assert.Equal(Path, span.Path); - Assert.Equal(13, span.StartLinePosition.Line); - Assert.Equal(5, span.StartLinePosition.Character); - Assert.Contains("not a valid nil contract", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } - - /// A duplicate target type blocks only its owning specs and reports each participating additional file. - [Fact] - public void Conflicting_specs_report_each_participant_and_emit_neither_while_an_independent_type_is_emitted() - { - const string First = - "namespace: Demo\ntype: Duplicate\n\nglobal: readInteger\nmethod: First\nform: try\nresult: value:int32\ndoc: first.\n"; - const string Second = - "namespace: Demo\ntype: Duplicate\n\nglobal: readQword\nmethod: Second\nform: try\nresult: value:int64\ndoc: second.\n"; - - var run = roslyn.Run( - ("Specs/first.cheatengine-sdk-api.txt", First), - ("Specs/second.cheatengine-sdk-api.txt", Second), - ("Specs/independent.cheatengine-sdk-api.txt", SpecSources.BeepOnly)); - - Assert.Null(run.Result.Exception); - Assert.Single(run.GeneratedSources); - Assert.Contains("Other", run.SingleGeneratedText, StringComparison.Ordinal); - Assert.Equal(2, run.GeneratorDiagnostics.Length); - foreach (var diagnostic in run.GeneratorDiagnostics) - { - Assert.Equal("CESDK3002", diagnostic.Id); - Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); - Assert.Contains("Duplicate", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } - - AssertConflictLocation(run, "Generated type", "Specs/first.cheatengine-sdk-api.txt", 1, 6); - AssertConflictLocation(run, "Generated type", "Specs/second.cheatengine-sdk-api.txt", 1, 6); - } - - /// Duplicate wrapper and cache identities receive specific conflict diagnostics before code generation. - [Fact] - public void Conflicting_member_and_cache_identities_are_diagnosed_on_both_spec_files() - { - const string First = - "namespace: Demo\ntype: Duplicate\n\n global: readInteger\n method: Same\n form: try\n result: value:int32\n doc: first.\n"; - const string Second = - "namespace: Demo\ntype: Duplicate\n\n global: readInteger\n method: Same\n form: try\n result: value:int32\n doc: second.\n"; - - var run = roslyn.Run( - ("Specs/first.cheatengine-sdk-api.txt", First), - ("Specs/second.cheatengine-sdk-api.txt", Second)); - - run.AssertNoGeneratedSource(); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) - && diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains("member", StringComparison.Ordinal)); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) - && diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains("cache field", StringComparison.Ordinal)); - AssertConflictLocation(run, "Generated member", "Specs/first.cheatengine-sdk-api.txt", 4, 10); - AssertConflictLocation(run, "Generated member", "Specs/second.cheatengine-sdk-api.txt", 4, 10); - AssertConflictLocation(run, "Generated cache field", "Specs/first.cheatengine-sdk-api.txt", 3, 10); - AssertConflictLocation(run, "Generated cache field", "Specs/second.cheatengine-sdk-api.txt", 3, 10); - } - - /// Repeated file names from different directories always receive separate deterministic source hint names. - [Fact] - public void Three_same_named_spec_files_receive_unique_case_insensitive_hint_names() - { - const string First = - "namespace: Demo\ntype: First\n\nglobal: first\nmethod: LoadFirst\nform: throwing\ndoc: first.\n"; - const string Second = - "namespace: Demo\ntype: Second\n\nglobal: second\nmethod: LoadSecond\nform: throwing\ndoc: second.\n"; - const string Third = - "namespace: Demo\ntype: Third\n\nglobal: third\nmethod: LoadThird\nform: throwing\ndoc: third.\n"; - - var run = roslyn.Run( - ("One/shared.cheatengine-sdk-api.txt", First), - ("Two/shared.cheatengine-sdk-api.txt", Second), - ("Three/shared.cheatengine-sdk-api.txt", Third)); - - run.AssertCompilesClean(); - Assert.Equal(3, run.HintNames.Length); - HashSet distinct = new(run.HintNames, StringComparer.OrdinalIgnoreCase); - Assert.Equal(3, distinct.Count); - } - - private static void AssertConflictLocation(GeneratorRun run, string messageFragment, string path, int line, - int character) - { - foreach (var diagnostic in run.GeneratorDiagnostics) - { - if (!diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains(messageFragment, StringComparison.Ordinal)) - continue; - - var span = diagnostic.Location.GetLineSpan(); - if (!string.Equals(span.Path, path, StringComparison.Ordinal)) continue; - - Assert.Equal(line, span.StartLinePosition.Line); - Assert.Equal(character, span.StartLinePosition.Character); - return; - } - - Assert.Fail("Expected a CESDK3002 diagnostic containing '" + messageFragment + "' for '" + path + "'."); - } + /// Malformed input reports its additional-file line and does not hide a separately valid wrapper. + [Fact] + public void An_invalid_entry_reports_its_additional_file_line_and_column_while_a_valid_sibling_is_emitted() + { + const string Text = + "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: Bad\nform: try\nresult: value:int32\ndoc: bad.\nextra: value\n\nglobal: readQword\nmethod: Good\nform: try\nresult: value:int64\ndoc: good.\n"; + const string Path = "Specs/diagnostics.cheatengine-sdk-api.txt"; + + GeneratorRun run = roslyn.Run(Path, Text); + + Assert.Null(run.Result.Exception); + Assert.Single(run.GeneratedSources); + Diagnostic diagnostic = Assert.Single(run.GeneratorDiagnostics); + Assert.Equal("CESDK3001", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); + FileLinePositionSpan span = diagnostic.Location.GetLineSpan(); + Assert.Equal(Path, span.Path); + Assert.Equal(8, span.StartLinePosition.Line); + Assert.Equal(0, span.StartLinePosition.Character); + Assert.Contains("Unknown entry key 'extra'", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + Assert.Contains("Good", run.SingleGeneratedText, StringComparison.Ordinal); + } + + /// Every ce77 contract field is validated as an additional-file diagnostic at its value, not as a C# error. + [Fact] + public void An_invalid_ce77_nil_contract_reports_the_exact_additional_file_value_location() + { + const string Path = "Specs/invalid-contract.cheatengine-sdk-api.txt"; + const string Text = + "namespace: Demo\ntype: Contract\ncontract: ce77\nprovenance: ExactInstalledFile: CE fixture\nminimum-ce: 7.7.0.10621\narchitecture: x64\nthread: unknown\nownership: none\n\nglobal: readInteger\nmethod: Read\nform: try\nresult: value:int32\nnil: ambiguous\ndoc: Reads an integer.\n"; + + GeneratorRun run = roslyn.Run(Path, Text); + + run.AssertNoGeneratedSource(); + Diagnostic diagnostic = Assert.Single(run.GeneratorDiagnostics); + Assert.Equal("CESDK3001", diagnostic.Id); + FileLinePositionSpan span = diagnostic.Location.GetLineSpan(); + Assert.Equal(Path, span.Path); + Assert.Equal(13, span.StartLinePosition.Line); + Assert.Equal(5, span.StartLinePosition.Character); + Assert.Contains("not a valid nil contract", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + + /// A duplicate target type blocks only its owning specs and reports each participating additional file. + [Fact] + public void Conflicting_specs_report_each_participant_and_emit_neither_while_an_independent_type_is_emitted() + { + const string First = + "namespace: Demo\ntype: Duplicate\n\nglobal: readInteger\nmethod: First\nform: try\nresult: value:int32\ndoc: first.\n"; + const string Second = + "namespace: Demo\ntype: Duplicate\n\nglobal: readQword\nmethod: Second\nform: try\nresult: value:int64\ndoc: second.\n"; + + GeneratorRun run = roslyn.Run( + ("Specs/first.cheatengine-sdk-api.txt", First), + ("Specs/second.cheatengine-sdk-api.txt", Second), + ("Specs/independent.cheatengine-sdk-api.txt", SpecSources.BeepOnly)); + + Assert.Null(run.Result.Exception); + Assert.Single(run.GeneratedSources); + Assert.Contains("Other", run.SingleGeneratedText, StringComparison.Ordinal); + Assert.Equal(2, run.GeneratorDiagnostics.Length); + foreach (Diagnostic diagnostic in run.GeneratorDiagnostics) + { + Assert.Equal("CESDK3002", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("Duplicate", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + AssertConflictLocation(run, "Generated type", "Specs/first.cheatengine-sdk-api.txt", 1, 6); + AssertConflictLocation(run, "Generated type", "Specs/second.cheatengine-sdk-api.txt", 1, 6); + } + + /// Duplicate wrapper and cache identities receive specific conflict diagnostics before code generation. + [Fact] + public void Conflicting_member_and_cache_identities_are_diagnosed_on_both_spec_files() + { + const string First = + "namespace: Demo\ntype: Duplicate\n\n global: readInteger\n method: Same\n form: try\n result: value:int32\n doc: first.\n"; + const string Second = + "namespace: Demo\ntype: Duplicate\n\n global: readInteger\n method: Same\n form: try\n result: value:int32\n doc: second.\n"; + + GeneratorRun run = roslyn.Run( + ("Specs/first.cheatengine-sdk-api.txt", First), + ("Specs/second.cheatengine-sdk-api.txt", Second)); + + run.AssertNoGeneratedSource(); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) + && diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains("member", StringComparison.Ordinal)); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) + && diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains("cache field", StringComparison.Ordinal)); + AssertConflictLocation(run, "Generated member", "Specs/first.cheatengine-sdk-api.txt", 4, 10); + AssertConflictLocation(run, "Generated member", "Specs/second.cheatengine-sdk-api.txt", 4, 10); + AssertConflictLocation(run, "Generated cache field", "Specs/first.cheatengine-sdk-api.txt", 3, 10); + AssertConflictLocation(run, "Generated cache field", "Specs/second.cheatengine-sdk-api.txt", 3, 10); + } + + /// Repeated file names from different directories always receive separate deterministic source hint names. + [Fact] + public void Three_same_named_spec_files_receive_unique_case_insensitive_hint_names() + { + const string First = + "namespace: Demo\ntype: First\n\nglobal: first\nmethod: LoadFirst\nform: throwing\ndoc: first.\n"; + const string Second = + "namespace: Demo\ntype: Second\n\nglobal: second\nmethod: LoadSecond\nform: throwing\ndoc: second.\n"; + const string Third = + "namespace: Demo\ntype: Third\n\nglobal: third\nmethod: LoadThird\nform: throwing\ndoc: third.\n"; + + GeneratorRun run = roslyn.Run( + ("One/shared.cheatengine-sdk-api.txt", First), + ("Two/shared.cheatengine-sdk-api.txt", Second), + ("Three/shared.cheatengine-sdk-api.txt", Third)); + + run.AssertCompilesClean(); + Assert.Equal(3, run.HintNames.Length); + HashSet distinct = new(run.HintNames, StringComparer.OrdinalIgnoreCase); + Assert.Equal(3, distinct.Count); + } + + private static void AssertConflictLocation(GeneratorRun run, string messageFragment, string path, int line, + int character) + { + foreach (Diagnostic diagnostic in run.GeneratorDiagnostics) + { + if (!diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains(messageFragment, StringComparison.Ordinal)) + { + continue; + } + + FileLinePositionSpan span = diagnostic.Location.GetLineSpan(); + if (!string.Equals(span.Path, path, StringComparison.Ordinal)) + { + continue; + } + + Assert.Equal(line, span.StartLinePosition.Line); + Assert.Equal(character, span.StartLinePosition.Character); + return; + } + + Assert.Fail("Expected a CESDK3002 diagnostic containing '" + messageFragment + "' for '" + path + "'."); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs index d5bc9d93..4dc7d209 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs @@ -5,235 +5,235 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Generator; /// The generated text for the nominal spec file, and that it compiles clean against the real SDK. public sealed class EmissionTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Nominal_spec_emits_one_file_with_the_namespace_type_cache_fields_and_four_wrappers() - { - var run = roslyn.Run("memory-scalars.cheatengine-sdk-api.txt", SpecSources.Memory); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - - Assert.StartsWith("// \n#nullable enable\n", text, StringComparison.Ordinal); - Assert.Contains("namespace Demo.Engine.Generated", text, StringComparison.Ordinal); - Assert.Contains("public static partial class MemoryScalars", text, StringComparison.Ordinal); - Assert.Contains( - "private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_readInteger = new();", - text, - StringComparison.Ordinal); - Assert.Contains( - "private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_writeQword = new();", - text, - StringComparison.Ordinal); - Assert.Contains( - "CE >= 7.7.0.10621; architecture: x64; thread: unknown; ownership: none; return: bool with out results; nil: absence; provenance: ExactInstalledFile: CE 7.7 celua.txt scalar memory globals.", - text, - StringComparison.Ordinal); - - // Every entry declares an 'address' argument, so each gets the two-method split (Emit/EngineApiFileEmitter.cs, - // EmitAddressTypedWrapper): a private nuint core (LuaGlobalCallEmitter's own call shape, untouched) plus a - // public wrapper typed with CheatEngine.SDK.Engine.Values.Address. A CE-side address is a ulong, not a nuint, so the - // public surface never exposes the nuint-typed parameter. - Assert.Contains("private static bool __TryReadInt32Raw(nuint address, out int value)", text, - StringComparison.Ordinal); - Assert.Contains( - "public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value)", - text, - StringComparison.Ordinal); - Assert.Contains("private static bool __TryReadInt64Raw(nuint address, out long value)", text, - StringComparison.Ordinal); - Assert.Contains( - "public static bool TryReadInt64(global::CheatEngine.SDK.Engine.Values.Address address, out long value)", - text, - StringComparison.Ordinal); - Assert.Contains("private static bool __WriteInt32Raw(nuint address, int value)", text, - StringComparison.Ordinal); - Assert.Contains( - "public static bool WriteInt32(global::CheatEngine.SDK.Engine.Values.Address address, int value)", text, - StringComparison.Ordinal); - Assert.Contains("private static bool __WriteInt64Raw(nuint address, long value)", text, - StringComparison.Ordinal); - Assert.Contains( - "public static bool WriteInt64(global::CheatEngine.SDK.Engine.Values.Address address, long value)", text, - StringComparison.Ordinal); - Assert.Contains( - "[global::System.CodeDom.Compiler.GeneratedCode(\"CheatEngine.SDK.SourceGenerators.EngineApi\",", text, - StringComparison.Ordinal); - Assert.DoesNotContain("partial bool", text, - StringComparison.Ordinal); // complete declarations, never a partial-method body - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(__L, true);", text, - StringComparison.Ordinal); - Assert.Contains("if (!__L.TryCall(2, 1).IsOk)", text, StringComparison.Ordinal); - } - - [Fact] - public void The_try_form_body_matches_the_documented_call_shape_exactly() - { - var run = roslyn.Run("single.cheatengine-sdk-api.txt", SpecSources.SingleTry); - run.AssertCompilesClean(); - - // The private core: byte-for-byte the shape LuaGlobalCallEmitter produces, just renamed and - // made private by EmitAddressTypedWrapper. - var expectedCore = """ - private static bool __TryReadInt32Raw(nuint address, out int value) - { - using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); - global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; - int __top = __L.Top; - try - { - if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_readInteger, "readInteger"u8)) - { - return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); - } - - global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); - if (!__L.TryCall(1, 1).IsOk) - { - return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); - } - - bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); - return __ok; - } - catch (global::CheatEngine.SDK.Lua.Calls.LuaException) - { - value = default; - return false; - } - finally - { - __L.SetTop(__top); - } - } - """; - Assert.Contains(Indented(expectedCore), run.SingleGeneratedText, StringComparison.Ordinal); - - // The public wrapper: the only new code, a two-line boundary conversion. - var expectedWrapper = """ - public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value) - { - return __TryReadInt32Raw(unchecked((nuint)address.ToUInt64()), out value); - } - """; - Assert.Contains(Indented(expectedWrapper), run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Two_forms_of_the_same_global_declare_one_cache_field_and_two_methods() - { - var run = roslyn.Run("shared.cheatengine-sdk-api.txt", SpecSources.SharedGlobal); - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - - Assert.Equal(1, CountOccurrences(text, "s_luaGlobal_readInteger = new();")); - Assert.Contains( - "public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value)", - text, - StringComparison.Ordinal); - Assert.Contains("public static int ReadInt32(global::CheatEngine.SDK.Engine.Values.Address address)", text, - StringComparison.Ordinal); - } - - [Fact] - public void The_global_namespace_is_emitted_without_a_namespace_block() - { - const string Text = """ - namespace: - type: Root - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - """; - - var run = roslyn.Run("root.cheatengine-sdk-api.txt", Text); - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - - Assert.DoesNotContain("namespace ", text, StringComparison.Ordinal); - Assert.Contains("public static partial class Root", text, StringComparison.Ordinal); - } - - /// Address Try results are public target addresses, not host-width integers. - [Fact] - public void An_address_try_result_is_exposed_as_Address_and_converted_from_the_raw_core() - { - const string Text = - "namespace: Demo\ntype: Addresses\n\nglobal: getAddress\nmethod: TryGetAddress\nform: try\nresult: value:address\ndoc: Gets a target address.\n"; - - var run = roslyn.Run("address-result.cheatengine-sdk-api.txt", Text); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains("private static bool __TryGetAddressRaw(out nuint value)", text, StringComparison.Ordinal); - Assert.Contains("public static bool TryGetAddress(out global::CheatEngine.SDK.Engine.Values.Address value)", - text, - StringComparison.Ordinal); - Assert.Contains("nuint __engineApiRawResult0;", text, StringComparison.Ordinal); - Assert.Contains( - "value = new global::CheatEngine.SDK.Engine.Values.Address(unchecked((ulong)__engineApiRawResult0));", - text, StringComparison.Ordinal); - } - - /// Address throwing returns are public target addresses, not host-width integers. - [Fact] - public void An_address_throwing_return_is_exposed_as_Address_and_converted_from_the_raw_core() - { - const string Text = - "namespace: Demo\ntype: Addresses\n\nglobal: getAddress\nmethod: GetAddress\nform: throwing\nreturn: address\ndoc: Gets a target address.\n"; - - var run = roslyn.Run("address-return.cheatengine-sdk-api.txt", Text); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains("private static nuint __GetAddressRaw()", text, StringComparison.Ordinal); - Assert.Contains("public static global::CheatEngine.SDK.Engine.Values.Address GetAddress()", text, - StringComparison.Ordinal); - Assert.Contains( - "return new global::CheatEngine.SDK.Engine.Values.Address(unchecked((ulong)__engineApiRawResult));", - text, StringComparison.Ordinal); - Assert.DoesNotContain("public static nuint", text, StringComparison.Ordinal); - } - - /// Address arguments and results retain their strong target-address contract in the public signature. - [Fact] - public void Address_arguments_and_try_results_keep_the_complete_public_signature_strongly_typed() - { - const string Text = - "namespace: Demo\ntype: Addresses\n\nglobal: resolvePointer\nmethod: TryResolvePointer\nform: try\narg: address:address\nresult: result:address\ndoc: Resolves a target pointer.\n"; - - var run = roslyn.Run("address-argument-and-result.cheatengine-sdk-api.txt", Text); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains( - "public static bool TryResolvePointer(global::CheatEngine.SDK.Engine.Values.Address address, out global::CheatEngine.SDK.Engine.Values.Address result)", - text, StringComparison.Ordinal); - Assert.DoesNotContain("public static bool TryResolvePointer(nuint", text, StringComparison.Ordinal); - Assert.Contains("unchecked((nuint)address.ToUInt64())", text, StringComparison.Ordinal); - } - - private static int CountOccurrences(string text, string value) - { - var count = 0; - var index = 0; - while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0) - { - count++; - index += value.Length; - } - - return count; - } - - // The method sits two levels deep (namespace, then the partial class): 8 spaces, SourceWriter's convention. - private static string Indented(string text) - { - return string.Join('\n', - text.ReplaceLineEndings("\n").Split('\n') - .Select(static line => line.Length > 0 ? " " + line : line)); - } + [Fact] + public void Nominal_spec_emits_one_file_with_the_namespace_type_cache_fields_and_four_wrappers() + { + GeneratorRun run = roslyn.Run("memory-scalars.cheatengine-sdk-api.txt", SpecSources.Memory); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + + Assert.StartsWith("// \n#nullable enable\n", text, StringComparison.Ordinal); + Assert.Contains("namespace Demo.Engine.Generated", text, StringComparison.Ordinal); + Assert.Contains("public static partial class MemoryScalars", text, StringComparison.Ordinal); + Assert.Contains( + "private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_readInteger = new();", + text, + StringComparison.Ordinal); + Assert.Contains( + "private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_writeQword = new();", + text, + StringComparison.Ordinal); + Assert.Contains( + "CE >= 7.7.0.10621; architecture: x64; thread: unknown; ownership: none; return: bool with out results; nil: absence; provenance: ExactInstalledFile: CE 7.7 celua.txt scalar memory globals.", + text, + StringComparison.Ordinal); + + // Every entry declares an 'address' argument, so each gets the two-method split (Emit/EngineApiFileEmitter.cs, + // EmitAddressTypedWrapper): a private nuint core (LuaGlobalCallEmitter's own call shape, untouched) plus a + // public wrapper typed with CheatEngine.SDK.Engine.Values.Address. A CE-side address is a ulong, not a nuint, so the + // public surface never exposes the nuint-typed parameter. + Assert.Contains("private static bool __TryReadInt32Raw(nuint address, out int value)", text, + StringComparison.Ordinal); + Assert.Contains( + "public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value)", + text, + StringComparison.Ordinal); + Assert.Contains("private static bool __TryReadInt64Raw(nuint address, out long value)", text, + StringComparison.Ordinal); + Assert.Contains( + "public static bool TryReadInt64(global::CheatEngine.SDK.Engine.Values.Address address, out long value)", + text, + StringComparison.Ordinal); + Assert.Contains("private static bool __WriteInt32Raw(nuint address, int value)", text, + StringComparison.Ordinal); + Assert.Contains( + "public static bool WriteInt32(global::CheatEngine.SDK.Engine.Values.Address address, int value)", text, + StringComparison.Ordinal); + Assert.Contains("private static bool __WriteInt64Raw(nuint address, long value)", text, + StringComparison.Ordinal); + Assert.Contains( + "public static bool WriteInt64(global::CheatEngine.SDK.Engine.Values.Address address, long value)", text, + StringComparison.Ordinal); + Assert.Contains( + "[global::System.CodeDom.Compiler.GeneratedCode(\"CheatEngine.SDK.SourceGenerators.EngineApi\",", text, + StringComparison.Ordinal); + Assert.DoesNotContain("partial bool", text, + StringComparison.Ordinal); // complete declarations, never a partial-method body + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(__L, true);", text, + StringComparison.Ordinal); + Assert.Contains("if (!__L.TryCall(2, 1).IsOk)", text, StringComparison.Ordinal); + } + + [Fact] + public void The_try_form_body_matches_the_documented_call_shape_exactly() + { + GeneratorRun run = roslyn.Run("single.cheatengine-sdk-api.txt", SpecSources.SingleTry); + run.AssertCompilesClean(); + + // The private core: byte-for-byte the shape LuaGlobalCallEmitter produces, just renamed and + // made private by EmitAddressTypedWrapper. + string expectedCore = """ + private static bool __TryReadInt32Raw(nuint address, out int value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_readInteger, "readInteger"u8)) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + if (!__L.TryCall(1, 1).IsOk) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); + return __ok; + } + catch (global::CheatEngine.SDK.Lua.Calls.LuaException) + { + value = default; + return false; + } + finally + { + __L.SetTop(__top); + } + } + """; + Assert.Contains(Indented(expectedCore), run.SingleGeneratedText, StringComparison.Ordinal); + + // The public wrapper: the only new code, a two-line boundary conversion. + string expectedWrapper = """ + public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value) + { + return __TryReadInt32Raw(unchecked((nuint)address.ToUInt64()), out value); + } + """; + Assert.Contains(Indented(expectedWrapper), run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Two_forms_of_the_same_global_declare_one_cache_field_and_two_methods() + { + GeneratorRun run = roslyn.Run("shared.cheatengine-sdk-api.txt", SpecSources.SharedGlobal); + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + + Assert.Equal(1, CountOccurrences(text, "s_luaGlobal_readInteger = new();")); + Assert.Contains( + "public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value)", + text, + StringComparison.Ordinal); + Assert.Contains("public static int ReadInt32(global::CheatEngine.SDK.Engine.Values.Address address)", text, + StringComparison.Ordinal); + } + + [Fact] + public void The_global_namespace_is_emitted_without_a_namespace_block() + { + const string Text = """ + namespace: + type: Root + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + """; + + GeneratorRun run = roslyn.Run("root.cheatengine-sdk-api.txt", Text); + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + + Assert.DoesNotContain("namespace ", text, StringComparison.Ordinal); + Assert.Contains("public static partial class Root", text, StringComparison.Ordinal); + } + + /// Address Try results are public target addresses, not host-width integers. + [Fact] + public void An_address_try_result_is_exposed_as_Address_and_converted_from_the_raw_core() + { + const string Text = + "namespace: Demo\ntype: Addresses\n\nglobal: getAddress\nmethod: TryGetAddress\nform: try\nresult: value:address\ndoc: Gets a target address.\n"; + + GeneratorRun run = roslyn.Run("address-result.cheatengine-sdk-api.txt", Text); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains("private static bool __TryGetAddressRaw(out nuint value)", text, StringComparison.Ordinal); + Assert.Contains("public static bool TryGetAddress(out global::CheatEngine.SDK.Engine.Values.Address value)", + text, + StringComparison.Ordinal); + Assert.Contains("nuint __engineApiRawResult0;", text, StringComparison.Ordinal); + Assert.Contains( + "value = new global::CheatEngine.SDK.Engine.Values.Address(unchecked((ulong)__engineApiRawResult0));", + text, StringComparison.Ordinal); + } + + /// Address throwing returns are public target addresses, not host-width integers. + [Fact] + public void An_address_throwing_return_is_exposed_as_Address_and_converted_from_the_raw_core() + { + const string Text = + "namespace: Demo\ntype: Addresses\n\nglobal: getAddress\nmethod: GetAddress\nform: throwing\nreturn: address\ndoc: Gets a target address.\n"; + + GeneratorRun run = roslyn.Run("address-return.cheatengine-sdk-api.txt", Text); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains("private static nuint __GetAddressRaw()", text, StringComparison.Ordinal); + Assert.Contains("public static global::CheatEngine.SDK.Engine.Values.Address GetAddress()", text, + StringComparison.Ordinal); + Assert.Contains( + "return new global::CheatEngine.SDK.Engine.Values.Address(unchecked((ulong)__engineApiRawResult));", + text, StringComparison.Ordinal); + Assert.DoesNotContain("public static nuint", text, StringComparison.Ordinal); + } + + /// Address arguments and results retain their strong target-address contract in the public signature. + [Fact] + public void Address_arguments_and_try_results_keep_the_complete_public_signature_strongly_typed() + { + const string Text = + "namespace: Demo\ntype: Addresses\n\nglobal: resolvePointer\nmethod: TryResolvePointer\nform: try\narg: address:address\nresult: result:address\ndoc: Resolves a target pointer.\n"; + + GeneratorRun run = roslyn.Run("address-argument-and-result.cheatengine-sdk-api.txt", Text); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains( + "public static bool TryResolvePointer(global::CheatEngine.SDK.Engine.Values.Address address, out global::CheatEngine.SDK.Engine.Values.Address result)", + text, StringComparison.Ordinal); + Assert.DoesNotContain("public static bool TryResolvePointer(nuint", text, StringComparison.Ordinal); + Assert.Contains("unchecked((nuint)address.ToUInt64())", text, StringComparison.Ordinal); + } + + private static int CountOccurrences(string text, string value) + { + int count = 0; + int index = 0; + while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + + return count; + } + + // The method sits two levels deep (namespace, then the partial class): 8 spaces, SourceWriter's convention. + private static string Indented(string text) + { + return string.Join('\n', + text.ReplaceLineEndings("\n").Split('\n') + .Select(static line => line.Length > 0 ? " " + line : line)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs index edc7b9ed..11923f81 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -13,124 +14,130 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Generator; /// public sealed class IncrementalityTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Pipeline_first_run_tracks_every_named_step_as_new() - { - var run = roslyn.Run("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); - - foreach (var stepName in EngineApiTrackingNames.All) - Assert.All(StepAssert.Reasons(run.Result, stepName), - static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); - - Assert.All(StepAssert.OutputReasons(run.Result), - static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); - } - - [Fact] - public void Pipeline_identical_rerun_recomputes_nothing() - { - InMemoryAdditionalText text = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); - var compilation = roslyn.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(text), compilation); - - var second = GeneratorRun.Execute(first.Driver, compilation); - - StepAssert.NothingWasRecomputed(second.Result); - Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText, StringComparer.Ordinal); - } - - [Fact] - public void Pipeline_unrelated_compilation_edit_recomputes_nothing() - { - InMemoryAdditionalText text = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); - var compilation = roslyn.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(text), compilation); - - var unrelated = CSharpSyntaxTree.ParseText( - "namespace Demo; public sealed class Unrelated { }", - RoslynEnvironment.ParseOptions, - "Unrelated.cs", - cancellationToken: TestContext.Current.CancellationToken); - var second = GeneratorRun.Execute(first.Driver, compilation.AddSyntaxTrees(unrelated)); - - StepAssert.NothingWasRecomputed(second.Result); - Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText, StringComparer.Ordinal); - } - - [Fact] - public void Pipeline_editing_one_spec_files_text_reruns_only_that_files_output() - { - InMemoryAdditionalText original = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); - InMemoryAdditionalText other = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); - var compilation = roslyn.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original, other), compilation); - Assert.Equal(2, first.GeneratedSources.Length); - - InMemoryAdditionalText edited = new("a.cheatengine-sdk-api.txt", - SpecSources.SingleTry.Replace("TryReadInt32", "TryReadRenamed", StringComparison.Ordinal)); - var updatedDriver = first.Driver.ReplaceAdditionalText(original, edited); - var second = GeneratorRun.Execute(updatedDriver, compilation); - - Assert.Equal(2, second.GeneratedSources.Length); - Assert.Contains(IncrementalStepRunReason.Modified, - StepAssert.Reasons(second.Result, EngineApiTrackingNames.ParsedSpec)); - Assert.Contains(IncrementalStepRunReason.Modified, - StepAssert.Reasons(second.Result, EngineApiTrackingNames.SpecFileOutput)); - Assert.Contains("TryReadRenamed", second.GeneratedTextByContent("TryReadRenamed"), StringComparison.Ordinal); - Assert.Equal(first.GeneratedTextByContent("Beep"), second.GeneratedTextByContent("Beep"), - StringComparer.Ordinal); - } - - [Fact] - public void Pipeline_adding_a_second_spec_file_leaves_the_first_files_output_cached() - { - InMemoryAdditionalText original = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); - var compilation = roslyn.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); - Assert.Single(first.GeneratedSources.AsEnumerable()); - - InMemoryAdditionalText added = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); - var updatedDriver = first.Driver.AddAdditionalTexts([added]); - var second = GeneratorRun.Execute(updatedDriver, compilation); - - Assert.Equal(2, second.GeneratedSources.Length); - Assert.Equal(first.SingleGeneratedText, second.GeneratedTextByContent("TryReadInt32"), StringComparer.Ordinal); - Assert.Contains("Beep", second.GeneratedTextByContent("Beep"), StringComparison.Ordinal); - } - - [Fact] - public void Pipeline_removing_a_spec_file_removes_only_its_output() - { - InMemoryAdditionalText a = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); - InMemoryAdditionalText b = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); - var compilation = roslyn.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(a, b), compilation); - Assert.Equal(2, first.GeneratedSources.Length); - - var updatedDriver = first.Driver.RemoveAdditionalTexts([b]); - var second = GeneratorRun.Execute(updatedDriver, compilation); - - Assert.Single(second.GeneratedSources.AsEnumerable()); - Assert.Equal(first.GeneratedTextByContent("TryReadInt32"), second.SingleGeneratedText, StringComparer.Ordinal); - } - - [Fact] - public void Pipeline_step_values_hold_no_roslyn_objects() - { - var run = roslyn.Run("a.cheatengine-sdk-api.txt", SpecSources.Memory); - - var visited = 0; - foreach (var stepName in EngineApiTrackingNames.All) - { - if (string.Equals(stepName, EngineApiTrackingNames.SpecTextFile, StringComparison.Ordinal)) - // Legitimately holds the raw AdditionalText: that is the point of this filter step. - continue; - - foreach (var step in run.Result.TrackedSteps[stepName]) - foreach (var (value, _) in step.Outputs) - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); - } - - Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); - } + [Fact] + public void Pipeline_first_run_tracks_every_named_step_as_new() + { + GeneratorRun run = roslyn.Run("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); + + foreach (string stepName in EngineApiTrackingNames.All) + { + Assert.All(StepAssert.Reasons(run.Result, stepName), + static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + } + + Assert.All(StepAssert.OutputReasons(run.Result), + static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + } + + [Fact] + public void Pipeline_identical_rerun_recomputes_nothing() + { + InMemoryAdditionalText text = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); + CSharpCompilation compilation = roslyn.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(text), compilation); + + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation); + + StepAssert.NothingWasRecomputed(second.Result); + Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText, StringComparer.Ordinal); + } + + [Fact] + public void Pipeline_unrelated_compilation_edit_recomputes_nothing() + { + InMemoryAdditionalText text = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); + CSharpCompilation compilation = roslyn.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(text), compilation); + + SyntaxTree unrelated = CSharpSyntaxTree.ParseText( + "namespace Demo; public sealed class Unrelated { }", + RoslynEnvironment.ParseOptions, + "Unrelated.cs", + cancellationToken: TestContext.Current.CancellationToken); + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation.AddSyntaxTrees(unrelated)); + + StepAssert.NothingWasRecomputed(second.Result); + Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText, StringComparer.Ordinal); + } + + [Fact] + public void Pipeline_editing_one_spec_files_text_reruns_only_that_files_output() + { + InMemoryAdditionalText original = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); + InMemoryAdditionalText other = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); + CSharpCompilation compilation = roslyn.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original, other), compilation); + Assert.Equal(2, first.GeneratedSources.Length); + + InMemoryAdditionalText edited = new("a.cheatengine-sdk-api.txt", + SpecSources.SingleTry.Replace("TryReadInt32", "TryReadRenamed", StringComparison.Ordinal)); + GeneratorDriver updatedDriver = first.Driver.ReplaceAdditionalText(original, edited); + GeneratorRun second = GeneratorRun.Execute(updatedDriver, compilation); + + Assert.Equal(2, second.GeneratedSources.Length); + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, EngineApiTrackingNames.ParsedSpec)); + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, EngineApiTrackingNames.SpecFileOutput)); + Assert.Contains("TryReadRenamed", second.GeneratedTextByContent("TryReadRenamed"), StringComparison.Ordinal); + Assert.Equal(first.GeneratedTextByContent("Beep"), second.GeneratedTextByContent("Beep"), + StringComparer.Ordinal); + } + + [Fact] + public void Pipeline_adding_a_second_spec_file_leaves_the_first_files_output_cached() + { + InMemoryAdditionalText original = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); + CSharpCompilation compilation = roslyn.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); + Assert.Single(first.GeneratedSources.AsEnumerable()); + + InMemoryAdditionalText added = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); + GeneratorDriver updatedDriver = first.Driver.AddAdditionalTexts([added]); + GeneratorRun second = GeneratorRun.Execute(updatedDriver, compilation); + + Assert.Equal(2, second.GeneratedSources.Length); + Assert.Equal(first.SingleGeneratedText, second.GeneratedTextByContent("TryReadInt32"), StringComparer.Ordinal); + Assert.Contains("Beep", second.GeneratedTextByContent("Beep"), StringComparison.Ordinal); + } + + [Fact] + public void Pipeline_removing_a_spec_file_removes_only_its_output() + { + InMemoryAdditionalText a = new("a.cheatengine-sdk-api.txt", SpecSources.SingleTry); + InMemoryAdditionalText b = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); + CSharpCompilation compilation = roslyn.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(a, b), compilation); + Assert.Equal(2, first.GeneratedSources.Length); + + GeneratorDriver updatedDriver = first.Driver.RemoveAdditionalTexts([b]); + GeneratorRun second = GeneratorRun.Execute(updatedDriver, compilation); + + Assert.Single(second.GeneratedSources.AsEnumerable()); + Assert.Equal(first.GeneratedTextByContent("TryReadInt32"), second.SingleGeneratedText, StringComparer.Ordinal); + } + + [Fact] + public void Pipeline_step_values_hold_no_roslyn_objects() + { + GeneratorRun run = roslyn.Run("a.cheatengine-sdk-api.txt", SpecSources.Memory); + + int visited = 0; + foreach (string stepName in EngineApiTrackingNames.All) + { + if (string.Equals(stepName, EngineApiTrackingNames.SpecTextFile, StringComparison.Ordinal)) + // Legitimately holds the raw AdditionalText: that is the point of this filter step. + { + continue; + } + + foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } + } + + Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs index b74729cd..300b65f1 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs @@ -5,59 +5,59 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Generator; /// Valid ignored or empty-shell inputs stay silent; malformed specs instead report a located CESDK3001 error. public sealed class NoOutputTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void An_additional_file_not_named_cheatengine_sdk_api_txt_is_ignored() - { - var run = roslyn.Run("notes.txt", SpecSources.SingleTry); - - run.AssertNoOutput(); - } - - [Fact] - public void An_empty_spec_file_produces_no_output_and_reports_a_specification_error() - { - var run = roslyn.Run("empty.cheatengine-sdk-api.txt", string.Empty); - - run.AssertNoGeneratedSource(); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK3001", StringComparison.Ordinal)); - } - - [Fact] - public void A_spec_file_with_only_a_header_produces_no_output() - { - var run = roslyn.Run("shell.cheatengine-sdk-api.txt", "namespace: Demo\ntype: Shell\n"); - - run.AssertNoOutput(); - } - - [Fact] - public void A_spec_file_whose_only_entry_is_invalid_produces_no_output_and_reports_a_specification_error() - { - const string Text = """ - namespace: Demo - type: T - - global: not a name - method: Bad - form: try - arg: address:address - result: value:int32 - doc: bad. - """; - - var run = roslyn.Run("bad.cheatengine-sdk-api.txt", Text); - - run.AssertNoGeneratedSource(); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK3001", StringComparison.Ordinal)); - } - - [Fact] - public void No_spec_files_at_all_produces_no_output() - { - var run = GeneratorRun.Execute(RoslynFixture.CreateDriver(), roslyn.CreateCompilation()); - - run.AssertNoOutput(); - } + [Fact] + public void An_additional_file_not_named_cheatengine_sdk_api_txt_is_ignored() + { + GeneratorRun run = roslyn.Run("notes.txt", SpecSources.SingleTry); + + run.AssertNoOutput(); + } + + [Fact] + public void An_empty_spec_file_produces_no_output_and_reports_a_specification_error() + { + GeneratorRun run = roslyn.Run("empty.cheatengine-sdk-api.txt", string.Empty); + + run.AssertNoGeneratedSource(); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK3001", StringComparison.Ordinal)); + } + + [Fact] + public void A_spec_file_with_only_a_header_produces_no_output() + { + GeneratorRun run = roslyn.Run("shell.cheatengine-sdk-api.txt", "namespace: Demo\ntype: Shell\n"); + + run.AssertNoOutput(); + } + + [Fact] + public void A_spec_file_whose_only_entry_is_invalid_produces_no_output_and_reports_a_specification_error() + { + const string Text = """ + namespace: Demo + type: T + + global: not a name + method: Bad + form: try + arg: address:address + result: value:int32 + doc: bad. + """; + + GeneratorRun run = roslyn.Run("bad.cheatengine-sdk-api.txt", Text); + + run.AssertNoGeneratedSource(); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK3001", StringComparison.Ordinal)); + } + + [Fact] + public void No_spec_files_at_all_produces_no_output() + { + GeneratorRun run = GeneratorRun.Execute(RoslynFixture.CreateDriver(), roslyn.CreateCompilation()); + + run.AssertNoOutput(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/Address.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/Address.cs index c3ac39ed..1c4e040e 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/Address.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/Address.cs @@ -37,27 +37,30 @@ namespace CheatEngine.SDK.Engine.Values; /// public readonly struct Address { - /// Wraps a raw address, mirroring the real type's constructor. - /// The address. - public Address(ulong value) - { - Value = value; - } + /// Wraps a raw address, mirroring the real type's constructor. + /// The address. + public Address(ulong value) + { + Value = value; + } - /// Gets the raw address. - public ulong Value { get; } + /// Gets the raw address. + public ulong Value + { + get; + } - /// Converts a raw value, mirroring the real type's implicit conversion. - /// The address. - public static implicit operator Address(ulong value) - { - return new Address(value); - } + /// Converts a raw value, mirroring the real type's implicit conversion. + /// The address. + public static implicit operator Address(ulong value) + { + return new Address(value); + } - /// Unwraps the raw value, mirroring the real type's named accessor. - /// The raw address. - public ulong ToUInt64() - { - return Value; - } + /// Unwraps the raw value, mirroring the real type's named accessor. + /// The raw address. + public ulong ToUInt64() + { + return Value; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/AllocationGate.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/AllocationGate.cs index ee091e3a..28fa5d3e 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/AllocationGate.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/AllocationGate.cs @@ -8,16 +8,22 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal static class AllocationGate { - public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) - { - for (var i = 0; i < warmUp; i++) body(); + public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) + { + for (int i = 0; i < warmUp; i++) + { + body(); + } - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < iterations; i++) body(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + body(); + } - var allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(allocated == 0, - string.Create(CultureInfo.InvariantCulture, - $"{allocated} bytes were allocated over {iterations} iterations ({(double)allocated / iterations:F1} per call).")); - } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated == 0, + string.Create(CultureInfo.InvariantCulture, + $"{allocated} bytes were allocated over {iterations} iterations ({(double) allocated / iterations:F1} per call).")); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs index 0209a61c..99962168 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs @@ -2,6 +2,8 @@ using System.Reflection; using System.Runtime.Loader; +using Microsoft.CodeAnalysis.Emit; + namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// @@ -12,46 +14,49 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal sealed class GeneratedAssembly { - private const BindingFlags StaticMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; - - private static int s_counter; - - private GeneratedAssembly(Assembly assembly) - { - Assembly = assembly; - } - - /// The loaded assembly. - public Assembly Assembly { get; } - - /// - /// Compiles, emits and loads 's output; fails the test when it does not compile clean or - /// emit. - /// - public static GeneratedAssembly Load(GeneratorRun run) - { - run.AssertCompilesClean(); - - using MemoryStream image = new(); - var result = run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(result.Success, "The output compilation does not emit:\n" + string.Join('\n', result.Diagnostics)); - image.Position = 0; - - var assemblyName = "CheatEngine.SDK.EngineApi.Tests." + - Interlocked.Increment(ref s_counter).ToString(CultureInfo.InvariantCulture); - AssemblyLoadContext context = new(assemblyName); - return new GeneratedAssembly(context.LoadFromStream(image)); - } - - /// A delegate over a static method, for calls that must not allocate (reflection invocation does). - public TDelegate Delegate(string typeName, string methodName) - where TDelegate : Delegate - { - var parameters = typeof(TDelegate).GetMethod("Invoke")!.GetParameters(); - Type[] parameterTypes = [.. parameters.Select(static parameter => parameter.ParameterType)]; - var type = Assembly.GetType(typeName, true)!; - var method = type.GetMethod(methodName, StaticMembers, parameterTypes) ?? - throw new MissingMethodException(typeName, methodName); - return method.CreateDelegate(); - } + private const BindingFlags StaticMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + + private static int s_counter; + + private GeneratedAssembly(Assembly assembly) + { + Assembly = assembly; + } + + /// The loaded assembly. + public Assembly Assembly + { + get; + } + + /// + /// Compiles, emits and loads 's output; fails the test when it does not compile clean or + /// emit. + /// + public static GeneratedAssembly Load(GeneratorRun run) + { + run.AssertCompilesClean(); + + using MemoryStream image = new(); + EmitResult result = run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, "The output compilation does not emit:\n" + string.Join('\n', result.Diagnostics)); + image.Position = 0; + + string assemblyName = "CheatEngine.SDK.EngineApi.Tests." + + Interlocked.Increment(ref s_counter).ToString(CultureInfo.InvariantCulture); + AssemblyLoadContext context = new(assemblyName); + return new GeneratedAssembly(context.LoadFromStream(image)); + } + + /// A delegate over a static method, for calls that must not allocate (reflection invocation does). + public TDelegate Delegate(string typeName, string methodName) + where TDelegate : Delegate + { + ParameterInfo[] parameters = typeof(TDelegate).GetMethod("Invoke")!.GetParameters(); + Type[] parameterTypes = [.. parameters.Select(static parameter => parameter.ParameterType)]; + Type type = Assembly.GetType(typeName, true)!; + MethodInfo method = type.GetMethod(methodName, StaticMembers, parameterTypes) ?? + throw new MissingMethodException(typeName, methodName); + return method.CreateDelegate(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratorRun.cs index 9057a9a2..891f6315 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratorRun.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; @@ -6,107 +7,119 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// One execution of the generator: the driver (to run again), the result and the updated compilation. internal sealed class GeneratorRun { - private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics) - { - Driver = driver; - OutputCompilation = outputCompilation; - GeneratorDiagnostics = diagnostics; - Result = driver.GetRunResult().Results.Single(); - } - - /// The driver after the run; feed it to again to test incrementality. - public GeneratorDriver Driver { get; } - - /// Input compilation plus the generated trees. - public Compilation OutputCompilation { get; } - - /// Diagnostics reported by the generator itself for malformed or conflicting curated specifications. - public ImmutableArray GeneratorDiagnostics { get; } - - public GeneratorRunResult Result { get; } - - public ImmutableArray GeneratedSources => Result.GeneratedSources; - - /// Text of the only generated file; fails when there is none or more than one. - public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); - - /// Hint names of the generated files, in generation order. - public string[] HintNames => [.. GeneratedSources.Select(static source => source.HintName)]; - - public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) - { - var updated = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var diagnostics, - TestContext.Current.CancellationToken); - - return new GeneratorRun(updated, outputCompilation, diagnostics); - } - - /// - /// Text of the generated file whose hint name contains ; fails when there is - /// none or more than one. - /// - public string GeneratedTextContaining(string hintNameFragment) - { - GeneratedSourceResult[] matches = - [.. GeneratedSources.Where(source => source.HintName.Contains(hintNameFragment, StringComparison.Ordinal))]; - Assert.True(matches.Length == 1, - $"Expected exactly one generated file whose hint name contains '{hintNameFragment}'. Generated: {string.Join(", ", HintNames)}."); - return matches[0].SourceText.ToString(); - } - - /// - /// Text of the generated file whose CONTENT contains (a method or type name - /// unique to one spec file); fails when there is none or more than one. More robust than a hint-name lookup for - /// tests that only care which file's text changed, not the hashed hint name. - /// - public string GeneratedTextByContent(string codeFragment) - { - GeneratedSourceResult[] matches = - [ - .. GeneratedSources.Where(source => - source.SourceText.ToString().Contains(codeFragment, StringComparison.Ordinal)) - ]; - Assert.True(matches.Length == 1, - $"Expected exactly one generated file containing '{codeFragment}'. Generated: {string.Join(", ", HintNames)}."); - return matches[0].SourceText.ToString(); - } - - /// Asserts that a valid or ignored input stays fully silent: no file, diagnostic, or generator exception. - public void AssertNoOutput() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - Assert.Empty(GeneratedSources); - } - - /// - /// Asserts that an invalid or conflicted spec emitted no source, while leaving diagnostic assertions to the - /// caller. - /// - public void AssertNoGeneratedSource() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratedSources); - } - - /// - /// Asserts that the updated compilation (generated code, against the real SDK assemblies) has no error and no - /// warning, and that the generator reported nothing. - /// - public void AssertCompilesClean() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - - Diagnostic[] problems = - [ - .. OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static d => d.Severity >= DiagnosticSeverity.Warning) - ]; - Assert.True(problems.Length == 0, - "Unexpected compiler diagnostics:\n" + string.Join('\n', problems.AsEnumerable())); - } + private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics) + { + Driver = driver; + OutputCompilation = outputCompilation; + GeneratorDiagnostics = diagnostics; + Result = driver.GetRunResult().Results.Single(); + } + + /// The driver after the run; feed it to again to test incrementality. + public GeneratorDriver Driver + { + get; + } + + /// Input compilation plus the generated trees. + public Compilation OutputCompilation + { + get; + } + + /// Diagnostics reported by the generator itself for malformed or conflicting curated specifications. + public ImmutableArray GeneratorDiagnostics + { + get; + } + + public GeneratorRunResult Result + { + get; + } + + public ImmutableArray GeneratedSources => Result.GeneratedSources; + + /// Text of the only generated file; fails when there is none or more than one. + public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); + + /// Hint names of the generated files, in generation order. + public string[] HintNames => [.. GeneratedSources.Select(static source => source.HintName)]; + + public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) + { + GeneratorDriver updated = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + + return new GeneratorRun(updated, outputCompilation, diagnostics); + } + + /// + /// Text of the generated file whose hint name contains ; fails when there is + /// none or more than one. + /// + public string GeneratedTextContaining(string hintNameFragment) + { + GeneratedSourceResult[] matches = + [.. GeneratedSources.Where(source => source.HintName.Contains(hintNameFragment, StringComparison.Ordinal))]; + Assert.True(matches.Length == 1, + $"Expected exactly one generated file whose hint name contains '{hintNameFragment}'. Generated: {string.Join(", ", HintNames)}."); + return matches[0].SourceText.ToString(); + } + + /// + /// Text of the generated file whose CONTENT contains (a method or type name + /// unique to one spec file); fails when there is none or more than one. More robust than a hint-name lookup for + /// tests that only care which file's text changed, not the hashed hint name. + /// + public string GeneratedTextByContent(string codeFragment) + { + GeneratedSourceResult[] matches = + [ + .. GeneratedSources.Where(source => + source.SourceText.ToString().Contains(codeFragment, StringComparison.Ordinal)) + ]; + Assert.True(matches.Length == 1, + $"Expected exactly one generated file containing '{codeFragment}'. Generated: {string.Join(", ", HintNames)}."); + return matches[0].SourceText.ToString(); + } + + /// Asserts that a valid or ignored input stays fully silent: no file, diagnostic, or generator exception. + public void AssertNoOutput() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + Assert.Empty(GeneratedSources); + } + + /// + /// Asserts that an invalid or conflicted spec emitted no source, while leaving diagnostic assertions to the + /// caller. + /// + public void AssertNoGeneratedSource() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratedSources); + } + + /// + /// Asserts that the updated compilation (generated code, against the real SDK assemblies) has no error and no + /// warning, and that the generator reported nothing. + /// + public void AssertCompilesClean() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + + Diagnostic[] problems = + [ + .. OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static d => d.Severity >= DiagnosticSeverity.Warning) + ]; + Assert.True(problems.Length == 0, + "Unexpected compiler diagnostics:\n" + string.Join('\n', problems.AsEnumerable())); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/HostDouble.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/HostDouble.cs index 2f7483af..b9d53460 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/HostDouble.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/HostDouble.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; @@ -12,22 +13,22 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal static unsafe class HostDouble { - private static lua_State* s_state; + private static lua_State* s_state; - /// - /// Points the provider at and builds a binding with the calling thread as main thread - /// and no object pusher. - /// - public static LuaHostBinding CreateBinding(lua_State* state) - { - s_state = state; - delegate* unmanaged[Stdcall] provider = &Provide; - return new LuaHostBinding((nint)provider, 0, Environment.CurrentManagedThreadId); - } + /// + /// Points the provider at and builds a binding with the calling thread as main thread + /// and no object pusher. + /// + public static LuaHostBinding CreateBinding(lua_State* state) + { + s_state = state; + delegate* unmanaged[Stdcall] provider = &Provide; + return new LuaHostBinding((nint) provider, 0, Environment.CurrentManagedThreadId); + } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* Provide() - { - return s_state; - } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* Provide() + { + return s_state; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/InMemoryAdditionalText.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/InMemoryAdditionalText.cs index 74b94b3c..77e5e3c4 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/InMemoryAdditionalText.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/InMemoryAdditionalText.cs @@ -1,4 +1,5 @@ using System.Text; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -10,14 +11,17 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal sealed class InMemoryAdditionalText(string path, string text) : AdditionalText { - private readonly SourceText _text = SourceText.From(text, Encoding.UTF8); + private readonly SourceText _text = SourceText.From(text, Encoding.UTF8); - /// - public override string Path { get; } = path; + /// + public override string Path + { + get; + } = path; - /// - public override SourceText GetText(CancellationToken cancellationToken = default) - { - return _text; - } + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs index 2aacf392..b9de061c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; @@ -16,76 +17,91 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal static class LocalFrameworkReferences { - private const string TargetFrameworkFolder = "net10.0"; + private const string TargetFrameworkFolder = "net10.0"; - private static string RuntimeDirectory => - Path.GetDirectoryName(typeof(object).Assembly.Location) - ?? throw new InvalidOperationException( - "System.Private.CoreLib has no location: single-file test hosts are not supported."); + private static string RuntimeDirectory => + Path.GetDirectoryName(typeof(object).Assembly.Location) + ?? throw new InvalidOperationException( + "System.Private.CoreLib has no location: single-file test hosts are not supported."); - /// Targeting pack when there is one, the running runtime otherwise. - /// Neither source yields a single assembly. - public static ImmutableArray Load() - { - var references = FromTargetingPack(); - if (references.IsEmpty) references = FromRunningRuntime(); + /// Targeting pack when there is one, the running runtime otherwise. + /// Neither source yields a single assembly. + public static ImmutableArray Load() + { + ImmutableArray references = FromTargetingPack(); + if (references.IsEmpty) + { + references = FromRunningRuntime(); + } - return references.IsEmpty - ? throw new InvalidOperationException( - $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") - : references; - } + return references.IsEmpty + ? throw new InvalidOperationException( + $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") + : references; + } - /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. - public static ImmutableArray FromTargetingPack() - { - var dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); - if (dotnetRoot is null) return []; + /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. + public static ImmutableArray FromTargetingPack() + { + string? dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); + if (dotnetRoot is null) + { + return []; + } - var packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); - if (!Directory.Exists(packs)) return []; + string packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); + if (!Directory.Exists(packs)) + { + return []; + } - string? best = null; - Version? bestVersion = null; - foreach (var pack in Directory.EnumerateDirectories(packs)) - { - var candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); - if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out var version) - && (bestVersion is null || version > bestVersion)) - { - best = candidate; - bestVersion = version; - } - } + string? best = null; + Version? bestVersion = null; + foreach (string pack in Directory.EnumerateDirectories(packs)) + { + string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); + if (Directory.Exists(candidate) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) + { + best = candidate; + bestVersion = version; + } + } - return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); - } + return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); + } - /// Implementation assemblies of the runtime this process runs on (managed ones only). - public static ImmutableArray FromRunningRuntime() - { - var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; - List paths = []; - foreach (var path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) - paths.Add(path); + /// Implementation assemblies of the runtime this process runs on (managed ones only). + public static ImmutableArray FromRunningRuntime() + { + string trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; + List paths = []; + foreach (string path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(path); + } + } - return CreateReferences(paths); - } + return CreateReferences(paths); + } - private static bool TryParsePackVersion(string directoryName, out Version? version) - { - var label = directoryName.IndexOf('-', StringComparison.Ordinal); - return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); - } + private static bool TryParsePackVersion(string directoryName, out Version? version) + { + int label = directoryName.IndexOf('-', StringComparison.Ordinal); + return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); + } - private static ImmutableArray CreateReferences(IEnumerable paths) - { - var references = ImmutableArray.CreateBuilder(); - foreach (var path in paths.Order(StringComparer.OrdinalIgnoreCase)) - references.Add(MetadataReference.CreateFromFile(path)); + private static ImmutableArray CreateReferences(IEnumerable paths) + { + ImmutableArray.Builder references = ImmutableArray.CreateBuilder(); + foreach (string path in paths.Order(StringComparer.OrdinalIgnoreCase)) + { + references.Add(MetadataReference.CreateFromFile(path)); + } - return references.ToImmutable(); - } + return references.ToImmutable(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaRuntimeSuite.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaRuntimeSuite.cs index 2ee50c5c..232e8094 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaRuntimeSuite.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaRuntimeSuite.cs @@ -8,6 +8,6 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; [CollectionDefinition(Name, DisableParallelization = true)] public sealed class LuaRuntimeSuite { - /// The collection name. - public const string Name = "Lua runtime"; + /// The collection name. + public const string Name = "Lua runtime"; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaTest.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaTest.cs index 85da49c2..8d4e25b4 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaTest.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LuaTest.cs @@ -7,26 +7,26 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// What the NativeLua tests share: the skip guard, chunk execution through the public API and result reading. internal static class LuaTest { - /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. - public static void RequireNativeLua() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - } + /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. + public static void RequireNativeLua() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + } - /// Wraps the fixture's state in the SDK view. - public static LuaState View(NativeLuaState state) - { - return new LuaState(state.Pointer); - } + /// Wraps the fixture's state in the SDK view. + public static LuaState View(NativeLuaState state) + { + return new LuaState(state.Pointer); + } - /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. - public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) - { - var status = L.TryExecute(source, resultCount, "=test"u8); - if (!status.IsOk) - { - var error = LuaError.FromStack(L, status); - Assert.Fail("The chunk failed: " + error); - } - } + /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. + public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) + { + LuaStatus status = L.TryExecute(source, resultCount, "=test"u8); + if (!status.IsOk) + { + LuaError error = LuaError.FromStack(L, status); + Assert.Fail("The chunk failed: " + error); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs index e1a936ce..a4030574 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Globalization; using System.Reflection; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; @@ -12,51 +13,65 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal static class ModelGraph { - private static readonly Type[] ForbiddenTypes = - [ - typeof(ISymbol), - typeof(SyntaxNode), - typeof(SyntaxTree), - typeof(SyntaxToken), - typeof(SyntaxReference), - typeof(Location), - typeof(SemanticModel), - typeof(Compilation), - typeof(AttributeData) - ]; - - /// Asserts that reaches no Roslyn object; returns the number of objects visited. - public static int AssertFreeOfRoslynObjects(object? value, string stepName) - { - HashSet visited = new(ReferenceEqualityComparer.Instance); - Visit(value, stepName, visited, 0); - return visited.Count; - } - - private static void Visit(object? value, string path, HashSet visited, int depth) - { - if (value is null) return; - - var type = value.GetType(); - Assert.False( - Array.Exists(ForbiddenTypes, forbidden => forbidden.IsAssignableFrom(type)), - $"{path}: a {type.FullName} is held by the pipeline model."); - - if (type.IsPrimitive || type.IsEnum || value is string) return; - - Assert.True(depth < 32, $"{path}: object graph too deep."); - if (!type.IsValueType && !visited.Add(value)) return; - - if (value is IEnumerable sequence) - { - var index = 0; - foreach (var item in sequence) - Visit(item, $"{path}[{index++.ToString(CultureInfo.InvariantCulture)}]", visited, depth + 1); - - return; - } - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); - } + private static readonly Type[] ForbiddenTypes = + [ + typeof(ISymbol), + typeof(SyntaxNode), + typeof(SyntaxTree), + typeof(SyntaxToken), + typeof(SyntaxReference), + typeof(Location), + typeof(SemanticModel), + typeof(Compilation), + typeof(AttributeData) + ]; + + /// Asserts that reaches no Roslyn object; returns the number of objects visited. + public static int AssertFreeOfRoslynObjects(object? value, string stepName) + { + HashSet visited = new(ReferenceEqualityComparer.Instance); + Visit(value, stepName, visited, 0); + return visited.Count; + } + + private static void Visit(object? value, string path, HashSet visited, int depth) + { + if (value is null) + { + return; + } + + Type type = value.GetType(); + Assert.False( + Array.Exists(ForbiddenTypes, forbidden => forbidden.IsAssignableFrom(type)), + $"{path}: a {type.FullName} is held by the pipeline model."); + + if (type.IsPrimitive || type.IsEnum || value is string) + { + return; + } + + Assert.True(depth < 32, $"{path}: object graph too deep."); + if (!type.IsValueType && !visited.Add(value)) + { + return; + } + + if (value is IEnumerable sequence) + { + int index = 0; + foreach (object item in sequence) + { + Visit(item, $"{path}[{index++.ToString(CultureInfo.InvariantCulture)}]", visited, depth + 1); + } + + return; + } + + foreach (FieldInfo field in + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynEnvironment.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynEnvironment.cs index b242ba72..f24d7249 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynEnvironment.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynEnvironment.cs @@ -1,8 +1,10 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.State; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -17,49 +19,55 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal sealed class RoslynEnvironment { - /// Documentation comments are parsed and diagnosed, like in a project with GenerateDocumentationFile. - public static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); + /// Documentation comments are parsed and diagnosed, like in a project with GenerateDocumentationFile. + public static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); - /// Strict: nullable on, every warning wave. EngineApi bodies contain no unsafe code, so unsafe stays off. - public static readonly CSharpCompilationOptions CompilationOptions = new( - OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable, - warningLevel: 9999); + /// Strict: nullable on, every warning wave. EngineApi bodies contain no unsafe code, so unsafe stays off. + public static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + warningLevel: 9999); - private static readonly Lazy LazyShared = - new(static () => new RoslynEnvironment(LocalFrameworkReferences.Load())); + private static readonly Lazy LazyShared = + new(static () => new RoslynEnvironment(LocalFrameworkReferences.Load())); - private RoslynEnvironment(ImmutableArray frameworkReferences) - { - FrameworkReferences = frameworkReferences; - SdkReferences = - [ - MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(LuaApi).Assembly.Location), - MetadataReference.CreateFromFile(typeof(LuaState).Assembly.Location), + private RoslynEnvironment(ImmutableArray frameworkReferences) + { + FrameworkReferences = frameworkReferences; + SdkReferences = + [ + MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(LuaApi).Assembly.Location), + MetadataReference.CreateFromFile(typeof(LuaState).Assembly.Location), - // Not a real CheatEngine.SDK.Engine reference (this project deliberately does not reference it): this test - // assembly's own file, so a generated wrapper's 'global::CheatEngine.SDK.Engine.Values.Address' - // resolves to Infrastructure/Address.cs (declared in namespace CheatEngine.SDK.Engine.Values), loaded a second - // time from the same file by the custom AssemblyLoadContext's fallback to the default context - // (GeneratedAssembly's own doc comment). - MetadataReference.CreateFromFile(typeof(Address).Assembly.Location) - ]; - } + // Not a real CheatEngine.SDK.Engine reference (this project deliberately does not reference it): this test + // assembly's own file, so a generated wrapper's 'global::CheatEngine.SDK.Engine.Values.Address' + // resolves to Infrastructure/Address.cs (declared in namespace CheatEngine.SDK.Engine.Values), loaded a second + // time from the same file by the custom AssemblyLoadContext's fallback to the default context + // (GeneratedAssembly's own doc comment). + MetadataReference.CreateFromFile(typeof(Address).Assembly.Location) + ]; + } - /// The process-wide environment. - public static RoslynEnvironment Shared => LazyShared.Value; + /// The process-wide environment. + public static RoslynEnvironment Shared => LazyShared.Value; - /// Microsoft.NETCore.App 10.0: reference assemblies, or the running runtime as a fallback. - public ImmutableArray FrameworkReferences { get; } + /// Microsoft.NETCore.App 10.0: reference assemblies, or the running runtime as a fallback. + public ImmutableArray FrameworkReferences + { + get; + } - /// - /// The real CheatEngine.SDK.Annotations, CheatEngine.SDK.Lua.Interop and CheatEngine.SDK.Lua, as - /// loaded in this process, - /// plus this test assembly's own file for the stub (see its own doc comment). - /// - public ImmutableArray SdkReferences { get; } + /// + /// The real CheatEngine.SDK.Annotations, CheatEngine.SDK.Lua.Interop and CheatEngine.SDK.Lua, as + /// loaded in this process, + /// plus this test assembly's own file for the stub (see its own doc comment). + /// + public ImmutableArray SdkReferences + { + get; + } - /// Framework + SDK: the references of a compilation the generated wrappers land in. - public ImmutableArray PluginReferences => FrameworkReferences.AddRange(SdkReferences); + /// Framework + SDK: the references of a compilation the generated wrappers land in. + public ImmutableArray PluginReferences => FrameworkReferences.AddRange(SdkReferences); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynFixture.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynFixture.cs index edfa0c08..1459d341 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynFixture.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RoslynFixture.cs @@ -16,51 +16,54 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// public sealed class RoslynFixture { - /// Assembly name of the test compilations. - internal const string PluginAssemblyName = "TestEngineApi"; + /// Assembly name of the test compilations. + internal const string PluginAssemblyName = "TestEngineApi"; - /// - /// Takes the process-wide environment; a failure to find the framework references fails the class with the - /// resolver's message. - /// - public RoslynFixture() - { - Environment = RoslynEnvironment.Shared; - } + /// + /// Takes the process-wide environment; a failure to find the framework references fails the class with the + /// resolver's message. + /// + public RoslynFixture() + { + Environment = RoslynEnvironment.Shared; + } - internal RoslynEnvironment Environment { get; } + internal RoslynEnvironment Environment + { + get; + } - /// An otherwise-empty compilation (no attributed source is needed by this generator). - internal CSharpCompilation CreateCompilation() - { - return CSharpCompilation.Create(PluginAssemblyName, [], Environment.PluginReferences, - RoslynEnvironment.CompilationOptions); - } + /// An otherwise-empty compilation (no attributed source is needed by this generator). + internal CSharpCompilation CreateCompilation() + { + return CSharpCompilation.Create(PluginAssemblyName, [], Environment.PluginReferences, + RoslynEnvironment.CompilationOptions); + } - /// Creates a driver for the generator, with step tracking on, over . - internal static GeneratorDriver CreateDriver(params AdditionalText[] additionalTexts) - { - return CSharpGeneratorDriver.Create( - [new EngineApiGenerator().AsSourceGenerator()], - additionalTexts, - RoslynEnvironment.ParseOptions, - null, - new GeneratorDriverOptions( - IncrementalGeneratorOutputKind.None, - true)); - } + /// Creates a driver for the generator, with step tracking on, over . + internal static GeneratorDriver CreateDriver(params AdditionalText[] additionalTexts) + { + return CSharpGeneratorDriver.Create( + [new EngineApiGenerator().AsSourceGenerator()], + additionalTexts, + RoslynEnvironment.ParseOptions, + null, + new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + true)); + } - /// Runs the generator once over one spec file at with content . - internal GeneratorRun Run(string path, string text) - { - return Run((path, text)); - } + /// Runs the generator once over one spec file at with content . + internal GeneratorRun Run(string path, string text) + { + return Run((path, text)); + } - /// Runs the generator once over several spec files. - internal GeneratorRun Run(params (string Path, string Text)[] specs) - { - AdditionalText[] texts = - [.. specs.Select(static spec => (AdditionalText)new InMemoryAdditionalText(spec.Path, spec.Text))]; - return GeneratorRun.Execute(CreateDriver(texts), CreateCompilation()); - } + /// Runs the generator once over several spec files. + internal GeneratorRun Run(params (string Path, string Text)[] specs) + { + AdditionalText[] texts = + [.. specs.Select(static spec => (AdditionalText) new InMemoryAdditionalText(spec.Path, spec.Text))]; + return GeneratorRun.Execute(CreateDriver(texts), CreateCompilation()); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RuntimeScope.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RuntimeScope.cs index 2177fd39..b71faf27 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RuntimeScope.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/RuntimeScope.cs @@ -9,13 +9,13 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// internal sealed unsafe class RuntimeScope : IDisposable { - public RuntimeScope(NativeLuaState state) - { - LuaRuntime.Attach(HostDouble.CreateBinding(state.L)); - } + public RuntimeScope(NativeLuaState state) + { + LuaRuntime.Attach(HostDouble.CreateBinding(state.L)); + } - public void Dispose() - { - LuaRuntime.Detach(); - } + public void Dispose() + { + LuaRuntime.Detach(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs index 62b645c9..defb5d17 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs @@ -3,142 +3,142 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// Spec-file texts reused across the parser, generator and end-to-end tests. internal static class SpecSources { - /// - /// The nominal case: two scalar reads (Try form) and two scalar writes (throwing form, boolean return), sharing - /// no global. - /// - public const string Memory = """ - namespace: Demo.Engine.Generated - type: MemoryScalars - contract: ce77 - provenance: ExactInstalledFile: CE 7.7 celua.txt scalar memory globals - minimum-ce: 7.7.0.10621 - architecture: x64 - thread: unknown - ownership: none - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - fixed: boolean:true - result: value:int32 - nil: absence - doc: Reads a 32-bit integer from the target process at the given address. - - global: writeInteger - method: WriteInt32 - form: throwing - arg: address:address - arg: value:int32 - return: boolean - nil: none - doc: Writes a 32-bit integer to the target process at the given address. - - global: readQword - method: TryReadInt64 - form: try - arg: address:address - result: value:int64 - nil: absence - doc: Reads a 64-bit integer from the target process at the given address. - - global: writeQword - method: WriteInt64 - form: throwing - arg: address:address - arg: value:int64 - return: boolean - nil: none - doc: Writes a 64-bit integer to the target process at the given address. - """; - - /// A minimal, single-entry spec: a Try form with one argument and one result. - public const string SingleTry = """ - namespace: Demo.One - type: One - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads a 32-bit integer. - """; - - /// Two wrapper forms (Try and throwing) of the same global: they must share one cache field. - public const string SharedGlobal = """ - namespace: Demo.Shared - type: Shared - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads a 32-bit integer, reporting failure. - - global: readInteger - method: ReadInt32 - form: throwing - arg: address:address - return: int32 - doc: Reads a 32-bit integer, raising on failure. - """; - - /// - /// A minimal, unrelated single-entry spec (a different type and global from ), for - /// two-file pipeline tests. - /// - public const string BeepOnly = """ - namespace: Demo.Other - type: Other - - global: beep - method: Beep - form: throwing - doc: Calls a global with no arguments and no result. - """; - - /// Every wrapper shape the end-to-end test exercises: both Try results, both throwing forms, and a void call. - public const string EndToEnd = """ - namespace: Demo.EndToEnd - type: MemoryScalars - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - fixed: boolean:true - result: value:int32 - doc: Reads a 32-bit integer from the target process at the given address. - - global: writeInteger - method: WriteInt32 - form: throwing - arg: address:address - arg: value:int32 - return: boolean - doc: Writes a 32-bit integer to the target process at the given address. - - global: readQword - method: TryReadInt64 - form: try - arg: address:address - result: value:int64 - doc: Reads a 64-bit integer from the target process at the given address. - - global: writeQword - method: WriteInt64 - form: throwing - arg: address:address - arg: value:int64 - return: boolean - doc: Writes a 64-bit integer to the target process at the given address. - - global: beep - method: Beep - form: throwing - doc: Calls a global with no arguments and no result. - """; + /// + /// The nominal case: two scalar reads (Try form) and two scalar writes (throwing form, boolean return), sharing + /// no global. + /// + public const string Memory = """ + namespace: Demo.Engine.Generated + type: MemoryScalars + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt scalar memory globals + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + fixed: boolean:true + result: value:int32 + nil: absence + doc: Reads a 32-bit integer from the target process at the given address. + + global: writeInteger + method: WriteInt32 + form: throwing + arg: address:address + arg: value:int32 + return: boolean + nil: none + doc: Writes a 32-bit integer to the target process at the given address. + + global: readQword + method: TryReadInt64 + form: try + arg: address:address + result: value:int64 + nil: absence + doc: Reads a 64-bit integer from the target process at the given address. + + global: writeQword + method: WriteInt64 + form: throwing + arg: address:address + arg: value:int64 + return: boolean + nil: none + doc: Writes a 64-bit integer to the target process at the given address. + """; + + /// A minimal, single-entry spec: a Try form with one argument and one result. + public const string SingleTry = """ + namespace: Demo.One + type: One + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads a 32-bit integer. + """; + + /// Two wrapper forms (Try and throwing) of the same global: they must share one cache field. + public const string SharedGlobal = """ + namespace: Demo.Shared + type: Shared + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads a 32-bit integer, reporting failure. + + global: readInteger + method: ReadInt32 + form: throwing + arg: address:address + return: int32 + doc: Reads a 32-bit integer, raising on failure. + """; + + /// + /// A minimal, unrelated single-entry spec (a different type and global from ), for + /// two-file pipeline tests. + /// + public const string BeepOnly = """ + namespace: Demo.Other + type: Other + + global: beep + method: Beep + form: throwing + doc: Calls a global with no arguments and no result. + """; + + /// Every wrapper shape the end-to-end test exercises: both Try results, both throwing forms, and a void call. + public const string EndToEnd = """ + namespace: Demo.EndToEnd + type: MemoryScalars + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + fixed: boolean:true + result: value:int32 + doc: Reads a 32-bit integer from the target process at the given address. + + global: writeInteger + method: WriteInt32 + form: throwing + arg: address:address + arg: value:int32 + return: boolean + doc: Writes a 32-bit integer to the target process at the given address. + + global: readQword + method: TryReadInt64 + form: try + arg: address:address + result: value:int64 + doc: Reads a 64-bit integer from the target process at the given address. + + global: writeQword + method: WriteInt64 + form: throwing + arg: address:address + arg: value:int64 + return: boolean + doc: Writes a 64-bit integer to the target process at the given address. + + global: beep + method: Beep + form: throwing + doc: Calls a global with no arguments and no result. + """; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/StepAssert.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/StepAssert.cs index 4a63ebda..01358781 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/StepAssert.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/StepAssert.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; @@ -7,59 +9,61 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// Assertions over (the incrementality gate). internal static class StepAssert { - /// Name Roslyn gives to the step of a RegisterSourceOutput callback. - public const string SourceOutputStep = "SourceOutput"; + /// Name Roslyn gives to the step of a RegisterSourceOutput callback. + public const string SourceOutputStep = "SourceOutput"; - /// - /// Every CheatEngine.SDK-named step ran, produced something, and produced only Cached/Unchanged values; - /// no - /// source output was re-executed. Also fails when a step exists that - /// does not know, so a new step cannot slip past the gate. - /// - public static void NothingWasRecomputed(GeneratorRunResult result) - { - string[] trackedCheatEngineSdkSteps = - [.. result.TrackedSteps.Keys.Where(TrackingNames.IsCheatEngineSdkStep).Order(StringComparer.Ordinal)]; - Assert.Equal(EngineApiTrackingNames.All.Order(StringComparer.Ordinal), trackedCheatEngineSdkSteps, - StringComparer.Ordinal); + /// + /// Every CheatEngine.SDK-named step ran, produced something, and produced only Cached/Unchanged values; + /// no + /// source output was re-executed. Also fails when a step exists that + /// does not know, so a new step cannot slip past the gate. + /// + public static void NothingWasRecomputed(GeneratorRunResult result) + { + string[] trackedCheatEngineSdkSteps = + [.. result.TrackedSteps.Keys.Where(TrackingNames.IsCheatEngineSdkStep).Order(StringComparer.Ordinal)]; + Assert.Equal(EngineApiTrackingNames.All.Order(StringComparer.Ordinal), trackedCheatEngineSdkSteps, + StringComparer.Ordinal); - foreach (var stepName in EngineApiTrackingNames.All) - Assert.All( - Reasons(result, stepName), - reason => Assert.True( - reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, - $"Step '{stepName}' was recomputed: {reason}.")); + foreach (string stepName in EngineApiTrackingNames.All) + { + Assert.All( + Reasons(result, stepName), + reason => Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Step '{stepName}' was recomputed: {reason}.")); + } - Assert.All(OutputReasons(result), static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); - } + Assert.All(OutputReasons(result), static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); + } - /// Reasons of every output of the step named ; fails when there is none. - public static ImmutableArray Reasons(GeneratorRunResult result, string stepName) - { - Assert.True(result.TrackedSteps.ContainsKey(stepName), $"Step '{stepName}' was not tracked."); + /// Reasons of every output of the step named ; fails when there is none. + public static ImmutableArray Reasons(GeneratorRunResult result, string stepName) + { + Assert.True(result.TrackedSteps.ContainsKey(stepName), $"Step '{stepName}' was not tracked."); - ImmutableArray reasons = - [ - .. result.TrackedSteps[stepName].SelectMany(static step => step.Outputs) - .Select(static output => output.Reason) - ]; + ImmutableArray reasons = + [ + .. result.TrackedSteps[stepName].SelectMany(static step => step.Outputs) + .Select(static output => output.Reason) + ]; - Assert.False(reasons.IsEmpty, $"Step '{stepName}' produced no output: the assertion would be vacuous."); - return reasons; - } + Assert.False(reasons.IsEmpty, $"Step '{stepName}' produced no output: the assertion would be vacuous."); + return reasons; + } - /// Reasons of every source output; fails when there is none. - public static ImmutableArray OutputReasons(GeneratorRunResult result) - { - Assert.True(result.TrackedOutputSteps.ContainsKey(SourceOutputStep), "The source output step was not tracked."); + /// Reasons of every source output; fails when there is none. + public static ImmutableArray OutputReasons(GeneratorRunResult result) + { + Assert.True(result.TrackedOutputSteps.ContainsKey(SourceOutputStep), "The source output step was not tracked."); - ImmutableArray reasons = - [ - .. result.TrackedOutputSteps[SourceOutputStep].SelectMany(static step => step.Outputs) - .Select(static output => output.Reason) - ]; + ImmutableArray reasons = + [ + .. result.TrackedOutputSteps[SourceOutputStep].SelectMany(static step => step.Outputs) + .Select(static output => output.Reason) + ]; - Assert.False(reasons.IsEmpty, "The source output step produced no output: the assertion would be vacuous."); - return reasons; - } + Assert.False(reasons.IsEmpty, "The source output step produced no output: the assertion would be vacuous."); + return reasons; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs index 23b8a265..0a14a9fd 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs @@ -11,765 +11,772 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Parsing; /// public sealed class SpecFileParserTests { - [Fact] - public void IsSpecFile_matches_the_convention_extension_case_insensitively() - { - Assert.True(SpecFileParser.IsSpecFile("memory-scalars.cheatengine-sdk-api.txt")); - Assert.True(SpecFileParser.IsSpecFile(@"C:\repo\Specs\memory-scalars.CHEATENGINE-SDK-API.TXT")); - Assert.False(SpecFileParser.IsSpecFile("notes.txt")); - Assert.False(SpecFileParser.IsSpecFile("memory.cheatengine-sdk-api.txt.bak")); - Assert.False(SpecFileParser.IsSpecFile(null)); - } - - [Fact] - public void Nominal_spec_parses_into_four_sorted_calls_and_two_shared_free_cache_fields() - { - var spec = SpecFileParser.Parse("memory-scalars.cheatengine-sdk-api.txt", SpecSources.Memory); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Equal("Demo.Engine.Generated", spec.Namespace); - Assert.Equal("MemoryScalars", spec.TypeName); - Assert.Equal(4, spec.Calls.Length); - Assert.Equal(["readInteger", "readQword", "writeInteger", "writeQword"], spec.CachedGlobals.AsSpan().ToArray()); - - // Sorted by C# method name (ordinal). - string[] methodNames = [.. spec.Calls.AsSpan().ToArray().Select(static c => c.Call.MethodName)]; - Assert.Equal(["TryReadInt32", "TryReadInt64", "WriteInt32", "WriteInt64"], methodNames); - - var tryReadInt32 = spec.Calls[0]; - Assert.Equal("readInteger", tryReadInt32.Call.GlobalName); - Assert.Equal(LuaCallForm.Try, tryReadInt32.Call.Form); - Assert.Equal("public static", tryReadInt32.Call.Modifiers); - Assert.Equal(2, tryReadInt32.Call.Arguments.Length); - Assert.Equal(LuaValueKind.Address, tryReadInt32.Call.Arguments[0].Kind); - Assert.True(tryReadInt32.Call.Arguments[1].IsFixed); - Assert.Equal(LuaValueKind.Boolean, tryReadInt32.Call.Arguments[1].Kind); - Assert.Equal("true", tryReadInt32.Call.Arguments[1].FixedValue); - Assert.Single(tryReadInt32.Call.Results.AsSpan().ToArray()); - Assert.Equal(LuaValueKind.Int32, tryReadInt32.Call.Results[0].Kind); - Assert.StartsWith("Reads a 32-bit integer", tryReadInt32.Summary, StringComparison.Ordinal); - - var writeInt32 = spec.Calls[2]; - Assert.Equal(LuaCallForm.Throwing, writeInt32.Call.Form); - Assert.Equal(LuaValueKind.Boolean, Assert.NotNull(writeInt32.Call.ReturnKind)); - Assert.Empty(writeInt32.Call.Results.AsSpan().ToArray()); - } - - [Fact] - public void Two_forms_of_the_same_global_share_one_cache_field() - { - var spec = SpecFileParser.Parse("shared.cheatengine-sdk-api.txt", SpecSources.SharedGlobal); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Equal(2, spec.Calls.Length); - Assert.Equal(["readInteger"], spec.CachedGlobals.AsSpan().ToArray()); - Assert.Equal(spec.Calls[0].Call.CacheFieldName, spec.Calls[1].Call.CacheFieldName); - } - - [Fact] - public void Comments_and_blank_lines_are_ignored_wherever_they_appear() - { - const string Text = """ - # a file comment - namespace: Demo - # a comment between header keys - type: T - - # a comment before an entry - global: readInteger - # a comment between entry keys - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - # a trailing comment - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Single(spec.Calls.AsSpan().ToArray()); - } - - [Fact] - public void Indentation_and_CRLF_line_endings_are_tolerated() - { - var text = SpecSources.SingleTry.Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace("\n", "\r\n", StringComparison.Ordinal); - text = " namespace: Demo.One\r\n type: One\r\n\r\n" + - text[text.IndexOf("global:", StringComparison.Ordinal)..]; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Equal("Demo.One", spec.Namespace); - Assert.Single(spec.Calls.AsSpan().ToArray()); - } - - [Fact] - public void Empty_text_produces_one_issue_and_no_output() - { - var spec = SpecFileParser.Parse("empty.cheatengine-sdk-api.txt", string.Empty); - - Assert.Equal(string.Empty, spec.Namespace); - Assert.Equal(string.Empty, spec.TypeName); - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Single(spec.Issues.AsSpan().ToArray()); - } - - [Fact] - public void A_comment_only_file_is_treated_as_empty() - { - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "# nothing here\n# still nothing\n"); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Single(spec.Issues.AsSpan().ToArray()); - } - - [Fact] - public void The_global_namespace_is_written_as_an_empty_namespace_value() - { - const string Text = """ - namespace: - type: Root - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Equal(string.Empty, spec.Namespace); - Assert.Equal("Root", spec.TypeName); - } - - [Fact] - public void A_line_without_a_colon_marks_its_whole_block_malformed() - { - const string Text = """ - namespace: Demo - type: T - - global readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("Malformed line", StringComparison.Ordinal)); - } - - [Fact] - public void A_malformed_header_line_drops_the_whole_file() - { - const string Text = "namespace Demo\ntype: T\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Equal(string.Empty, spec.Namespace); - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.NotEmpty(spec.Issues.AsSpan().ToArray()); - } - - [Fact] - public void A_duplicate_header_key_fails_the_header() - { - const string Text = "namespace: Demo\nnamespace: Other\ntype: T\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Equal(string.Empty, spec.Namespace); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("Duplicate header key", StringComparison.Ordinal)); - } - - [Fact] - public void An_unknown_header_key_fails_the_header() - { - const string Text = "namespace: Demo\ntype: T\nauthor: someone\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Equal(string.Empty, spec.Namespace); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("Unknown header key", StringComparison.Ordinal)); - } - - [Fact] - public void A_missing_type_key_fails_the_header() - { - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\n"); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, static issue => issue.Message.Contains("'type'", StringComparison.Ordinal)); - } - - [Fact] - public void An_invalid_namespace_fails_the_header() - { - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: 1Bad.Name\ntype: T\n"); - - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("not a valid namespace", StringComparison.Ordinal)); - } - - [Fact] - public void An_invalid_type_name_fails_the_header() - { - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\ntype: 1Bad\n"); - - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("not a valid type name", StringComparison.Ordinal)); - } - - [Theory] - [InlineData("global")] - [InlineData("method")] - [InlineData("form")] - [InlineData("doc")] - public void An_entry_missing_a_required_key_is_dropped(string missingKey) - { - var entry = """ - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - """; - - var edited = string.Join( - '\n', - entry.Split('\n').Where(line => !line.StartsWith(missingKey + ":", StringComparison.Ordinal))); - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\ntype: T\n\n" + edited); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, issue => issue.Message.Contains("'" + missingKey + "'", StringComparison.Ordinal)); - } - - [Fact] - public void An_unknown_entry_key_drops_the_entry() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - extra: nonsense - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("Unknown entry key", StringComparison.Ordinal)); - } - - [Fact] - public void A_duplicate_entry_key_drops_the_entry() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - global: writeInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: Reads an integer. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("Duplicate entry key", StringComparison.Ordinal)); - } - - [Theory] - [InlineData("Not A Name")] - [InlineData("1leading")] - [InlineData("end")] // a Lua reserved word - public void An_invalid_lua_global_name_drops_the_entry(string badName) - { - var text = "namespace: Demo\ntype: T\n\nglobal: " + badName + - "\nmethod: M\nform: try\narg: address:address\nresult: value:int32\ndoc: d.\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("not a valid Lua global name", StringComparison.Ordinal)); - } - - [Fact] - public void An_invalid_method_name_drops_the_entry() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: 1Bad - form: try - arg: address:address - result: value:int32 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("not a valid C# method name", StringComparison.Ordinal)); - } - - [Fact] - public void A_method_name_that_is_a_reserved_word_is_escaped_with_at() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: class - form: try - arg: address:address - result: value:int32 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Equal("@class", spec.Calls[0].Call.MethodName); - } - - [Fact] - public void An_invalid_form_value_drops_the_entry() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: maybe - arg: address:address - result: value:int32 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("not a valid form", StringComparison.Ordinal)); - } - - [Fact] - public void A_try_entry_with_no_result_is_dropped() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: try - arg: address:address - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("needs at least one 'result'", StringComparison.Ordinal)); - } - - [Fact] - public void A_try_entry_with_a_return_is_dropped() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: try - arg: address:address - result: value:int32 - return: int32 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("must not declare 'return'", StringComparison.Ordinal)); - } - - [Fact] - public void A_throwing_entry_with_a_result_is_dropped() - { - const string Text = """ - namespace: Demo - type: T - - global: writeInteger - method: M - form: throwing - arg: address:address - result: value:int32 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("must not declare 'result'", StringComparison.Ordinal)); - } - - [Fact] - public void A_throwing_entry_without_a_return_is_a_void_wrapper() - { - const string Text = """ - namespace: Demo - type: T - - global: beep - method: Beep - form: throwing - doc: Calls a global with no arguments and no result. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Null(spec.Calls[0].Call.ReturnKind); - Assert.Empty(spec.Calls[0].Call.Arguments.AsSpan().ToArray()); - } - - [Theory] - [InlineData("arg: address:notakind")] - [InlineData("arg: :address")] - [InlineData("arg: address")] - [InlineData("arg: 1bad:address")] - public void A_malformed_or_unknown_kind_argument_drops_the_entry(string argLine) - { - var text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\n" + argLine + - "\nresult: value:int32\ndoc: d.\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.NotEmpty(spec.Issues.AsSpan().ToArray()); - } - - [Theory] - [InlineData("fixed: boolean:maybe")] - [InlineData("fixed: int32:1")] - [InlineData("fixed: boolean:true; System.Console.WriteLine()")] - public void A_fixed_argument_accepts_only_boolean_literals(string fixedLine) - { - var text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\narg: address:address\n" + - fixedLine + "\nresult: value:int32\ndoc: d.\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("fixed argument", StringComparison.Ordinal)); - } - - [Fact] - public void A_utf8_result_is_rejected_because_it_would_dangle() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: try - arg: address:address - result: value:utf8 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("cannot be a result", StringComparison.Ordinal)); - } - - [Fact] - public void A_utf8_return_is_rejected_because_it_would_dangle() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: throwing - arg: address:address - return: utf8 - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("cannot be a return type", StringComparison.Ordinal)); - } - - [Fact] - public void An_invalid_return_kind_drops_the_entry() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: throwing - arg: address:address - return: notakind - doc: d. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("not a valid return kind", StringComparison.Ordinal)); - } - - [Fact] - public void A_string_argument_may_be_declared_nullable() - { - const string Text = """ - namespace: Demo - type: T - - global: upper - method: Upper - form: throwing - arg: text:string? - return: string - doc: Upper-cases a string. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - var argument = spec.Calls[0].Call.Arguments[0]; - Assert.Equal(LuaValueKind.String, argument.Kind); - Assert.True(argument.IsNullable); - } - - [Fact] - public void A_duplicate_method_name_drops_every_entry_that_uses_it_with_one_issue_per_line() - { - const string Text = """ - namespace: Demo - type: T - - global: readInteger - method: M - form: try - arg: address:address - result: value:int32 - doc: first. - - global: readQword - method: M - form: try - arg: address:address - result: value:int64 - doc: second. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Empty(spec.CachedGlobals.AsSpan().ToArray()); - var duplicateIssues = spec.Issues.AsSpan().ToArray().Count(static issue => - issue.Message.Contains("Duplicate method name", StringComparison.Ordinal)); - Assert.Equal(2, duplicateIssues); - } - - [Fact] - public void An_invalid_entry_does_not_prevent_other_entries_from_being_emitted() - { - const string Text = """ - namespace: Demo - type: T - - global: notAName! - method: Bad - form: try - arg: address:address - result: value:int32 - doc: bad. - - global: readInteger - method: TryReadInt32 - form: try - arg: address:address - result: value:int32 - doc: good. - """; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Single(spec.Calls.AsSpan().ToArray()); - Assert.Equal("TryReadInt32", spec.Calls[0].Call.MethodName); - Assert.NotEmpty(spec.Issues.AsSpan().ToArray()); - } - - [Fact] - public void The_hint_name_is_empty_until_SpecFiles_assigns_it() - { - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", SpecSources.SingleTry); - - Assert.Equal(string.Empty, spec.HintName); - } - - /// A ce77 contract becomes immutable per-entry data rather than an ignored comment beside the spec. - [Fact] - public void A_ce77_contract_is_attached_to_every_valid_entry_with_its_nil_semantics() - { - var spec = SpecFileParser.Parse("memory.cheatengine-sdk-api.txt", SpecSources.Memory); - - Assert.Empty(spec.Issues.AsSpan().ToArray()); - Assert.Equal(4, spec.Calls.Length); - Assert.Equal("7.7.0.10621", Assert.IsType(spec.Contract).MinimumCheatEngineVersion); - var absenceCount = 0; - var noneCount = 0; - foreach (var entry in spec.Calls) - { - var contract = Assert.IsType(entry.Contract); - Assert.Equal("ExactInstalledFile: CE 7.7 celua.txt scalar memory globals", contract.Provenance); - Assert.Equal("7.7.0.10621", contract.MinimumCheatEngineVersion); - Assert.Equal("x64", contract.Architecture); - Assert.Equal("unknown", contract.ThreadAffinity); - Assert.Equal("none", contract.Ownership); - if (string.Equals(contract.NilSemantics, "absence", StringComparison.Ordinal)) absenceCount++; - if (string.Equals(contract.NilSemantics, "none", StringComparison.Ordinal)) noneCount++; - } - - Assert.Equal(2, absenceCount); - Assert.Equal(2, noneCount); - } - - /// Contract fields may not remain free-form comments: an invalid status is a localized grammar issue. - [Fact] - public void An_invalid_ce77_provenance_records_the_provenance_value_location() - { - const string Text = - "namespace: Demo\ntype: T\ncontract: ce77\nprovenance: unverified note\nminimum-ce: 7.7.0.10621\narchitecture: x64\nthread: unknown\nownership: none\n"; - - var spec = SpecFileParser.Parse("contract.cheatengine-sdk-api.txt", Text); - - var issue = Assert.Single(spec.Issues); - Assert.Equal(4, issue.Line); - Assert.Equal(13, issue.Column); - Assert.Contains("not a valid provenance", issue.Message, StringComparison.Ordinal); - } - - /// Every independently malformed CE 7.7 header fact is retained as its own source-located issue. - [Fact] - public void Invalid_ce77_contract_facts_report_each_exact_value_location() - { - const string Text = - "namespace: Demo\ntype: T\ncontract: ce77\nprovenance: not proof\nminimum-ce: seven\narchitecture: x86\nthread: worker\nownership: shared\n"; - - var spec = SpecFileParser.Parse("contract.cheatengine-sdk-api.txt", Text); - - Assert.Equal(5, spec.Issues.Length); - AssertIssue(spec, "not a valid provenance", 4, 13); - AssertIssue(spec, "not a valid minimum CE version", 5, 13); - AssertIssue(spec, "not a supported Engine API architecture", 6, 15); - AssertIssue(spec, "not a valid thread contract", 7, 9); - AssertIssue(spec, "not a valid ownership contract", 8, 12); - } - - /// Parser issues preserve the exact value column from an indented additional-file field. - [Fact] - public void An_invalid_argument_kind_records_its_value_column() - { - const string Text = - "namespace: Demo\ntype: T\n\n global: readInteger\n method: M\n form: try\n arg: address:notakind\n result: value:int32\n doc: d.\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - var issue = Assert.Single(spec.Issues); - Assert.Equal(7, issue.Line); - Assert.Equal(10, issue.Column); - } - - /// Reserved implementation locals and hidden raw-core method identities cannot reach generated C#. - [Fact] - public void Parameter_and_generated_member_identity_collisions_drop_the_affected_entries() - { - const string Text = - "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: BadParameter\nform: try\narg: __L:int32\nresult: value:int32\ndoc: bad.\n\nglobal: readInteger\nmethod: Read\nform: try\narg: address:address\nresult: value:int32\ndoc: raw core.\n\nglobal: readQword\nmethod: __ReadRaw\nform: try\nresult: value:int64\ndoc: collision.\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("reserved local", StringComparison.Ordinal)); - Assert.Contains(spec.Issues, - static issue => issue.Message.Contains("Generated member", StringComparison.Ordinal)); - } - - [Fact] - public void A_parameter_named_operation_is_rejected_as_an_emitter_local_collision() - { - const string Text = - "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: BadOperation\nform: try\narg: __operation:int32\nresult: value:int32\ndoc: bad.\n"; - - var spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); - - Assert.Empty(spec.Calls.AsSpan().ToArray()); - var issue = Assert.Single(spec.Issues.AsSpan().ToArray()); - Assert.Contains("__operation", issue.Message, StringComparison.Ordinal); - Assert.Contains("reserved local", issue.Message, StringComparison.Ordinal); - } - - private static void AssertIssue(SpecFileModel spec, string messageFragment, int line, int column) - { - var issue = Assert.Single(spec.Issues.AsSpan().ToArray(), issue => - issue.Message.Contains(messageFragment, StringComparison.Ordinal)); - Assert.Equal(line, issue.Line); - Assert.Equal(column, issue.Column); - } + [Fact] + public void IsSpecFile_matches_the_convention_extension_case_insensitively() + { + Assert.True(SpecFileParser.IsSpecFile("memory-scalars.cheatengine-sdk-api.txt")); + Assert.True(SpecFileParser.IsSpecFile(@"C:\repo\Specs\memory-scalars.CHEATENGINE-SDK-API.TXT")); + Assert.False(SpecFileParser.IsSpecFile("notes.txt")); + Assert.False(SpecFileParser.IsSpecFile("memory.cheatengine-sdk-api.txt.bak")); + Assert.False(SpecFileParser.IsSpecFile(null)); + } + + [Fact] + public void Nominal_spec_parses_into_four_sorted_calls_and_two_shared_free_cache_fields() + { + SpecFileModel spec = SpecFileParser.Parse("memory-scalars.cheatengine-sdk-api.txt", SpecSources.Memory); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Equal("Demo.Engine.Generated", spec.Namespace); + Assert.Equal("MemoryScalars", spec.TypeName); + Assert.Equal(4, spec.Calls.Length); + Assert.Equal(["readInteger", "readQword", "writeInteger", "writeQword"], spec.CachedGlobals.AsSpan().ToArray()); + + // Sorted by C# method name (ordinal). + string[] methodNames = [.. spec.Calls.AsSpan().ToArray().Select(static c => c.Call.MethodName)]; + Assert.Equal(["TryReadInt32", "TryReadInt64", "WriteInt32", "WriteInt64"], methodNames); + + SpecCallModel tryReadInt32 = spec.Calls[0]; + Assert.Equal("readInteger", tryReadInt32.Call.GlobalName); + Assert.Equal(LuaCallForm.Try, tryReadInt32.Call.Form); + Assert.Equal("public static", tryReadInt32.Call.Modifiers); + Assert.Equal(2, tryReadInt32.Call.Arguments.Length); + Assert.Equal(LuaValueKind.Address, tryReadInt32.Call.Arguments[0].Kind); + Assert.True(tryReadInt32.Call.Arguments[1].IsFixed); + Assert.Equal(LuaValueKind.Boolean, tryReadInt32.Call.Arguments[1].Kind); + Assert.Equal("true", tryReadInt32.Call.Arguments[1].FixedValue); + Assert.Single(tryReadInt32.Call.Results.AsSpan().ToArray()); + Assert.Equal(LuaValueKind.Int32, tryReadInt32.Call.Results[0].Kind); + Assert.StartsWith("Reads a 32-bit integer", tryReadInt32.Summary, StringComparison.Ordinal); + + SpecCallModel writeInt32 = spec.Calls[2]; + Assert.Equal(LuaCallForm.Throwing, writeInt32.Call.Form); + Assert.Equal(LuaValueKind.Boolean, Assert.NotNull(writeInt32.Call.ReturnKind)); + Assert.Empty(writeInt32.Call.Results.AsSpan().ToArray()); + } + + [Fact] + public void Two_forms_of_the_same_global_share_one_cache_field() + { + SpecFileModel spec = SpecFileParser.Parse("shared.cheatengine-sdk-api.txt", SpecSources.SharedGlobal); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Equal(2, spec.Calls.Length); + Assert.Equal(["readInteger"], spec.CachedGlobals.AsSpan().ToArray()); + Assert.Equal(spec.Calls[0].Call.CacheFieldName, spec.Calls[1].Call.CacheFieldName); + } + + [Fact] + public void Comments_and_blank_lines_are_ignored_wherever_they_appear() + { + const string Text = """ + # a file comment + namespace: Demo + # a comment between header keys + type: T + + # a comment before an entry + global: readInteger + # a comment between entry keys + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + # a trailing comment + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Single(spec.Calls.AsSpan().ToArray()); + } + + [Fact] + public void Indentation_and_CRLF_line_endings_are_tolerated() + { + string text = SpecSources.SingleTry.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\n", "\r\n", StringComparison.Ordinal); + text = " namespace: Demo.One\r\n type: One\r\n\r\n" + + text[text.IndexOf("global:", StringComparison.Ordinal)..]; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Equal("Demo.One", spec.Namespace); + Assert.Single(spec.Calls.AsSpan().ToArray()); + } + + [Fact] + public void Empty_text_produces_one_issue_and_no_output() + { + SpecFileModel spec = SpecFileParser.Parse("empty.cheatengine-sdk-api.txt", string.Empty); + + Assert.Equal(string.Empty, spec.Namespace); + Assert.Equal(string.Empty, spec.TypeName); + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Single(spec.Issues.AsSpan().ToArray()); + } + + [Fact] + public void A_comment_only_file_is_treated_as_empty() + { + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "# nothing here\n# still nothing\n"); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Single(spec.Issues.AsSpan().ToArray()); + } + + [Fact] + public void The_global_namespace_is_written_as_an_empty_namespace_value() + { + const string Text = """ + namespace: + type: Root + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Equal(string.Empty, spec.Namespace); + Assert.Equal("Root", spec.TypeName); + } + + [Fact] + public void A_line_without_a_colon_marks_its_whole_block_malformed() + { + const string Text = """ + namespace: Demo + type: T + + global readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("Malformed line", StringComparison.Ordinal)); + } + + [Fact] + public void A_malformed_header_line_drops_the_whole_file() + { + const string Text = "namespace Demo\ntype: T\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Equal(string.Empty, spec.Namespace); + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.NotEmpty(spec.Issues.AsSpan().ToArray()); + } + + [Fact] + public void A_duplicate_header_key_fails_the_header() + { + const string Text = "namespace: Demo\nnamespace: Other\ntype: T\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Equal(string.Empty, spec.Namespace); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("Duplicate header key", StringComparison.Ordinal)); + } + + [Fact] + public void An_unknown_header_key_fails_the_header() + { + const string Text = "namespace: Demo\ntype: T\nauthor: someone\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Equal(string.Empty, spec.Namespace); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("Unknown header key", StringComparison.Ordinal)); + } + + [Fact] + public void A_missing_type_key_fails_the_header() + { + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\n"); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, static issue => issue.Message.Contains("'type'", StringComparison.Ordinal)); + } + + [Fact] + public void An_invalid_namespace_fails_the_header() + { + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: 1Bad.Name\ntype: T\n"); + + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("not a valid namespace", StringComparison.Ordinal)); + } + + [Fact] + public void An_invalid_type_name_fails_the_header() + { + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\ntype: 1Bad\n"); + + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("not a valid type name", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("global")] + [InlineData("method")] + [InlineData("form")] + [InlineData("doc")] + public void An_entry_missing_a_required_key_is_dropped(string missingKey) + { + string entry = """ + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + """; + + string edited = string.Join( + '\n', + entry.Split('\n').Where(line => !line.StartsWith(missingKey + ":", StringComparison.Ordinal))); + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\ntype: T\n\n" + edited); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, issue => issue.Message.Contains("'" + missingKey + "'", StringComparison.Ordinal)); + } + + [Fact] + public void An_unknown_entry_key_drops_the_entry() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + extra: nonsense + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("Unknown entry key", StringComparison.Ordinal)); + } + + [Fact] + public void A_duplicate_entry_key_drops_the_entry() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + global: writeInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: Reads an integer. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("Duplicate entry key", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("Not A Name")] + [InlineData("1leading")] + [InlineData("end")] // a Lua reserved word + public void An_invalid_lua_global_name_drops_the_entry(string badName) + { + string text = "namespace: Demo\ntype: T\n\nglobal: " + badName + + "\nmethod: M\nform: try\narg: address:address\nresult: value:int32\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("not a valid Lua global name", StringComparison.Ordinal)); + } + + [Fact] + public void An_invalid_method_name_drops_the_entry() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: 1Bad + form: try + arg: address:address + result: value:int32 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("not a valid C# method name", StringComparison.Ordinal)); + } + + [Fact] + public void A_method_name_that_is_a_reserved_word_is_escaped_with_at() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: class + form: try + arg: address:address + result: value:int32 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Equal("@class", spec.Calls[0].Call.MethodName); + } + + [Fact] + public void An_invalid_form_value_drops_the_entry() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: maybe + arg: address:address + result: value:int32 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("not a valid form", StringComparison.Ordinal)); + } + + [Fact] + public void A_try_entry_with_no_result_is_dropped() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: try + arg: address:address + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("needs at least one 'result'", StringComparison.Ordinal)); + } + + [Fact] + public void A_try_entry_with_a_return_is_dropped() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: try + arg: address:address + result: value:int32 + return: int32 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("must not declare 'return'", StringComparison.Ordinal)); + } + + [Fact] + public void A_throwing_entry_with_a_result_is_dropped() + { + const string Text = """ + namespace: Demo + type: T + + global: writeInteger + method: M + form: throwing + arg: address:address + result: value:int32 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("must not declare 'result'", StringComparison.Ordinal)); + } + + [Fact] + public void A_throwing_entry_without_a_return_is_a_void_wrapper() + { + const string Text = """ + namespace: Demo + type: T + + global: beep + method: Beep + form: throwing + doc: Calls a global with no arguments and no result. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Null(spec.Calls[0].Call.ReturnKind); + Assert.Empty(spec.Calls[0].Call.Arguments.AsSpan().ToArray()); + } + + [Theory] + [InlineData("arg: address:notakind")] + [InlineData("arg: :address")] + [InlineData("arg: address")] + [InlineData("arg: 1bad:address")] + public void A_malformed_or_unknown_kind_argument_drops_the_entry(string argLine) + { + string text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\n" + argLine + + "\nresult: value:int32\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.NotEmpty(spec.Issues.AsSpan().ToArray()); + } + + [Theory] + [InlineData("fixed: boolean:maybe")] + [InlineData("fixed: int32:1")] + [InlineData("fixed: boolean:true; System.Console.WriteLine()")] + public void A_fixed_argument_accepts_only_boolean_literals(string fixedLine) + { + string text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\narg: address:address\n" + + fixedLine + "\nresult: value:int32\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("fixed argument", StringComparison.Ordinal)); + } + + [Fact] + public void A_utf8_result_is_rejected_because_it_would_dangle() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: try + arg: address:address + result: value:utf8 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("cannot be a result", StringComparison.Ordinal)); + } + + [Fact] + public void A_utf8_return_is_rejected_because_it_would_dangle() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: throwing + arg: address:address + return: utf8 + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("cannot be a return type", StringComparison.Ordinal)); + } + + [Fact] + public void An_invalid_return_kind_drops_the_entry() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: throwing + arg: address:address + return: notakind + doc: d. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("not a valid return kind", StringComparison.Ordinal)); + } + + [Fact] + public void A_string_argument_may_be_declared_nullable() + { + const string Text = """ + namespace: Demo + type: T + + global: upper + method: Upper + form: throwing + arg: text:string? + return: string + doc: Upper-cases a string. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + LuaArgumentModel argument = spec.Calls[0].Call.Arguments[0]; + Assert.Equal(LuaValueKind.String, argument.Kind); + Assert.True(argument.IsNullable); + } + + [Fact] + public void A_duplicate_method_name_drops_every_entry_that_uses_it_with_one_issue_per_line() + { + const string Text = """ + namespace: Demo + type: T + + global: readInteger + method: M + form: try + arg: address:address + result: value:int32 + doc: first. + + global: readQword + method: M + form: try + arg: address:address + result: value:int64 + doc: second. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Empty(spec.CachedGlobals.AsSpan().ToArray()); + int duplicateIssues = spec.Issues.AsSpan().ToArray().Count(static issue => + issue.Message.Contains("Duplicate method name", StringComparison.Ordinal)); + Assert.Equal(2, duplicateIssues); + } + + [Fact] + public void An_invalid_entry_does_not_prevent_other_entries_from_being_emitted() + { + const string Text = """ + namespace: Demo + type: T + + global: notAName! + method: Bad + form: try + arg: address:address + result: value:int32 + doc: bad. + + global: readInteger + method: TryReadInt32 + form: try + arg: address:address + result: value:int32 + doc: good. + """; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Single(spec.Calls.AsSpan().ToArray()); + Assert.Equal("TryReadInt32", spec.Calls[0].Call.MethodName); + Assert.NotEmpty(spec.Issues.AsSpan().ToArray()); + } + + [Fact] + public void The_hint_name_is_empty_until_SpecFiles_assigns_it() + { + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", SpecSources.SingleTry); + + Assert.Equal(string.Empty, spec.HintName); + } + + /// A ce77 contract becomes immutable per-entry data rather than an ignored comment beside the spec. + [Fact] + public void A_ce77_contract_is_attached_to_every_valid_entry_with_its_nil_semantics() + { + SpecFileModel spec = SpecFileParser.Parse("memory.cheatengine-sdk-api.txt", SpecSources.Memory); + + Assert.Empty(spec.Issues.AsSpan().ToArray()); + Assert.Equal(4, spec.Calls.Length); + Assert.Equal("7.7.0.10621", Assert.IsType(spec.Contract).MinimumCheatEngineVersion); + int absenceCount = 0; + int noneCount = 0; + foreach (SpecCallModel entry in spec.Calls) + { + SpecContract contract = Assert.IsType(entry.Contract); + Assert.Equal("ExactInstalledFile: CE 7.7 celua.txt scalar memory globals", contract.Provenance); + Assert.Equal("7.7.0.10621", contract.MinimumCheatEngineVersion); + Assert.Equal("x64", contract.Architecture); + Assert.Equal("unknown", contract.ThreadAffinity); + Assert.Equal("none", contract.Ownership); + if (string.Equals(contract.NilSemantics, "absence", StringComparison.Ordinal)) + { + absenceCount++; + } + + if (string.Equals(contract.NilSemantics, "none", StringComparison.Ordinal)) + { + noneCount++; + } + } + + Assert.Equal(2, absenceCount); + Assert.Equal(2, noneCount); + } + + /// Contract fields may not remain free-form comments: an invalid status is a localized grammar issue. + [Fact] + public void An_invalid_ce77_provenance_records_the_provenance_value_location() + { + const string Text = + "namespace: Demo\ntype: T\ncontract: ce77\nprovenance: unverified note\nminimum-ce: 7.7.0.10621\narchitecture: x64\nthread: unknown\nownership: none\n"; + + SpecFileModel spec = SpecFileParser.Parse("contract.cheatengine-sdk-api.txt", Text); + + SpecIssue issue = Assert.Single(spec.Issues); + Assert.Equal(4, issue.Line); + Assert.Equal(13, issue.Column); + Assert.Contains("not a valid provenance", issue.Message, StringComparison.Ordinal); + } + + /// Every independently malformed CE 7.7 header fact is retained as its own source-located issue. + [Fact] + public void Invalid_ce77_contract_facts_report_each_exact_value_location() + { + const string Text = + "namespace: Demo\ntype: T\ncontract: ce77\nprovenance: not proof\nminimum-ce: seven\narchitecture: x86\nthread: worker\nownership: shared\n"; + + SpecFileModel spec = SpecFileParser.Parse("contract.cheatengine-sdk-api.txt", Text); + + Assert.Equal(5, spec.Issues.Length); + AssertIssue(spec, "not a valid provenance", 4, 13); + AssertIssue(spec, "not a valid minimum CE version", 5, 13); + AssertIssue(spec, "not a supported Engine API architecture", 6, 15); + AssertIssue(spec, "not a valid thread contract", 7, 9); + AssertIssue(spec, "not a valid ownership contract", 8, 12); + } + + /// Parser issues preserve the exact value column from an indented additional-file field. + [Fact] + public void An_invalid_argument_kind_records_its_value_column() + { + const string Text = + "namespace: Demo\ntype: T\n\n global: readInteger\n method: M\n form: try\n arg: address:notakind\n result: value:int32\n doc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + SpecIssue issue = Assert.Single(spec.Issues); + Assert.Equal(7, issue.Line); + Assert.Equal(10, issue.Column); + } + + /// Reserved implementation locals and hidden raw-core method identities cannot reach generated C#. + [Fact] + public void Parameter_and_generated_member_identity_collisions_drop_the_affected_entries() + { + const string Text = + "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: BadParameter\nform: try\narg: __L:int32\nresult: value:int32\ndoc: bad.\n\nglobal: readInteger\nmethod: Read\nform: try\narg: address:address\nresult: value:int32\ndoc: raw core.\n\nglobal: readQword\nmethod: __ReadRaw\nform: try\nresult: value:int64\ndoc: collision.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("reserved local", StringComparison.Ordinal)); + Assert.Contains(spec.Issues, + static issue => issue.Message.Contains("Generated member", StringComparison.Ordinal)); + } + + [Fact] + public void A_parameter_named_operation_is_rejected_as_an_emitter_local_collision() + { + const string Text = + "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: BadOperation\nform: try\narg: __operation:int32\nresult: value:int32\ndoc: bad.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + SpecIssue issue = Assert.Single(spec.Issues.AsSpan().ToArray()); + Assert.Contains("__operation", issue.Message, StringComparison.Ordinal); + Assert.Contains("reserved local", issue.Message, StringComparison.Ordinal); + } + + private static void AssertIssue(SpecFileModel spec, string messageFragment, int line, int column) + { + SpecIssue issue = Assert.Single(spec.Issues.AsSpan().ToArray(), issue => + issue.Message.Contains(messageFragment, StringComparison.Ordinal)); + Assert.Equal(line, issue.Line); + Assert.Equal(column, issue.Column); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/BootstrapExecutionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/BootstrapExecutionTests.cs index dda0c36a..54c1c9f8 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/BootstrapExecutionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/BootstrapExecutionTests.cs @@ -1,4 +1,5 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -9,104 +10,104 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class BootstrapExecutionTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Entry_point_called_by_name_forwards_factory_to_the_host() - { - var run = roslyn.Run(PluginSources.Nominal); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - - var result = bootstrap.Initialize(IntPtr.Zero, 0); - - Assert.Equal(1, result); - Assert.Equal(1, bootstrap.HostCallCount); - Assert.Equal("Demo.DemoPlugin", bootstrap.LastPluginTypeName); - Assert.Equal("Demo Plugin"u8.ToArray(), bootstrap.LastUtf8Name); - } - - [Fact] - public void Entry_point_called_twice_forwards_both_calls() - { - // Cheat Engine calls the entry point twice (name query, then load). Idempotency is the host runtime's - // contract: the generated code must not cache or short-circuit anything. - var run = roslyn.Run(PluginSources.Nominal); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal(2, bootstrap.HostCallCount); - } - - [Theory] - [InlineData(0)] - [InlineData(6)] - [InlineData(36)] - [InlineData(40)] - [InlineData(int.MaxValue)] - public void Entry_point_forwards_the_host_second_argument_without_interpreting_it(int hostValue) - { - var run = roslyn.Run(PluginSources.Nominal); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, hostValue)); - Assert.Equal(1, bootstrap.HostCallCount); - Assert.Equal(hostValue, bootstrap.LastHostArgument); - } - - [Fact] - public void Entry_point_host_throws_returns_zero_instead_of_propagating() - { - var run = roslyn.Run(PluginSources.Nominal); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - - // A negative opaque argument makes the stub host throw. - var result = bootstrap.Initialize(IntPtr.Zero, -1); - - Assert.Equal(0, result); - Assert.Equal(1, bootstrap.HostCallCount); - Assert.Equal(-1, bootstrap.LastHostArgument); - } - - [Fact] - public void Entry_point_plugin_constructor_throws_returns_zero_instead_of_propagating() - { - var run = roslyn.Run($$""" - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Throws")] - public sealed class ThrowingPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - public ThrowingPlugin() => throw new System.InvalidOperationException("constructor failure"); - - {{PluginSources.LifecycleOverrides}} - } - """); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - - Assert.Equal(0, bootstrap.Initialize(IntPtr.Zero, 0)); - } - - [Fact] - public void Entry_point_nested_internal_plugin_is_constructed() - { - var run = roslyn.Run($$""" - namespace Demo - { - internal static class Outer - { - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Nested \u00E9")] - internal sealed class NestedPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - internal NestedPlugin() - { - } - - {{PluginSources.LifecycleOverrides}} - } - } - } - """); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal("Demo.Outer+NestedPlugin", bootstrap.LastPluginTypeName); - Assert.Equal(Encoding.UTF8.GetBytes("Nested \u00E9"), bootstrap.LastUtf8Name); - } + [Fact] + public void Entry_point_called_by_name_forwards_factory_to_the_host() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + + int result = bootstrap.Initialize(IntPtr.Zero, 0); + + Assert.Equal(1, result); + Assert.Equal(1, bootstrap.HostCallCount); + Assert.Equal("Demo.DemoPlugin", bootstrap.LastPluginTypeName); + Assert.Equal("Demo Plugin"u8.ToArray(), bootstrap.LastUtf8Name); + } + + [Fact] + public void Entry_point_called_twice_forwards_both_calls() + { + // Cheat Engine calls the entry point twice (name query, then load). Idempotency is the host runtime's + // contract: the generated code must not cache or short-circuit anything. + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal(2, bootstrap.HostCallCount); + } + + [Theory] + [InlineData(0)] + [InlineData(6)] + [InlineData(36)] + [InlineData(40)] + [InlineData(int.MaxValue)] + public void Entry_point_forwards_the_host_second_argument_without_interpreting_it(int hostValue) + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, hostValue)); + Assert.Equal(1, bootstrap.HostCallCount); + Assert.Equal(hostValue, bootstrap.LastHostArgument); + } + + [Fact] + public void Entry_point_host_throws_returns_zero_instead_of_propagating() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + + // A negative opaque argument makes the stub host throw. + int result = bootstrap.Initialize(IntPtr.Zero, -1); + + Assert.Equal(0, result); + Assert.Equal(1, bootstrap.HostCallCount); + Assert.Equal(-1, bootstrap.LastHostArgument); + } + + [Fact] + public void Entry_point_plugin_constructor_throws_returns_zero_instead_of_propagating() + { + GeneratorRun run = roslyn.Run($$""" + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Throws")] + public sealed class ThrowingPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + public ThrowingPlugin() => throw new System.InvalidOperationException("constructor failure"); + + {{PluginSources.LifecycleOverrides}} + } + """); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + + Assert.Equal(0, bootstrap.Initialize(IntPtr.Zero, 0)); + } + + [Fact] + public void Entry_point_nested_internal_plugin_is_constructed() + { + GeneratorRun run = roslyn.Run($$""" + namespace Demo + { + internal static class Outer + { + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Nested \u00E9")] + internal sealed class NestedPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + internal NestedPlugin() + { + } + + {{PluginSources.LifecycleOverrides}} + } + } + } + """); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal("Demo.Outer+NestedPlugin", bootstrap.LastPluginTypeName); + Assert.Equal(Encoding.UTF8.GetBytes("Nested \u00E9"), bootstrap.LastUtf8Name); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs index b48cb85d..5399c285 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs @@ -1,7 +1,10 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -11,177 +14,181 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class ContractIdentityTests(RoslynFixture roslyn) : IClassFixture { - private const string LifecycleOverrides = - "protected override void OnEnable() { } protected override void OnDisable() { }"; - - [Fact] - public void Generator_sdk_contract_symbols_from_the_expected_assemblies_emit_the_bootstrap() - { - var run = roslyn.Run(PluginSources.Nominal); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_file_local_entry_point_lookalike_does_not_block_the_bootstrap() - { - var run = roslyn.Run( - PluginSources.Nominal, - """ - namespace CESDK - { - file static class CESDK - { - } - } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_referenced_entry_point_lookalike_does_not_block_the_bootstrap() - { - var foreignEntryPoint = CreateReference( - roslyn.Environment, - "Foreign.EntryPoint", - """ - namespace CESDK - { - public static class CESDK - { - } - } - """); - var compilation = CreateCompilation( - roslyn.Environment, - roslyn.Environment.PluginReferences.Add(foreignEntryPoint), - PluginSources.Nominal); - - var run = RoslynFixture.Run(compilation); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_same_named_plugin_attribute_from_a_foreign_assembly_emits_nothing() - { - var foreignAttribute = CreateReference( - roslyn.Environment, - "Foreign.Plugin.Annotations", - """ - namespace CheatEngine.SDK.Annotations.Plugin - { - [global::System.AttributeUsage(global::System.AttributeTargets.Class)] - public sealed class CheatEnginePluginAttribute(string name) : global::System.Attribute - { - } - } - """); - var compilation = CreateCompilation( - roslyn.Environment, - roslyn.Environment.PluginReferences.Add(foreignAttribute.WithAliases(["foreign"])), - """ - extern alias foreign; - using CheatEngine.SDK.Hosting.Plugin; - - [foreign::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Foreign")] - public sealed class P : CheatEnginePlugin - { - protected override void OnEnable() { } - protected override void OnDisable() { } - } - """); - - var run = RoslynFixture.Run(compilation); - - run.AssertNoOutput(); - AssertNoSourceErrors(run); - } - - [Fact] - public void Generator_same_named_plugin_base_from_a_foreign_assembly_emits_nothing() - { - var foreignBase = CreateReference( - roslyn.Environment, - "Foreign.Plugin.Hosting", - """ - namespace CheatEngine.SDK.Hosting.Plugin - { - public abstract class CheatEnginePlugin - { - protected internal abstract void OnEnable(); - protected internal abstract void OnDisable(); - } - } - """); - var compilation = CreateCompilation( - roslyn.Environment, - roslyn.Environment.PluginReferences.Add(foreignBase.WithAliases(["foreign"])), - $$""" - extern alias foreign; - using CheatEngine.SDK.Annotations.Plugin; - - [CheatEnginePlugin("Foreign")] - public sealed class P : foreign::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{LifecycleOverrides}} - } - """); - - var run = RoslynFixture.Run(compilation); - - run.AssertNoOutput(); - AssertNoSourceErrors(run); - } - - private static CSharpCompilation CreateCompilation( - RoslynEnvironment environment, - ImmutableArray references, - string source) - { - return CSharpCompilation.Create( - RoslynFixture.PluginAssemblyName, - [CSharpSyntaxTree.ParseText( - source, - RoslynEnvironment.ParseOptions, - "Plugin.cs", - cancellationToken: TestContext.Current.CancellationToken)], - references, - RoslynEnvironment.CompilationOptions); - } - - private static PortableExecutableReference CreateReference( - RoslynEnvironment environment, - string assemblyName, - string source) - { - var compilation = CSharpCompilation.Create( - assemblyName, - [CSharpSyntaxTree.ParseText( - source, - RoslynEnvironment.ParseOptions, - assemblyName + ".cs", - cancellationToken: TestContext.Current.CancellationToken)], - environment.FrameworkReferences, - RoslynEnvironment.CompilationOptions); - using MemoryStream image = new(); - var result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(result.Success, "The foreign contract did not compile:\n" + string.Join('\n', result.Diagnostics)); - - return MetadataReference.CreateFromImage([.. image.ToArray()], filePath: assemblyName + ".dll"); - } - - private static void AssertNoSourceErrors(GeneratorRun run) - { - Assert.DoesNotContain( - run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken), - static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error - && (diagnostic.Location.SourceTree is null - || !diagnostic.Location.SourceTree.FilePath.EndsWith(".g.cs", - StringComparison.Ordinal))); - } + private const string LifecycleOverrides = + "protected override void OnEnable() { } protected override void OnDisable() { }"; + + [Fact] + public void Generator_sdk_contract_symbols_from_the_expected_assemblies_emit_the_bootstrap() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_file_local_entry_point_lookalike_does_not_block_the_bootstrap() + { + GeneratorRun run = roslyn.Run( + PluginSources.Nominal, + """ + namespace CESDK + { + file static class CESDK + { + } + } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_referenced_entry_point_lookalike_does_not_block_the_bootstrap() + { + PortableExecutableReference foreignEntryPoint = CreateReference( + roslyn.Environment, + "Foreign.EntryPoint", + """ + namespace CESDK + { + public static class CESDK + { + } + } + """); + CSharpCompilation compilation = CreateCompilation( + roslyn.Environment, + roslyn.Environment.PluginReferences.Add(foreignEntryPoint), + PluginSources.Nominal); + + GeneratorRun run = RoslynFixture.Run(compilation); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_same_named_plugin_attribute_from_a_foreign_assembly_emits_nothing() + { + PortableExecutableReference foreignAttribute = CreateReference( + roslyn.Environment, + "Foreign.Plugin.Annotations", + """ + namespace CheatEngine.SDK.Annotations.Plugin + { + [global::System.AttributeUsage(global::System.AttributeTargets.Class)] + public sealed class CheatEnginePluginAttribute(string name) : global::System.Attribute + { + } + } + """); + CSharpCompilation compilation = CreateCompilation( + roslyn.Environment, + roslyn.Environment.PluginReferences.Add(foreignAttribute.WithAliases(["foreign"])), + """ + extern alias foreign; + using CheatEngine.SDK.Hosting.Plugin; + + [foreign::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Foreign")] + public sealed class P : CheatEnginePlugin + { + protected override void OnEnable() { } + protected override void OnDisable() { } + } + """); + + GeneratorRun run = RoslynFixture.Run(compilation); + + run.AssertNoOutput(); + AssertNoSourceErrors(run); + } + + [Fact] + public void Generator_same_named_plugin_base_from_a_foreign_assembly_emits_nothing() + { + PortableExecutableReference foreignBase = CreateReference( + roslyn.Environment, + "Foreign.Plugin.Hosting", + """ + namespace CheatEngine.SDK.Hosting.Plugin + { + public abstract class CheatEnginePlugin + { + protected internal abstract void OnEnable(); + protected internal abstract void OnDisable(); + } + } + """); + CSharpCompilation compilation = CreateCompilation( + roslyn.Environment, + roslyn.Environment.PluginReferences.Add(foreignBase.WithAliases(["foreign"])), + $$""" + extern alias foreign; + using CheatEngine.SDK.Annotations.Plugin; + + [CheatEnginePlugin("Foreign")] + public sealed class P : foreign::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{LifecycleOverrides}} + } + """); + + GeneratorRun run = RoslynFixture.Run(compilation); + + run.AssertNoOutput(); + AssertNoSourceErrors(run); + } + + private static CSharpCompilation CreateCompilation( + RoslynEnvironment environment, + ImmutableArray references, + string source) + { + return CSharpCompilation.Create( + RoslynFixture.PluginAssemblyName, + [ + CSharpSyntaxTree.ParseText( + source, + RoslynEnvironment.ParseOptions, + "Plugin.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + references, + RoslynEnvironment.CompilationOptions); + } + + private static PortableExecutableReference CreateReference( + RoslynEnvironment environment, + string assemblyName, + string source) + { + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName, + [ + CSharpSyntaxTree.ParseText( + source, + RoslynEnvironment.ParseOptions, + assemblyName + ".cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + environment.FrameworkReferences, + RoslynEnvironment.CompilationOptions); + using MemoryStream image = new(); + EmitResult result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, "The foreign contract did not compile:\n" + string.Join('\n', result.Diagnostics)); + + return MetadataReference.CreateFromImage([.. image.ToArray()], filePath: assemblyName + ".dll"); + } + + private static void AssertNoSourceErrors(GeneratorRun run) + { + Assert.DoesNotContain( + run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken), + static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error + && (diagnostic.Location.SourceTree is null + || !diagnostic.Location.SourceTree.FilePath.EndsWith(".g.cs", + StringComparison.Ordinal))); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/DefaultVerifierTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/DefaultVerifierTests.cs index e6d8ab57..f4ac6f36 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/DefaultVerifierTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/DefaultVerifierTests.cs @@ -1,5 +1,7 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; @@ -15,91 +17,94 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class DefaultVerifierTests { - // This harness compiles with documentation diagnostics on: public members need XML comments (CS1591), which - // also proves that the generated file does not trip that rule. - private const string DocumentedPlugin = """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace Demo; - - /// Test plugin. - [CheatEnginePlugin("Demo Plugin")] - public sealed class DemoPlugin : CheatEnginePlugin - { - /// - protected override void OnEnable() { } - - /// - protected override void OnDisable() { } - } - """; - - [Fact] - public async Task Verifier_single_valid_plugin_with_direct_package_setting_matches_expected_source_and_compiles() - { - var test = CreateTest(); - test.TestState.GeneratedSources.Add(( - typeof(EntryPointGenerator), - ExpectedBootstrap.HintName, - SourceText.From(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), Encoding.UTF8))); - - await test.RunAsync(TestContext.Current.CancellationToken); - Assert.Single(test.TestState.GeneratedSources); - } - - [Fact] - public async Task Verifier_build_property_false_in_global_config_emits_nothing() - { - var test = CreateTest(applyDirectPackageSetting: false); - test.TestState.AnalyzerConfigFiles.Add(( - "/.globalconfig", - "is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = false\n")); - - // No entry in GeneratedSources: the verifier fails if the generator adds any file. - await test.RunAsync(TestContext.Current.CancellationToken); - Assert.Empty(test.TestState.GeneratedSources); - } - - [Fact] - public async Task Verifier_build_property_true_in_global_config_emits_the_bootstrap() - { - var test = CreateTest(applyDirectPackageSetting: false); - test.TestState.AnalyzerConfigFiles.Add(( - "/.globalconfig", - "is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n")); - test.TestState.GeneratedSources.Add(( - typeof(EntryPointGenerator), - ExpectedBootstrap.HintName, - SourceText.From(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), Encoding.UTF8))); - - await test.RunAsync(TestContext.Current.CancellationToken); - Assert.Single(test.TestState.GeneratedSources); - } - - private static CSharpSourceGeneratorTest CreateTest( - bool applyDirectPackageSetting = true) - { - var environment = RoslynEnvironment.Shared; - - CSharpSourceGeneratorTest test = new() - { - // A framework moniker WITHOUT a reference-assembly package: the library then resolves nothing through - // its NuGet client (ReferenceAssemblies.Net.Net100 would download Microsoft.NETCore.App.Ref on a cold - // cache; tests must not need the network). The framework comes from the local installation instead. - ReferenceAssemblies = new ReferenceAssemblies("net10.0"), - - // Warnings of the whole compilation (generated file included) fail the test, not only errors. - CompilerDiagnostics = CompilerDiagnostics.Warnings - }; - if (applyDirectPackageSetting) - test.TestState.AnalyzerConfigFiles.Add(( - "/.globalconfig", - "is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n")); - test.TestState.Sources.Add(DocumentedPlugin); - test.TestState.AdditionalReferences.AddRange(environment.FrameworkReferences); - test.TestState.AdditionalReferences.Add(environment.AnnotationsReference); - test.TestState.AdditionalReferences.Add(environment.HostingReference); - return test; - } + // This harness compiles with documentation diagnostics on: public members need XML comments (CS1591), which + // also proves that the generated file does not trip that rule. + private const string DocumentedPlugin = """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace Demo; + + /// Test plugin. + [CheatEnginePlugin("Demo Plugin")] + public sealed class DemoPlugin : CheatEnginePlugin + { + /// + protected override void OnEnable() { } + + /// + protected override void OnDisable() { } + } + """; + + [Fact] + public async Task Verifier_single_valid_plugin_with_direct_package_setting_matches_expected_source_and_compiles() + { + CSharpSourceGeneratorTest test = CreateTest(); + test.TestState.GeneratedSources.Add(( + typeof(EntryPointGenerator), + ExpectedBootstrap.HintName, + SourceText.From(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), Encoding.UTF8))); + + await test.RunAsync(TestContext.Current.CancellationToken); + Assert.Single(test.TestState.GeneratedSources); + } + + [Fact] + public async Task Verifier_build_property_false_in_global_config_emits_nothing() + { + CSharpSourceGeneratorTest test = CreateTest(false); + test.TestState.AnalyzerConfigFiles.Add(( + "/.globalconfig", + "is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = false\n")); + + // No entry in GeneratedSources: the verifier fails if the generator adds any file. + await test.RunAsync(TestContext.Current.CancellationToken); + Assert.Empty(test.TestState.GeneratedSources); + } + + [Fact] + public async Task Verifier_build_property_true_in_global_config_emits_the_bootstrap() + { + CSharpSourceGeneratorTest test = CreateTest(false); + test.TestState.AnalyzerConfigFiles.Add(( + "/.globalconfig", + "is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n")); + test.TestState.GeneratedSources.Add(( + typeof(EntryPointGenerator), + ExpectedBootstrap.HintName, + SourceText.From(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), Encoding.UTF8))); + + await test.RunAsync(TestContext.Current.CancellationToken); + Assert.Single(test.TestState.GeneratedSources); + } + + private static CSharpSourceGeneratorTest CreateTest( + bool applyDirectPackageSetting = true) + { + RoslynEnvironment environment = RoslynEnvironment.Shared; + + CSharpSourceGeneratorTest test = new() + { + // A framework moniker WITHOUT a reference-assembly package: the library then resolves nothing through + // its NuGet client (ReferenceAssemblies.Net.Net100 would download Microsoft.NETCore.App.Ref on a cold + // cache; tests must not need the network). The framework comes from the local installation instead. + ReferenceAssemblies = new ReferenceAssemblies("net10.0"), + + // Warnings of the whole compilation (generated file included) fail the test, not only errors. + CompilerDiagnostics = CompilerDiagnostics.Warnings + }; + if (applyDirectPackageSetting) + { + test.TestState.AnalyzerConfigFiles.Add(( + "/.globalconfig", + "is_global = true\nbuild_property.CheatEngineSdkGenerateEntryPoint = true\n")); + } + + test.TestState.Sources.Add(DocumentedPlugin); + test.TestState.AdditionalReferences.AddRange(environment.FrameworkReferences); + test.TestState.AdditionalReferences.Add(environment.AnnotationsReference); + test.TestState.AdditionalReferences.Add(environment.HostingReference); + return test; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs index 0f9f3fc7..249eef09 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -9,257 +11,261 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class IncrementalityTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Pipeline_first_run_tracks_every_named_step() - { - var run = roslyn.Run(PluginSources.Nominal); - - foreach (var stepName in EntryPointTrackingNames.All) - Assert.All(StepAssert.Reasons(run.Result, stepName), - static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); - - Assert.All(StepAssert.OutputReasons(run.Result), - static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); - } - - [Fact] - public void Pipeline_unrelated_class_added_in_another_file_recomputes_nothing() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var second = GeneratorRun.Execute( - first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse("namespace Demo; public sealed class Unrelated { }", - "Unrelated.cs"))); - - StepAssert.NothingWasRecomputed(second.Result); - Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText); - } - - [Fact] - public void Pipeline_comment_added_to_the_plugin_file_recomputes_nothing() - { - // The attributed node itself is re-parsed, so the transform runs again: the step must come out Unchanged - // because the model compares by value. - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.Single(); - var edited = RoslynFixture.Parse("// an unrelated comment\n" + PluginSources.Nominal + "\n// trailing", - original.FilePath); - var second = GeneratorRun.Execute(first.Driver, compilation.ReplaceSyntaxTree(original, edited)); - - StepAssert.NothingWasRecomputed(second.Result); - Assert.Contains(IncrementalStepRunReason.Unchanged, - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugin)); - } - - [Fact] - public void Pipeline_member_added_to_the_plugin_class_recomputes_nothing() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.Single(); - var editedSource = PluginSources.Nominal.Replace( - "protected override void OnEnable() { }", - "private int _counter;\n\n protected override void OnEnable() { _counter++; }", - StringComparison.Ordinal); - Assert.NotEqual(PluginSources.Nominal, editedSource, StringComparer.Ordinal); - var second = GeneratorRun.Execute( - first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); - - StepAssert.NothingWasRecomputed(second.Result); - } - - [Fact] - public void Pipeline_identical_compilation_recomputes_nothing() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var second = GeneratorRun.Execute(first.Driver, compilation); - - StepAssert.NothingWasRecomputed(second.Result); - } - - [Fact] - public void Pipeline_attribute_argument_edited_reruns_the_output() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.Single(); - var editedSource = - PluginSources.Nominal.Replace("\"Demo Plugin\"", "\"Renamed Plugin\"", StringComparison.Ordinal); - var second = GeneratorRun.Execute( - first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); - - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugin)); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugins)); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); - Assert.Equal([IncrementalStepRunReason.Modified], StepAssert.OutputReasons(second.Result)); - Assert.All( - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Options), - static reason => - Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Renamed Plugin\"u8"), - second.SingleGeneratedText); - } - - [Fact] - public void Pipeline_plugin_class_renamed_reruns_the_output() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.Single(); - var editedSource = - PluginSources.Nominal.Replace("class DemoPlugin", "class RenamedPlugin", StringComparison.Ordinal); - var second = GeneratorRun.Execute( - first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); - - Assert.Equal([IncrementalStepRunReason.Modified], StepAssert.OutputReasons(second.Result)); - Assert.Equal(ExpectedBootstrap.Text("global::Demo.RenamedPlugin", "\"Demo Plugin\"u8"), - second.SingleGeneratedText); - } - - [Fact] - public void Pipeline_plugin_class_marked_experimental_reruns_the_output() - { - // The third string of the final model: the diagnostic IDs the class declares. - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.Single(); - var editedSource = PluginSources.Nominal.Replace( - "public sealed class DemoPlugin", - "[System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] public sealed class DemoPlugin", - StringComparison.Ordinal); - var second = GeneratorRun.Execute( - first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); - - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); - Assert.Equal([IncrementalStepRunReason.Modified], StepAssert.OutputReasons(second.Result)); - Assert.Equal( - ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8", declaredDiagnosticIds: "EXP001"), - second.SingleGeneratedText); - } - - [Fact] - public void Pipeline_invalid_second_plugin_added_keeps_the_output_cached() - { - // The plugin list changes, the final model does not: this is what the extra Bootstrap projection buys. - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - - const string Invalid = - "[CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin(\"Broken\")] public abstract class Broken : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { }"; - var second = GeneratorRun.Execute(first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse(Invalid, "Broken.cs"))); - - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugins)); - Assert.Equal([IncrementalStepRunReason.Unchanged], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); - Assert.Equal([IncrementalStepRunReason.Cached], StepAssert.OutputReasons(second.Result)); - Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText); - } - - [Fact] - public void Pipeline_second_valid_plugin_added_removes_the_output() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - Assert.Single(first.GeneratedSources); - - var second = GeneratorRun.Execute( - first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse(PluginSources.WithNameExpression("\"Other\"", "OtherPlugin"), - "Other.cs"))); - - second.AssertNoOutput(); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); - } - - [Fact] - public void Pipeline_user_declared_entry_point_type_added_removes_the_output() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - Assert.Single(first.GeneratedSources); - - var second = GeneratorRun.Execute( - first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse( - "namespace CESDK { public static class CESDK { } }", - "UserEntryPoint.cs"))); - - second.AssertNoOutput(); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.EntryPointTypeCollision)); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); - } - - [Fact] - public void Pipeline_build_property_switched_off_removes_the_output() - { - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation); - Assert.Single(first.GeneratedSources); - - var switchedOff = first.Driver.WithUpdatedAnalyzerConfigOptions( - TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "false")); - var second = GeneratorRun.Execute(switchedOff, compilation); - - second.AssertNoOutput(); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Options)); - Assert.All( - StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugin), - static reason => - Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); - } - - [Fact] - public void Pipeline_equivalent_options_object_recomputes_nothing() - { - // A new provider instance with the same values: the parsed options compare equal, nothing flows further. - var compilation = roslyn.CreateCompilation(PluginSources.Nominal); - var first = RoslynFixture.Run(compilation, - TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "true")); - - var sameValues = first.Driver.WithUpdatedAnalyzerConfigOptions( - TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "TRUE")); - var second = GeneratorRun.Execute(sameValues, compilation); - - StepAssert.NothingWasRecomputed(second.Result); - } - - [Fact] - public void Pipeline_step_values_hold_no_roslyn_objects() - { - var run = roslyn.Run( - PluginSources.Nominal, - "[CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin(\"Broken\")] public abstract class Broken : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { }"); - - var visited = 0; - foreach (var stepName in EntryPointTrackingNames.All) - foreach (var step in run.Result.TrackedSteps[stepName]) - foreach (var (value, _) in step.Outputs) - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); - - Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); - } + [Fact] + public void Pipeline_first_run_tracks_every_named_step() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + foreach (string stepName in EntryPointTrackingNames.All) + { + Assert.All(StepAssert.Reasons(run.Result, stepName), + static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + } + + Assert.All(StepAssert.OutputReasons(run.Result), + static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + } + + [Fact] + public void Pipeline_unrelated_class_added_in_another_file_recomputes_nothing() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse("namespace Demo; public sealed class Unrelated { }", + "Unrelated.cs"))); + + StepAssert.NothingWasRecomputed(second.Result); + Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText); + } + + [Fact] + public void Pipeline_comment_added_to_the_plugin_file_recomputes_nothing() + { + // The attributed node itself is re-parsed, so the transform runs again: the step must come out Unchanged + // because the model compares by value. + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Single(); + SyntaxTree edited = RoslynFixture.Parse("// an unrelated comment\n" + PluginSources.Nominal + "\n// trailing", + original.FilePath); + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation.ReplaceSyntaxTree(original, edited)); + + StepAssert.NothingWasRecomputed(second.Result); + Assert.Contains(IncrementalStepRunReason.Unchanged, + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugin)); + } + + [Fact] + public void Pipeline_member_added_to_the_plugin_class_recomputes_nothing() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Single(); + string editedSource = PluginSources.Nominal.Replace( + "protected override void OnEnable() { }", + "private int _counter;\n\n protected override void OnEnable() { _counter++; }", + StringComparison.Ordinal); + Assert.NotEqual(PluginSources.Nominal, editedSource, StringComparer.Ordinal); + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); + + StepAssert.NothingWasRecomputed(second.Result); + } + + [Fact] + public void Pipeline_identical_compilation_recomputes_nothing() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation); + + StepAssert.NothingWasRecomputed(second.Result); + } + + [Fact] + public void Pipeline_attribute_argument_edited_reruns_the_output() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Single(); + string editedSource = + PluginSources.Nominal.Replace("\"Demo Plugin\"", "\"Renamed Plugin\"", StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); + + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugin)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugins)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); + Assert.Equal([IncrementalStepRunReason.Modified], StepAssert.OutputReasons(second.Result)); + Assert.All( + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Options), + static reason => + Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Renamed Plugin\"u8"), + second.SingleGeneratedText); + } + + [Fact] + public void Pipeline_plugin_class_renamed_reruns_the_output() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Single(); + string editedSource = + PluginSources.Nominal.Replace("class DemoPlugin", "class RenamedPlugin", StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); + + Assert.Equal([IncrementalStepRunReason.Modified], StepAssert.OutputReasons(second.Result)); + Assert.Equal(ExpectedBootstrap.Text("global::Demo.RenamedPlugin", "\"Demo Plugin\"u8"), + second.SingleGeneratedText); + } + + [Fact] + public void Pipeline_plugin_class_marked_experimental_reruns_the_output() + { + // The third string of the final model: the diagnostic IDs the class declares. + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Single(); + string editedSource = PluginSources.Nominal.Replace( + "public sealed class DemoPlugin", + "[System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] public sealed class DemoPlugin", + StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(editedSource, original.FilePath))); + + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); + Assert.Equal([IncrementalStepRunReason.Modified], StepAssert.OutputReasons(second.Result)); + Assert.Equal( + ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8", declaredDiagnosticIds: "EXP001"), + second.SingleGeneratedText); + } + + [Fact] + public void Pipeline_invalid_second_plugin_added_keeps_the_output_cached() + { + // The plugin list changes, the final model does not: this is what the extra Bootstrap projection buys. + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + + const string Invalid = + "[CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin(\"Broken\")] public abstract class Broken : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { }"; + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse(Invalid, "Broken.cs"))); + + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugins)); + Assert.Equal([IncrementalStepRunReason.Unchanged], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); + Assert.Equal([IncrementalStepRunReason.Cached], StepAssert.OutputReasons(second.Result)); + Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText); + } + + [Fact] + public void Pipeline_second_valid_plugin_added_removes_the_output() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + Assert.Single(first.GeneratedSources); + + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse(PluginSources.WithNameExpression("\"Other\"", "OtherPlugin"), + "Other.cs"))); + + second.AssertNoOutput(); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); + } + + [Fact] + public void Pipeline_user_declared_entry_point_type_added_removes_the_output() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + Assert.Single(first.GeneratedSources); + + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse( + "namespace CESDK { public static class CESDK { } }", + "UserEntryPoint.cs"))); + + second.AssertNoOutput(); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.EntryPointTypeCollision)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Bootstrap)); + } + + [Fact] + public void Pipeline_build_property_switched_off_removes_the_output() + { + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation); + Assert.Single(first.GeneratedSources); + + GeneratorDriver switchedOff = first.Driver.WithUpdatedAnalyzerConfigOptions( + TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "false")); + GeneratorRun second = GeneratorRun.Execute(switchedOff, compilation); + + second.AssertNoOutput(); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Options)); + Assert.All( + StepAssert.Reasons(second.Result, EntryPointTrackingNames.Plugin), + static reason => + Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); + } + + [Fact] + public void Pipeline_equivalent_options_object_recomputes_nothing() + { + // A new provider instance with the same values: the parsed options compare equal, nothing flows further. + CSharpCompilation compilation = roslyn.CreateCompilation(PluginSources.Nominal); + GeneratorRun first = RoslynFixture.Run(compilation, + TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "true")); + + GeneratorDriver sameValues = first.Driver.WithUpdatedAnalyzerConfigOptions( + TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "TRUE")); + GeneratorRun second = GeneratorRun.Execute(sameValues, compilation); + + StepAssert.NothingWasRecomputed(second.Result); + } + + [Fact] + public void Pipeline_step_values_hold_no_roslyn_objects() + { + GeneratorRun run = roslyn.Run( + PluginSources.Nominal, + "[CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin(\"Broken\")] public abstract class Broken : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { }"); + + int visited = 0; + foreach (string stepName in EntryPointTrackingNames.All) + foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } + + Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/KnownLimitationTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/KnownLimitationTests.cs index 2866f341..ba23649c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/KnownLimitationTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/KnownLimitationTests.cs @@ -1,4 +1,7 @@ +using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -14,69 +17,70 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class KnownLimitationTests(RoslynFixture roslyn) : IClassFixture { - private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; + private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; - private const string Members = "protected override void OnEnable() { } protected override void OnDisable() { }"; + private const string Members = "protected override void OnEnable() { } protected override void OnDisable() { }"; - [Fact] - public void Generator_sdk_referenced_only_through_an_extern_alias_fails_in_generated_code_with_CS0400() - { - // The generated file names the contract as global::CheatEngine.SDK.Hosting.*, which an aliased reference does not - // feed. No namespace CheatEngine exists in the global alias of this plugin, hence CS0400. - var references = roslyn.Environment.FrameworkReferences - .Add(roslyn.Environment.AnnotationsReference.WithAliases(["sdk"])) - .Add(roslyn.Environment.HostingReference.WithAliases(["sdk"])); - var compilation = CSharpCompilation.Create( - RoslynFixture.PluginAssemblyName, - [ - RoslynFixture.Parse( - $"extern alias sdk; namespace N {{ [sdk::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin(\"x\")] public sealed class P : sdk::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin {{ {Members} }} }}", - "Source0.cs") - ], - references, - RoslynEnvironment.CompilationOptions); + [Fact] + public void Generator_sdk_referenced_only_through_an_extern_alias_fails_in_generated_code_with_CS0400() + { + // The generated file names the contract as global::CheatEngine.SDK.Hosting.*, which an aliased reference does not + // feed. No namespace CheatEngine exists in the global alias of this plugin, hence CS0400. + ImmutableArray references = roslyn.Environment.FrameworkReferences + .Add(roslyn.Environment.AnnotationsReference.WithAliases(["sdk"])) + .Add(roslyn.Environment.HostingReference.WithAliases(["sdk"])); + CSharpCompilation compilation = CSharpCompilation.Create( + RoslynFixture.PluginAssemblyName, + [ + RoslynFixture.Parse( + $"extern alias sdk; namespace N {{ [sdk::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin(\"x\")] public sealed class P : sdk::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin {{ {Members} }} }}", + "Source0.cs") + ], + references, + RoslynEnvironment.CompilationOptions); - var run = RoslynFixture.Run(compilation); + GeneratorRun run = RoslynFixture.Run(compilation); - AssertOnlyErrorIsInGeneratedFile(run, "CS0400"); - } + AssertOnlyErrorIsInGeneratedFile(run, "CS0400"); + } - [Fact] - public void Generator_consumer_below_csharp_11_fails_in_generated_code_with_language_version_errors() - { - // C# 11 is the floor: 'file' types, u8 literals and static abstract interface members (see LanguageVersionTests - // for the passing side). The compiler's own messages name the version to use. - var csharp10 = RoslynEnvironment.ParseOptions.WithLanguageVersion(LanguageVersion.CSharp10); - var compilation = RoslynFixture.CreateCompilation(roslyn.Environment, csharp10, PluginSources.Nominal); + [Fact] + public void Generator_consumer_below_csharp_11_fails_in_generated_code_with_language_version_errors() + { + // C# 11 is the floor: 'file' types, u8 literals and static abstract interface members (see LanguageVersionTests + // for the passing side). The compiler's own messages name the version to use. + CSharpParseOptions csharp10 = RoslynEnvironment.ParseOptions.WithLanguageVersion(LanguageVersion.CSharp10); + CSharpCompilation compilation = + RoslynFixture.CreateCompilation(roslyn.Environment, csharp10, PluginSources.Nominal); - var run = RoslynFixture.Run(compilation, parseOptions: csharp10); + GeneratorRun run = RoslynFixture.Run(compilation, parseOptions: csharp10); - AssertOnlyErrorIsInGeneratedFile(run, "CS8936", "CS8706"); - } + AssertOnlyErrorIsInGeneratedFile(run, "CS8936", "CS8706"); + } - // One generated file, no generator diagnostic, and every compiler error is one of the expected IDs, located in - // the generated file (the author's own code stays clean), each expected ID occurring at least once. - private static void AssertOnlyErrorIsInGeneratedFile(GeneratorRun run, params string[] expectedIds) - { - Assert.Null(run.Result.Exception); - Assert.Empty(run.GeneratorDiagnostics); - Assert.Single(run.GeneratedSources); + // One generated file, no generator diagnostic, and every compiler error is one of the expected IDs, located in + // the generated file (the author's own code stays clean), each expected ID occurring at least once. + private static void AssertOnlyErrorIsInGeneratedFile(GeneratorRun run, params string[] expectedIds) + { + Assert.Null(run.Result.Exception); + Assert.Empty(run.GeneratorDiagnostics); + Assert.Single(run.GeneratedSources); - Diagnostic[] errors = - [ - .. run.OutputCompilation - .GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - ]; + Diagnostic[] errors = + [ + .. run.OutputCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + ]; - Assert.NotEmpty(errors); - Assert.All(errors, error => - { - Assert.Contains(error.Id, expectedIds, StringComparer.Ordinal); - Assert.EndsWith(ExpectedBootstrap.HintName, error.Location.SourceTree?.FilePath ?? string.Empty, - StringComparison.Ordinal); - }); - Assert.All(expectedIds, - id => Assert.Contains(errors, error => string.Equals(error.Id, id, StringComparison.Ordinal))); - } + Assert.NotEmpty(errors); + Assert.All(errors, error => + { + Assert.Contains(error.Id, expectedIds, StringComparer.Ordinal); + Assert.EndsWith(ExpectedBootstrap.HintName, error.Location.SourceTree?.FilePath ?? string.Empty, + StringComparison.Ordinal); + }); + Assert.All(expectedIds, + id => Assert.Contains(errors, error => string.Equals(error.Id, id, StringComparison.Ordinal))); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LanguageVersionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LanguageVersionTests.cs index c2b1c8bf..4d8cb801 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LanguageVersionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LanguageVersionTests.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis.CSharp; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -10,23 +11,24 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class LanguageVersionTests(RoslynFixture roslyn) : IClassFixture { - [Theory] - [InlineData(LanguageVersion.CSharp11)] - [InlineData(LanguageVersion.CSharp12)] - [InlineData(LanguageVersion.CSharp13)] - [InlineData(LanguageVersion.CSharp14)] - public void Generator_output_compiles_clean_and_runs_from_csharp_11_up(LanguageVersion version) - { - var parseOptions = RoslynEnvironment.ParseOptions.WithLanguageVersion(version); - var compilation = RoslynFixture.CreateCompilation(roslyn.Environment, parseOptions, PluginSources.Nominal); + [Theory] + [InlineData(LanguageVersion.CSharp11)] + [InlineData(LanguageVersion.CSharp12)] + [InlineData(LanguageVersion.CSharp13)] + [InlineData(LanguageVersion.CSharp14)] + public void Generator_output_compiles_clean_and_runs_from_csharp_11_up(LanguageVersion version) + { + CSharpParseOptions parseOptions = RoslynEnvironment.ParseOptions.WithLanguageVersion(version); + CSharpCompilation compilation = + RoslynFixture.CreateCompilation(roslyn.Environment, parseOptions, PluginSources.Nominal); - // The driver parses the generated tree with the consumer's language version, like the compiler does. - var run = RoslynFixture.Run(compilation, parseOptions: parseOptions); + // The driver parses the generated tree with the consumer's language version, like the compiler does. + GeneratorRun run = RoslynFixture.Run(compilation, parseOptions: parseOptions); - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - } + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LocalFrameworkReferencesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LocalFrameworkReferencesTests.cs index 5db91395..82062805 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LocalFrameworkReferencesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/LocalFrameworkReferencesTests.cs @@ -1,5 +1,9 @@ +using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -10,64 +14,64 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class LocalFrameworkReferencesTests { - [Fact] - public void Load_finds_the_framework_without_a_package_restore() - { - var references = LocalFrameworkReferences.Load(); - - Assert.NotEmpty(references); - Assert.All(references, - static reference => Assert.True(File.Exists(reference.Display), $"Not a local file: {reference.Display}")); - Assert.Contains(references, static reference => IsNamed(reference, "System.Runtime.dll")); - } - - [Fact] - public void FromRunningRuntime_holds_managed_framework_assemblies_only() - { - var references = LocalFrameworkReferences.FromRunningRuntime(); - - Assert.Contains(references, static reference => IsNamed(reference, "System.Private.CoreLib.dll")); - Assert.DoesNotContain(references, static reference => IsNamed(reference, "xunit.v3.core.dll")); - Assert.DoesNotContain(references, static reference => IsNamed(reference, "Microsoft.CodeAnalysis.dll")); - } - - [Fact] - public void Generator_output_compiles_clean_against_the_running_runtime_fallback() - { - var environment = RoslynEnvironment.Create(LocalFrameworkReferences.FromRunningRuntime()); - var compilation = - RoslynFixture.CreateCompilation(environment, RoslynEnvironment.ParseOptions, PluginSources.Nominal); - - var run = RoslynFixture.Run(compilation); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - - using var bootstrap = LoadedBootstrap.Load(environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - } - - [Fact] - public void Generator_output_compiles_clean_against_the_targeting_pack_when_one_is_installed() - { - var pack = LocalFrameworkReferences.FromTargetingPack(); - Assert.SkipWhen(pack.IsEmpty, - "No Microsoft.NETCore.App.Ref 10.0.x targeting pack next to the running runtime (runtime-only installation)."); - - var environment = RoslynEnvironment.Create(pack); - var compilation = - RoslynFixture.CreateCompilation(environment, RoslynEnvironment.ParseOptions, PluginSources.Nominal); - - var run = RoslynFixture.Run(compilation); - - Assert.All(pack, - static reference => - Assert.Contains("Microsoft.NETCore.App.Ref", reference.Display, StringComparison.Ordinal)); - run.AssertCompilesClean(); - } - - private static bool IsNamed(MetadataReference reference, string fileName) - { - return string.Equals(Path.GetFileName(reference.Display), fileName, StringComparison.OrdinalIgnoreCase); - } + [Fact] + public void Load_finds_the_framework_without_a_package_restore() + { + ImmutableArray references = LocalFrameworkReferences.Load(); + + Assert.NotEmpty(references); + Assert.All(references, + static reference => Assert.True(File.Exists(reference.Display), $"Not a local file: {reference.Display}")); + Assert.Contains(references, static reference => IsNamed(reference, "System.Runtime.dll")); + } + + [Fact] + public void FromRunningRuntime_holds_managed_framework_assemblies_only() + { + ImmutableArray references = LocalFrameworkReferences.FromRunningRuntime(); + + Assert.Contains(references, static reference => IsNamed(reference, "System.Private.CoreLib.dll")); + Assert.DoesNotContain(references, static reference => IsNamed(reference, "xunit.v3.core.dll")); + Assert.DoesNotContain(references, static reference => IsNamed(reference, "Microsoft.CodeAnalysis.dll")); + } + + [Fact] + public void Generator_output_compiles_clean_against_the_running_runtime_fallback() + { + RoslynEnvironment environment = RoslynEnvironment.Create(LocalFrameworkReferences.FromRunningRuntime()); + CSharpCompilation compilation = + RoslynFixture.CreateCompilation(environment, RoslynEnvironment.ParseOptions, PluginSources.Nominal); + + GeneratorRun run = RoslynFixture.Run(compilation); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(environment, run.OutputCompilation); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + } + + [Fact] + public void Generator_output_compiles_clean_against_the_targeting_pack_when_one_is_installed() + { + ImmutableArray pack = LocalFrameworkReferences.FromTargetingPack(); + Assert.SkipWhen(pack.IsEmpty, + "No Microsoft.NETCore.App.Ref 10.0.x targeting pack next to the running runtime (runtime-only installation)."); + + RoslynEnvironment environment = RoslynEnvironment.Create(pack); + CSharpCompilation compilation = + RoslynFixture.CreateCompilation(environment, RoslynEnvironment.ParseOptions, PluginSources.Nominal); + + GeneratorRun run = RoslynFixture.Run(compilation); + + Assert.All(pack, + static reference => + Assert.Contains("Microsoft.NETCore.App.Ref", reference.Display, StringComparison.Ordinal)); + run.AssertCompilesClean(); + } + + private static bool IsNamed(MetadataReference reference, string fileName) + { + return string.Equals(Path.GetFileName(reference.Display), fileName, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NameEscapingTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NameEscapingTests.cs index f28aad15..2803321c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NameEscapingTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NameEscapingTests.cs @@ -1,5 +1,7 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis.CSharp; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -11,83 +13,83 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class NameEscapingTests(RoslynFixture roslyn) : IClassFixture { - // Key -> (display name, expected line fragment in the generated file). Looked up by key so that theory rows stay - // plain ASCII identifiers (some names contain unpaired surrogates, which do not survive test-case serialisation). - private static readonly Dictionary Cases = - new(StringComparer.Ordinal) - { - ["plain"] = ("Demo Plugin 1.0", """Utf8Name => "Demo Plugin 1.0"u8;"""), - ["quotes"] = ("Say \"hi\"", """Utf8Name => "Say \"hi\""u8;"""), - ["triple quotes"] = ("\"\"\"raw\"\"\"", """Utf8Name => "\"\"\"raw\"\"\""u8;"""), - ["backslashes"] = (@"C:\Tools\CE\", """Utf8Name => "C:\\Tools\\CE\\"u8;"""), - ["backslash before quote"] = ("a\\\"b", """Utf8Name => "a\\\"b"u8;"""), - ["escape look-alike"] = (@"\n is not a newline, \u0041 is not A", - """Utf8Name => "\\n is not a newline, \\u0041 is not A"u8;"""), - ["line breaks and tab"] = ("line1\r\nline2\ttab", """Utf8Name => "line1\r\nline2\ttab"u8;"""), - ["short escapes"] = ("\0\a\b\f\v", """Utf8Name => "\0\a\b\f\v"u8;"""), - ["nul before digit"] = ("a\01", """Utf8Name => "a\01"u8;"""), - ["control characters"] = ("\u0001\u001F\u007F\u0085\u009F", - """Utf8Name => "\u0001\u001F\u007F\u0085\u009F"u8;"""), - ["unicode line separators"] = ("a\u2028b\u2029c", """Utf8Name => "a\u2028b\u2029c"u8;"""), - ["latin accents"] = ("Caf\u00E9 \u00DCber", """Utf8Name => "Caf\u00E9 \u00DCber"u8;"""), - ["cjk"] = ("\u65E5\u672C\u8A9E", """Utf8Name => "\u65E5\u672C\u8A9E"u8;"""), - ["surrogate pair"] = ("\U0001F600 plugin \U0001D11E", """Utf8Name => "\U0001F600 plugin \U0001D11E"u8;"""), - ["byte order mark"] = ("\uFEFFname", """Utf8Name => "\uFEFFname"u8;"""), - ["lone high surrogate"] = ("bad\uD800end", """Utf8Name => "bad\uFFFDend"u8;"""), - ["lone low surrogate"] = ("\uDC00start", """Utf8Name => "\uFFFDstart"u8;"""), - ["reversed surrogates"] = ("x\uDE00\uD83Dy", """Utf8Name => "x\uFFFD\uFFFDy"u8;"""), - ["trailing high surrogate"] = ("end\uD83D", """Utf8Name => "end\uFFFD"u8;"""), - ["csharp punctuation"] = ("{braces} $dollar @at 'single' // comment /* block */", - """Utf8Name => "{braces} $dollar @at 'single' // comment /* block */"u8;"""), - ["surrounding spaces"] = (" padded ", """Utf8Name => " padded "u8;""") - }; + // Key -> (display name, expected line fragment in the generated file). Looked up by key so that theory rows stay + // plain ASCII identifiers (some names contain unpaired surrogates, which do not survive test-case serialisation). + private static readonly Dictionary Cases = + new(StringComparer.Ordinal) + { + ["plain"] = ("Demo Plugin 1.0", """Utf8Name => "Demo Plugin 1.0"u8;"""), + ["quotes"] = ("Say \"hi\"", """Utf8Name => "Say \"hi\""u8;"""), + ["triple quotes"] = ("\"\"\"raw\"\"\"", """Utf8Name => "\"\"\"raw\"\"\""u8;"""), + ["backslashes"] = (@"C:\Tools\CE\", """Utf8Name => "C:\\Tools\\CE\\"u8;"""), + ["backslash before quote"] = ("a\\\"b", """Utf8Name => "a\\\"b"u8;"""), + ["escape look-alike"] = (@"\n is not a newline, \u0041 is not A", + """Utf8Name => "\\n is not a newline, \\u0041 is not A"u8;"""), + ["line breaks and tab"] = ("line1\r\nline2\ttab", """Utf8Name => "line1\r\nline2\ttab"u8;"""), + ["short escapes"] = ("\0\a\b\f\v", """Utf8Name => "\0\a\b\f\v"u8;"""), + ["nul before digit"] = ("a\01", """Utf8Name => "a\01"u8;"""), + ["control characters"] = ("\u0001\u001F\u007F\u0085\u009F", + """Utf8Name => "\u0001\u001F\u007F\u0085\u009F"u8;"""), + ["unicode line separators"] = ("a\u2028b\u2029c", """Utf8Name => "a\u2028b\u2029c"u8;"""), + ["latin accents"] = ("Caf\u00E9 \u00DCber", """Utf8Name => "Caf\u00E9 \u00DCber"u8;"""), + ["cjk"] = ("\u65E5\u672C\u8A9E", """Utf8Name => "\u65E5\u672C\u8A9E"u8;"""), + ["surrogate pair"] = ("\U0001F600 plugin \U0001D11E", """Utf8Name => "\U0001F600 plugin \U0001D11E"u8;"""), + ["byte order mark"] = ("\uFEFFname", """Utf8Name => "\uFEFFname"u8;"""), + ["lone high surrogate"] = ("bad\uD800end", """Utf8Name => "bad\uFFFDend"u8;"""), + ["lone low surrogate"] = ("\uDC00start", """Utf8Name => "\uFFFDstart"u8;"""), + ["reversed surrogates"] = ("x\uDE00\uD83Dy", """Utf8Name => "x\uFFFD\uFFFDy"u8;"""), + ["trailing high surrogate"] = ("end\uD83D", """Utf8Name => "end\uFFFD"u8;"""), + ["csharp punctuation"] = ("{braces} $dollar @at 'single' // comment /* block */", + """Utf8Name => "{braces} $dollar @at 'single' // comment /* block */"u8;"""), + ["surrounding spaces"] = (" padded ", """Utf8Name => " padded "u8;""") + }; - public static TheoryData CaseKeys => [.. Cases.Keys]; + public static TheoryData CaseKeys => [.. Cases.Keys]; - [Theory] - [MemberData(nameof(CaseKeys))] - public void Generator_display_name_is_escaped_into_a_u8_literal(string caseKey) - { - var (name, expectedFragment) = Cases[caseKey]; + [Theory] + [MemberData(nameof(CaseKeys))] + public void Generator_display_name_is_escaped_into_a_u8_literal(string caseKey) + { + (string name, string expectedFragment) = Cases[caseKey]; - // Roslyn's own literal formatter writes the attribute argument: independent of the escaper under test. - var run = roslyn.Run(PluginSources.WithNameExpression(SymbolDisplay.FormatLiteral(name, true))); + // Roslyn's own literal formatter writes the attribute argument: independent of the escaper under test. + GeneratorRun run = roslyn.Run(PluginSources.WithNameExpression(SymbolDisplay.FormatLiteral(name, true))); - var generated = run.SingleGeneratedText; - Assert.Contains("> " + expectedFragment + "\n", generated, StringComparison.Ordinal); + string generated = run.SingleGeneratedText; + Assert.Contains("> " + expectedFragment + "\n", generated, StringComparison.Ordinal); - // Holds because the plugin type of these cases (Demo.DemoPlugin) is ASCII: whatever the display name is, the - // literal adds no other character. Identifiers are written as declared (PluginLocationTests, non-ASCII case). - Assert.All(generated, - static c => Assert.True(c is '\n' or >= ' ' and <= '~', - $"Non-ASCII character U+{(int)c:X4} in generated text.")); - run.AssertCompilesClean(); - } + // Holds because the plugin type of these cases (Demo.DemoPlugin) is ASCII: whatever the display name is, the + // literal adds no other character. Identifiers are written as declared (PluginLocationTests, non-ASCII case). + Assert.All(generated, + static c => Assert.True(c is '\n' or >= ' ' and <= '~', + $"Non-ASCII character U+{(int) c:X4} in generated text.")); + run.AssertCompilesClean(); + } - [Theory] - [MemberData(nameof(CaseKeys))] - public void Generator_display_name_bytes_equal_the_utf8_encoding_of_the_name(string caseKey) - { - var (name, _) = Cases[caseKey]; - var run = roslyn.Run(PluginSources.WithNameExpression(SymbolDisplay.FormatLiteral(name, true))); - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + [Theory] + [MemberData(nameof(CaseKeys))] + public void Generator_display_name_bytes_equal_the_utf8_encoding_of_the_name(string caseKey) + { + (string name, _) = Cases[caseKey]; + GeneratorRun run = roslyn.Run(PluginSources.WithNameExpression(SymbolDisplay.FormatLiteral(name, true))); + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - // Encoding.UTF8 replaces unpaired surrogates with U+FFFD, which is the documented behaviour of the literal too. - Assert.Equal(Encoding.UTF8.GetBytes(name), bootstrap.LastUtf8Name); - } + // Encoding.UTF8 replaces unpaired surrogates with U+FFFD, which is the documented behaviour of the literal too. + Assert.Equal(Encoding.UTF8.GetBytes(name), bootstrap.LastUtf8Name); + } - [Fact] - public void Generator_verbatim_and_raw_string_arguments_are_read_by_value() - { - var verbatim = roslyn.Run(PluginSources.WithNameExpression("@\"C:\\dir \"\"quoted\"\" \"")); - var raw = roslyn.Run(PluginSources.WithNameExpression("\"\"\"C:\\dir \"quoted\" \"\"\"")); + [Fact] + public void Generator_verbatim_and_raw_string_arguments_are_read_by_value() + { + GeneratorRun verbatim = roslyn.Run(PluginSources.WithNameExpression("@\"C:\\dir \"\"quoted\"\" \"")); + GeneratorRun raw = roslyn.Run(PluginSources.WithNameExpression("\"\"\"C:\\dir \"quoted\" \"\"\"")); - const string Expected = """Utf8Name => "C:\\dir \"quoted\" "u8;"""; - Assert.Contains(Expected, verbatim.SingleGeneratedText, StringComparison.Ordinal); - Assert.Contains(Expected, raw.SingleGeneratedText, StringComparison.Ordinal); - verbatim.AssertCompilesClean(); - raw.AssertCompilesClean(); - } + const string Expected = """Utf8Name => "C:\\dir \"quoted\" "u8;"""; + Assert.Contains(Expected, verbatim.SingleGeneratedText, StringComparison.Ordinal); + Assert.Contains(Expected, raw.SingleGeneratedText, StringComparison.Ordinal); + verbatim.AssertCompilesClean(); + raw.AssertCompilesClean(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NoOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NoOutputTests.cs index 4b42f702..d2d860fe 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NoOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NoOutputTests.cs @@ -8,239 +8,255 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class NoOutputTests(RoslynFixture roslyn) : IClassFixture { - private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; - - private const string Body = "{ protected override void OnEnable() { } protected override void OnDisable() { } }"; - - public static TheoryData InvalidShapes - { - get - { - var data = new TheoryData(); - foreach (var (shape, declaration) in ClassShapeRejections()) data.Add(shape, declaration); - foreach (var (shape, declaration) in AccessibilityAndBaseRejections()) data.Add(shape, declaration); - foreach (var (shape, declaration) in ConstructorRejections()) data.Add(shape, declaration); - foreach (var (shape, declaration) in NameAndEntryPointRejections()) data.Add(shape, declaration); - return data; - } - } - - private static IEnumerable<(string Shape, string Declaration)> ClassShapeRejections() - { - yield return ("abstract class", - $"[CheatEnginePlugin(\"P\")] public abstract class P : CheatEnginePlugin {Body}"); - yield return ("static class", "[CheatEnginePlugin(\"P\")] public static class P { }"); - yield return ("generic class", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}"); - yield return ( - "nested in a generic class", - $"public static class Outer {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }}"); - yield return ("struct", "[CheatEnginePlugin(\"P\")] public struct P { }"); - yield return ("record class", "[CheatEnginePlugin(\"P\")] public sealed record P : CheatEnginePlugin;"); - } - - private static IEnumerable<(string Shape, string Declaration)> AccessibilityAndBaseRejections() - { - yield return ("not derived from the plugin base", "[CheatEnginePlugin(\"P\")] public sealed class P { }"); - yield return ( - "derived from a look-alike base", - "namespace Other.Hosting { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : Other.Hosting.CheatEnginePlugin { }"); - // The SDK namespace is two segments deep: a base that lacks the SDK segment, or that sits under another root, - // is not the plugin base either. - yield return ( - "derived from a look-alike base without the SDK segment", - "namespace CheatEngine.Hosting.Plugin { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : CheatEngine.Hosting.Plugin.CheatEnginePlugin { }"); - yield return ( - "derived from a look-alike base under another root namespace", - "namespace Other.CheatEngine.SDK.Hosting.Plugin { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : Other.CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { }"); - yield return ( - "private nested class", - $"public static class Outer {{ [CheatEnginePlugin(\"P\")] private sealed class P : CheatEnginePlugin {Body} }}"); - yield return ( - "protected nested class", - $"public class Outer {{ [CheatEnginePlugin(\"P\")] protected sealed class P : CheatEnginePlugin {Body} }}"); - yield return ( - "nested in a private class", - $"public static class Outer {{ private static class Hidden {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}"); - yield return ("file-local class", $"[CheatEnginePlugin(\"P\")] file sealed class P : CheatEnginePlugin {Body}"); - } - - // The last three cases are rejected by the shape predicate that the generator shares with CESDK0001: emitting - // would make the compiler fail the generated file with CS9035 / CS0619. - private static IEnumerable<(string Shape, string Declaration)> ConstructorRejections() - { - yield return ( - "no parameterless constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value) {{ _ = value; }} {Body[1..]}"); - yield return ( - "optional-only constructor is not a parameterless contract", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value = 0) {{ _ = value; }} {Body[1..]}"); - yield return ( - "params-only constructor is not a parameterless contract", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(params int[] values) {{ _ = values; }} {Body[1..]}"); - yield return ( - "private constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ private P() {{ }} {Body[1..]}"); - yield return ( - "protected constructor", - $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected P() {{ }} {Body[1..]}"); - yield return ( - "required member without a constructor that sets it", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public required int Value {{ get; init; }} {Body[1..]}"); - yield return ( - "optional constructor that sets required members is not a parameterless contract", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] public P(int value = 0) {{ Value = value; }} public required int Value {{ get; init; }} {Body[1..]}"); - yield return ( - "obsolete as error on the class", - $"[System.Obsolete(\"no\", true)] [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}"); - yield return ( - "obsolete as error on the constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Obsolete(\"no\", true)] public P() {{ }} {Body[1..]}"); - } - - // The host imposes the name CESDK.CESDK on the generated entry point: a plugin that takes it (or lives inside a - // type that does) leaves no room for it. Emitting anyway gives CS0101 plus misleading errors in the author's own - // file (the last two entries). - private static IEnumerable<(string Shape, string Declaration)> NameAndEntryPointRejections() - { - yield return ("empty name", $"[CheatEnginePlugin(\"\")] public sealed class P : CheatEnginePlugin {Body}"); - yield return ( - "white-space name", - $"[CheatEnginePlugin(\" \\t\\u00A0\")] public sealed class P : CheatEnginePlugin {Body}"); - yield return ("null name", $"[CheatEnginePlugin(null!)] public sealed class P : CheatEnginePlugin {Body}"); - yield return ("missing name argument", $"[CheatEnginePlugin] public sealed class P : CheatEnginePlugin {Body}"); - yield return ( - "named like the entry point", - $"namespace CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class CESDK : CheatEnginePlugin {Body} }}"); - yield return ( - "nested in a type named like the entry point", - $"namespace CESDK {{ public static class CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}"); - } - - [Fact] - public void Generator_no_plugin_class_emits_nothing() - { - var run = roslyn.Run("namespace Demo; public sealed class NotAPlugin { }"); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_plugin_base_without_the_attribute_emits_nothing() - { - // Discovery is attribute-driven: deriving from the base class alone is not a plugin declaration. - var run = roslyn.Run($"{Usings} public sealed class P : CheatEnginePlugin {Body}"); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_user_declared_host_entry_point_type_emits_nothing() - { - // The user type is not a plugin class, so PluginShape cannot see it. Emitting a second CESDK.CESDK would be a - // duplicate type error in generated code; CESDK0005 is the analyzer's source-local explanation. - var run = roslyn.Run( - PluginSources.Nominal, - "namespace CESDK { public static class CESDK { } }"); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_attribute_with_the_same_simple_name_from_another_namespace_emits_nothing() - { - var run = roslyn.Run($$""" - namespace Other - { - [System.AttributeUsage(System.AttributeTargets.Class)] - public sealed class CheatEnginePluginAttribute(string name) : System.Attribute - { - public string Name { get; } = name; - } - } - - [Other.CheatEnginePlugin("P")] - public sealed class P : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin {{Body}} - """); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_two_valid_plugins_emits_nothing() - { - var run = roslyn.Run( - PluginSources.WithNameExpression("\"First\"", "FirstPlugin"), - PluginSources.WithNameExpression("\"Second\"", "SecondPlugin")); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_two_valid_plugins_in_one_file_emits_nothing() - { - var run = roslyn.Run( - $"{Usings} [CheatEnginePlugin(\"A\")] public sealed class A : CheatEnginePlugin {Body} [CheatEnginePlugin(\"B\")] public sealed class B : CheatEnginePlugin {Body}"); - - run.AssertNoOutput(); - } - - [Theory] - [MemberData(nameof(InvalidShapes))] - public void Generator_invalid_plugin_shape_emits_nothing(string shape, string declaration) - { - var run = roslyn.Run(Usings + declaration); - - Assert.True(run.GeneratedSources.IsEmpty, $"Unexpected output for: {shape}"); - run.AssertNoOutput(); - } - - [Fact] - public void Generator_one_valid_and_one_invalid_plugin_emits_for_the_valid_one() - { - // "Exactly one VALID plugin": the abstract class is CESDK0001's business and does not make the valid - // class ambiguous. - var run = roslyn.Run( - PluginSources.Nominal, - $"{Usings} [CheatEnginePlugin(\"Broken\")] public abstract class Broken : CheatEnginePlugin {{ }}"); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Theory] - [InlineData("false")] - [InlineData("False")] - [InlineData("FALSE")] - [InlineData(" false ")] - public void Generator_build_property_false_emits_nothing(string value) - { - var run = RoslynFixture.Run( - roslyn.CreateCompilation(PluginSources.Nominal), - TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", value)); - - run.AssertNoOutput(); - } - - [Theory] - [InlineData("true")] - [InlineData("True")] - public void Generator_build_property_true_emits(string value) - { - var run = RoslynFixture.Run( - roslyn.CreateCompilation(PluginSources.Nominal), - TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", value)); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); - } - - [Fact] - public void Generator_missing_direct_package_property_emits_nothing() - { - var run = RoslynFixture.Run( - roslyn.CreateCompilation(PluginSources.Nominal), - TestAnalyzerConfigOptionsProvider.Empty); - - run.AssertNoOutput(); - } + private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; + + private const string Body = "{ protected override void OnEnable() { } protected override void OnDisable() { } }"; + + public static TheoryData InvalidShapes + { + get + { + TheoryData data = new(); + foreach ((string shape, string declaration) in ClassShapeRejections()) + { + data.Add(shape, declaration); + } + + foreach ((string shape, string declaration) in AccessibilityAndBaseRejections()) + { + data.Add(shape, declaration); + } + + foreach ((string shape, string declaration) in ConstructorRejections()) + { + data.Add(shape, declaration); + } + + foreach ((string shape, string declaration) in NameAndEntryPointRejections()) + { + data.Add(shape, declaration); + } + + return data; + } + } + + private static IEnumerable<(string Shape, string Declaration)> ClassShapeRejections() + { + yield return ("abstract class", + $"[CheatEnginePlugin(\"P\")] public abstract class P : CheatEnginePlugin {Body}"); + yield return ("static class", "[CheatEnginePlugin(\"P\")] public static class P { }"); + yield return ("generic class", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}"); + yield return ( + "nested in a generic class", + $"public static class Outer {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }}"); + yield return ("struct", "[CheatEnginePlugin(\"P\")] public struct P { }"); + yield return ("record class", "[CheatEnginePlugin(\"P\")] public sealed record P : CheatEnginePlugin;"); + } + + private static IEnumerable<(string Shape, string Declaration)> AccessibilityAndBaseRejections() + { + yield return ("not derived from the plugin base", "[CheatEnginePlugin(\"P\")] public sealed class P { }"); + yield return ( + "derived from a look-alike base", + "namespace Other.Hosting { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : Other.Hosting.CheatEnginePlugin { }"); + // The SDK namespace is two segments deep: a base that lacks the SDK segment, or that sits under another root, + // is not the plugin base either. + yield return ( + "derived from a look-alike base without the SDK segment", + "namespace CheatEngine.Hosting.Plugin { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : CheatEngine.Hosting.Plugin.CheatEnginePlugin { }"); + yield return ( + "derived from a look-alike base under another root namespace", + "namespace Other.CheatEngine.SDK.Hosting.Plugin { public abstract class CheatEnginePlugin { } } [CheatEnginePlugin(\"P\")] public sealed class P : Other.CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { }"); + yield return ( + "private nested class", + $"public static class Outer {{ [CheatEnginePlugin(\"P\")] private sealed class P : CheatEnginePlugin {Body} }}"); + yield return ( + "protected nested class", + $"public class Outer {{ [CheatEnginePlugin(\"P\")] protected sealed class P : CheatEnginePlugin {Body} }}"); + yield return ( + "nested in a private class", + $"public static class Outer {{ private static class Hidden {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}"); + yield return ("file-local class", $"[CheatEnginePlugin(\"P\")] file sealed class P : CheatEnginePlugin {Body}"); + } + + // The last three cases are rejected by the shape predicate that the generator shares with CESDK0001: emitting + // would make the compiler fail the generated file with CS9035 / CS0619. + private static IEnumerable<(string Shape, string Declaration)> ConstructorRejections() + { + yield return ( + "no parameterless constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value) {{ _ = value; }} {Body[1..]}"); + yield return ( + "optional-only constructor is not a parameterless contract", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(int value = 0) {{ _ = value; }} {Body[1..]}"); + yield return ( + "params-only constructor is not a parameterless contract", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P(params int[] values) {{ _ = values; }} {Body[1..]}"); + yield return ( + "private constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ private P() {{ }} {Body[1..]}"); + yield return ( + "protected constructor", + $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected P() {{ }} {Body[1..]}"); + yield return ( + "required member without a constructor that sets it", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public required int Value {{ get; init; }} {Body[1..]}"); + yield return ( + "optional constructor that sets required members is not a parameterless contract", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] public P(int value = 0) {{ Value = value; }} public required int Value {{ get; init; }} {Body[1..]}"); + yield return ( + "obsolete as error on the class", + $"[System.Obsolete(\"no\", true)] [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body}"); + yield return ( + "obsolete as error on the constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Obsolete(\"no\", true)] public P() {{ }} {Body[1..]}"); + } + + // The host imposes the name CESDK.CESDK on the generated entry point: a plugin that takes it (or lives inside a + // type that does) leaves no room for it. Emitting anyway gives CS0101 plus misleading errors in the author's own + // file (the last two entries). + private static IEnumerable<(string Shape, string Declaration)> NameAndEntryPointRejections() + { + yield return ("empty name", $"[CheatEnginePlugin(\"\")] public sealed class P : CheatEnginePlugin {Body}"); + yield return ( + "white-space name", + $"[CheatEnginePlugin(\" \\t\\u00A0\")] public sealed class P : CheatEnginePlugin {Body}"); + yield return ("null name", $"[CheatEnginePlugin(null!)] public sealed class P : CheatEnginePlugin {Body}"); + yield return ("missing name argument", $"[CheatEnginePlugin] public sealed class P : CheatEnginePlugin {Body}"); + yield return ( + "named like the entry point", + $"namespace CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class CESDK : CheatEnginePlugin {Body} }}"); + yield return ( + "nested in a type named like the entry point", + $"namespace CESDK {{ public static class CESDK {{ [CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {Body} }} }}"); + } + + [Fact] + public void Generator_no_plugin_class_emits_nothing() + { + GeneratorRun run = roslyn.Run("namespace Demo; public sealed class NotAPlugin { }"); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_plugin_base_without_the_attribute_emits_nothing() + { + // Discovery is attribute-driven: deriving from the base class alone is not a plugin declaration. + GeneratorRun run = roslyn.Run($"{Usings} public sealed class P : CheatEnginePlugin {Body}"); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_user_declared_host_entry_point_type_emits_nothing() + { + // The user type is not a plugin class, so PluginShape cannot see it. Emitting a second CESDK.CESDK would be a + // duplicate type error in generated code; CESDK0005 is the analyzer's source-local explanation. + GeneratorRun run = roslyn.Run( + PluginSources.Nominal, + "namespace CESDK { public static class CESDK { } }"); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_attribute_with_the_same_simple_name_from_another_namespace_emits_nothing() + { + GeneratorRun run = roslyn.Run($$""" + namespace Other + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class CheatEnginePluginAttribute(string name) : System.Attribute + { + public string Name { get; } = name; + } + } + + [Other.CheatEnginePlugin("P")] + public sealed class P : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin {{Body}} + """); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_two_valid_plugins_emits_nothing() + { + GeneratorRun run = roslyn.Run( + PluginSources.WithNameExpression("\"First\"", "FirstPlugin"), + PluginSources.WithNameExpression("\"Second\"", "SecondPlugin")); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_two_valid_plugins_in_one_file_emits_nothing() + { + GeneratorRun run = roslyn.Run( + $"{Usings} [CheatEnginePlugin(\"A\")] public sealed class A : CheatEnginePlugin {Body} [CheatEnginePlugin(\"B\")] public sealed class B : CheatEnginePlugin {Body}"); + + run.AssertNoOutput(); + } + + [Theory] + [MemberData(nameof(InvalidShapes))] + public void Generator_invalid_plugin_shape_emits_nothing(string shape, string declaration) + { + GeneratorRun run = roslyn.Run(Usings + declaration); + + Assert.True(run.GeneratedSources.IsEmpty, $"Unexpected output for: {shape}"); + run.AssertNoOutput(); + } + + [Fact] + public void Generator_one_valid_and_one_invalid_plugin_emits_for_the_valid_one() + { + // "Exactly one VALID plugin": the abstract class is CESDK0001's business and does not make the valid + // class ambiguous. + GeneratorRun run = roslyn.Run( + PluginSources.Nominal, + $"{Usings} [CheatEnginePlugin(\"Broken\")] public abstract class Broken : CheatEnginePlugin {{ }}"); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Theory] + [InlineData("false")] + [InlineData("False")] + [InlineData("FALSE")] + [InlineData(" false ")] + public void Generator_build_property_false_emits_nothing(string value) + { + GeneratorRun run = RoslynFixture.Run( + roslyn.CreateCompilation(PluginSources.Nominal), + TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", value)); + + run.AssertNoOutput(); + } + + [Theory] + [InlineData("true")] + [InlineData("True")] + public void Generator_build_property_true_emits(string value) + { + GeneratorRun run = RoslynFixture.Run( + roslyn.CreateCompilation(PluginSources.Nominal), + TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", value)); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), run.SingleGeneratedText); + } + + [Fact] + public void Generator_missing_direct_package_property_emits_nothing() + { + GeneratorRun run = RoslynFixture.Run( + roslyn.CreateCompilation(PluginSources.Nominal), + TestAnalyzerConfigOptionsProvider.Empty); + + run.AssertNoOutput(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs index 18cd5888..78243f61 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs @@ -1,7 +1,9 @@ using System.Text; + using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -9,88 +11,89 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; public sealed class NominalOutputTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Generator_single_valid_plugin_emits_exact_bootstrap() - { - var run = roslyn.Run(PluginSources.Nominal); - - var generated = Assert.Single(run.GeneratedSources); - Assert.Equal(ExpectedBootstrap.HintName, generated.HintName); - Assert.Equal( - ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), - generated.SourceText.ToString()); - } - - [Fact] - public void Generator_single_valid_plugin_output_is_utf8_with_lf_line_endings() - { - var run = roslyn.Run(PluginSources.Nominal); - - var generated = Assert.Single(run.GeneratedSources); - Assert.Equal(Encoding.UTF8, generated.SourceText.Encoding); - Assert.DoesNotContain('\r', generated.SourceText.ToString()); - } - - [Fact] - public void Generator_single_valid_plugin_output_compiles_without_errors_or_warnings() - { - var run = roslyn.Run(PluginSources.Nominal); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_single_valid_plugin_output_contains_no_unsafe_code() - { - // The compilation options of the harness forbid unsafe code, so compiling clean already proves it; the - // textual check documents the contract. - var run = roslyn.Run(PluginSources.Nominal); - - Assert.DoesNotContain("unsafe", run.SingleGeneratedText, StringComparison.Ordinal); - Assert.False(run.OutputCompilation.Options is CSharpCompilationOptions { AllowUnsafe: true }); - } - - [Fact] - public void Generator_single_valid_plugin_declares_the_host_mandated_symbols() - { - var run = roslyn.Run(PluginSources.Nominal); - - var entryPoint = run.OutputCompilation.Assembly.GetTypeByMetadataName("CESDK.CESDK"); - Assert.NotNull(entryPoint); - Assert.True(entryPoint.IsStatic); - Assert.Equal(Accessibility.Internal, entryPoint.DeclaredAccessibility); - - var initialize = - Assert.IsType(Assert.Single(entryPoint.GetMembers("CEPluginInitialize")), false); - Assert.True(initialize.IsStatic); - Assert.Equal(Accessibility.Public, initialize.DeclaredAccessibility); - Assert.Equal(SpecialType.System_Int32, initialize.ReturnType.SpecialType); - Assert.Equal(2, initialize.Parameters.Length); - Assert.Equal(SpecialType.System_IntPtr, initialize.Parameters[0].Type.SpecialType); - Assert.Equal(SpecialType.System_Int32, initialize.Parameters[1].Type.SpecialType); - } - - [Fact] - public void ManagedEntryPointNames_matches_CheatEngine_SDK_Abi_ManagedEntryPoint() - { - // Parity check for the generator-side identity constants BootstrapEmitter writes (source-generators/CheatEngine.SDK.SourceGenerators.Shared/ - // ManagedEntryPointNames.cs) against their net10.0-side source of truth: the netstandard2.0 Roslyn component - // cannot reference CheatEngine.SDK.Abi directly, so this is what keeps the two copies from drifting. - Assert.Equal(ManagedEntryPoint.Namespace, ManagedEntryPointNames.Namespace); - Assert.Equal(ManagedEntryPoint.TypeName, ManagedEntryPointNames.TypeName); - Assert.Equal(ManagedEntryPoint.MethodName, ManagedEntryPointNames.MethodName); - } - - [Fact] - public void Generator_any_input_reports_no_diagnostics() - { - var valid = roslyn.Run(PluginSources.Nominal); - var invalid = roslyn.Run(PluginSources.Nominal.Replace("sealed", "abstract", StringComparison.Ordinal)); - - Assert.Empty(valid.GeneratorDiagnostics); - Assert.Empty(valid.Result.Diagnostics); - Assert.Empty(invalid.GeneratorDiagnostics); - Assert.Empty(invalid.Result.Diagnostics); - } + [Fact] + public void Generator_single_valid_plugin_emits_exact_bootstrap() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + GeneratedSourceResult generated = Assert.Single(run.GeneratedSources); + Assert.Equal(ExpectedBootstrap.HintName, generated.HintName); + Assert.Equal( + ExpectedBootstrap.Text("global::Demo.DemoPlugin", "\"Demo Plugin\"u8"), + generated.SourceText.ToString()); + } + + [Fact] + public void Generator_single_valid_plugin_output_is_utf8_with_lf_line_endings() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + GeneratedSourceResult generated = Assert.Single(run.GeneratedSources); + Assert.Equal(Encoding.UTF8, generated.SourceText.Encoding); + Assert.DoesNotContain('\r', generated.SourceText.ToString()); + } + + [Fact] + public void Generator_single_valid_plugin_output_compiles_without_errors_or_warnings() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_single_valid_plugin_output_contains_no_unsafe_code() + { + // The compilation options of the harness forbid unsafe code, so compiling clean already proves it; the + // textual check documents the contract. + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + Assert.DoesNotContain("unsafe", run.SingleGeneratedText, StringComparison.Ordinal); + Assert.False(run.OutputCompilation.Options is CSharpCompilationOptions { AllowUnsafe: true }); + } + + [Fact] + public void Generator_single_valid_plugin_declares_the_host_mandated_symbols() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + + INamedTypeSymbol? entryPoint = run.OutputCompilation.Assembly.GetTypeByMetadataName("CESDK.CESDK"); + Assert.NotNull(entryPoint); + Assert.True(entryPoint.IsStatic); + Assert.Equal(Accessibility.Internal, entryPoint.DeclaredAccessibility); + + IMethodSymbol initialize = + Assert.IsType(Assert.Single(entryPoint.GetMembers("CEPluginInitialize")), false); + Assert.True(initialize.IsStatic); + Assert.Equal(Accessibility.Public, initialize.DeclaredAccessibility); + Assert.Equal(SpecialType.System_Int32, initialize.ReturnType.SpecialType); + Assert.Equal(2, initialize.Parameters.Length); + Assert.Equal(SpecialType.System_IntPtr, initialize.Parameters[0].Type.SpecialType); + Assert.Equal(SpecialType.System_Int32, initialize.Parameters[1].Type.SpecialType); + } + + [Fact] + public void ManagedEntryPointNames_matches_CheatEngine_SDK_Abi_ManagedEntryPoint() + { + // Parity check for the generator-side identity constants BootstrapEmitter writes (source-generators/CheatEngine.SDK.SourceGenerators.Shared/ + // ManagedEntryPointNames.cs) against their net10.0-side source of truth: the netstandard2.0 Roslyn component + // cannot reference CheatEngine.SDK.Abi directly, so this is what keeps the two copies from drifting. + Assert.Equal(ManagedEntryPoint.Namespace, ManagedEntryPointNames.Namespace); + Assert.Equal(ManagedEntryPoint.TypeName, ManagedEntryPointNames.TypeName); + Assert.Equal(ManagedEntryPoint.MethodName, ManagedEntryPointNames.MethodName); + } + + [Fact] + public void Generator_any_input_reports_no_diagnostics() + { + GeneratorRun valid = roslyn.Run(PluginSources.Nominal); + GeneratorRun invalid = + roslyn.Run(PluginSources.Nominal.Replace("sealed", "abstract", StringComparison.Ordinal)); + + Assert.Empty(valid.GeneratorDiagnostics); + Assert.Empty(valid.Result.Diagnostics); + Assert.Empty(invalid.GeneratorDiagnostics); + Assert.Empty(invalid.Result.Diagnostics); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/PluginLocationTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/PluginLocationTests.cs index c136c9dd..dfaa5441 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/PluginLocationTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/PluginLocationTests.cs @@ -1,5 +1,7 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -7,297 +9,297 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// Where the plugin class lives decides only one thing: the global:: name in Create(). public sealed class PluginLocationTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Generator_plugin_in_nested_namespace_uses_fully_qualified_name() - { - var run = roslyn.Run($$""" - namespace Company.Product.Plugins.Trainer - { - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Trainer")] - public sealed class TrainerPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::Company.Product.Plugins.Trainer.TrainerPlugin", "\"Trainer\"u8"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_plugin_in_global_namespace_uses_global_alias_only() - { - var run = roslyn.Run($$""" - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Global")] - public sealed class GlobalPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::GlobalPlugin", "\"Global\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_plugin_nested_in_another_class_uses_containing_type_chain() - { - var run = roslyn.Run($$""" - namespace Demo - { - public static class Outer - { - internal static class Middle - { - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Nested")] - internal sealed class NestedPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::Demo.Outer.Middle.NestedPlugin", "\"Nested\"u8"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_partial_plugin_across_two_files_merges_the_declarations() - { - // The attribute is on one part; the base class, the constructor and the overrides are on the other. - const string AttributedPart = """ - namespace Demo; - - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Split")] - public sealed partial class SplitPlugin - { - } - """; - const string OtherPart = $$""" - namespace Demo; - - public sealed partial class SplitPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - public SplitPlugin() - { - } - - {{PluginSources.LifecycleOverrides}} - } - """; - - var run = roslyn.Run(AttributedPart, OtherPart); - - Assert.Equal(ExpectedBootstrap.Text("global::Demo.SplitPlugin", "\"Split\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_plugin_named_like_a_keyword_escapes_the_identifier() - { - var run = roslyn.Run($$""" - namespace @namespace - { - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Keyword")] - public sealed class @class : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::@namespace.@class", "\"Keyword\"u8"), run.SingleGeneratedText); - Assert.Empty(run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); - } - - [Fact] - public void Generator_plugin_with_non_ascii_identifiers_is_named_as_declared() - { - // Only the display-name literal is escaped to ASCII. Identifiers are written the way the author declared - // them (the generated file is UTF-8), so the output is NOT ASCII-only here, and still compiles and runs. - var run = roslyn.Run($$""" - namespace España.Démo - { - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("x")] - public sealed class Plügin日 : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::España.Démo.Plügin日", "\"x\"u8"), - run.SingleGeneratedText); - Assert.Equal(Encoding.UTF8, Assert.Single(run.GeneratedSources).SourceText.Encoding); - run.AssertCompilesClean(); - - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal("España.Démo.Plügin日", bootstrap.LastPluginTypeName); - } - - [Fact] - public void Generator_plugin_named_like_the_file_local_factory_gets_another_factory_name() - { - // 'global::CESDK.PluginFactory' written inside the generated file would bind to the generated file-local - // type of that name (a file-local type wins the lookup in its own file, even through 'global::'), so the - // factory steps aside. CS0029 before the fix. - var run = roslyn.Run($$""" - namespace CESDK - { - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Factory")] - public sealed class PluginFactory : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::CESDK.PluginFactory", "\"Factory\"u8", "GeneratedPluginFactory"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal("CESDK.PluginFactory", bootstrap.LastPluginTypeName); - } - - [Fact] - public void Generator_plugin_nested_in_a_type_named_like_the_factory_gets_another_factory_name() - { - var run = roslyn.Run($$""" - namespace CESDK - { - public static class PluginFactory - { - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Nested")] - public sealed class Inner : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::CESDK.PluginFactory.Inner", "\"Nested\"u8", "GeneratedPluginFactory"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Theory] - [InlineData("Demo", "CESDK")] - [InlineData("CESDK.Samples", "CESDK")] - [InlineData("Demo.CESDK", "Plugin")] - public void Generator_plugin_that_only_resembles_the_entry_point_name_is_still_bootstrapped(string @namespace, - string className) - { - // Reserved is exactly the top-level type CESDK in the namespace CESDK (see NoOutputTests), nothing wider. - var run = roslyn.Run($$""" - namespace {{@namespace}} - { - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Look-alike")] - public sealed class {{className}} : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal(ExpectedBootstrap.Text($"global::{@namespace}.{className}", "\"Look-alike\"u8"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_plugin_whose_name_only_starts_like_the_factory_keeps_the_default_factory_name() - { - var run = roslyn.Run($$""" - namespace CESDK - { - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Prefix")] - public sealed class PluginFactoryPlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::CESDK.PluginFactoryPlugin", "\"Prefix\"u8"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_user_type_named_like_the_file_local_factory_does_not_collide() - { - // A 'file' type may share its name with a type of the same namespace declared in another file, and wins the - // lookup inside its own file. - var run = roslyn.Run( - PluginSources.Nominal, - "namespace CESDK { public sealed class PluginFactory { public static int Marker => 1; } }"); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal("Demo.DemoPlugin", bootstrap.LastPluginTypeName); - } - - [Fact] - public void Generator_plugin_inside_a_CESDK_namespace_still_compiles_thanks_to_global_names() - { - // Discouraged (analyzer CESDK0004) but it must not break the generated file itself. - var run = roslyn.Run($$""" - namespace CESDK.Samples - { - [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Inside")] - public sealed class InsidePlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::CESDK.Samples.InsidePlugin", "\"Inside\"u8"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_plugin_declared_under_the_sdk_own_namespace_is_bootstrapped() - { - // Only CESDK.CESDK is reserved. A plugin that shares the CheatEngine.SDK root with the SDK namespaces is an - // ordinary plugin: nothing in the generated file binds a simple name to it. - var run = roslyn.Run($$""" - namespace CheatEngine.SDK.Samples - { - [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Sdk")] - public sealed class SdkNamespacePlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin - { - {{PluginSources.LifecycleOverrides}} - } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::CheatEngine.SDK.Samples.SdkNamespacePlugin", "\"Sdk\"u8"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - - using var bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); - Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); - Assert.Equal("CheatEngine.SDK.Samples.SdkNamespacePlugin", bootstrap.LastPluginTypeName); - } + [Fact] + public void Generator_plugin_in_nested_namespace_uses_fully_qualified_name() + { + GeneratorRun run = roslyn.Run($$""" + namespace Company.Product.Plugins.Trainer + { + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Trainer")] + public sealed class TrainerPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::Company.Product.Plugins.Trainer.TrainerPlugin", "\"Trainer\"u8"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_plugin_in_global_namespace_uses_global_alias_only() + { + GeneratorRun run = roslyn.Run($$""" + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Global")] + public sealed class GlobalPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::GlobalPlugin", "\"Global\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_plugin_nested_in_another_class_uses_containing_type_chain() + { + GeneratorRun run = roslyn.Run($$""" + namespace Demo + { + public static class Outer + { + internal static class Middle + { + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Nested")] + internal sealed class NestedPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::Demo.Outer.Middle.NestedPlugin", "\"Nested\"u8"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_partial_plugin_across_two_files_merges_the_declarations() + { + // The attribute is on one part; the base class, the constructor and the overrides are on the other. + const string AttributedPart = """ + namespace Demo; + + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Split")] + public sealed partial class SplitPlugin + { + } + """; + const string OtherPart = $$""" + namespace Demo; + + public sealed partial class SplitPlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + public SplitPlugin() + { + } + + {{PluginSources.LifecycleOverrides}} + } + """; + + GeneratorRun run = roslyn.Run(AttributedPart, OtherPart); + + Assert.Equal(ExpectedBootstrap.Text("global::Demo.SplitPlugin", "\"Split\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_plugin_named_like_a_keyword_escapes_the_identifier() + { + GeneratorRun run = roslyn.Run($$""" + namespace @namespace + { + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Keyword")] + public sealed class @class : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::@namespace.@class", "\"Keyword\"u8"), run.SingleGeneratedText); + Assert.Empty(run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + } + + [Fact] + public void Generator_plugin_with_non_ascii_identifiers_is_named_as_declared() + { + // Only the display-name literal is escaped to ASCII. Identifiers are written the way the author declared + // them (the generated file is UTF-8), so the output is NOT ASCII-only here, and still compiles and runs. + GeneratorRun run = roslyn.Run($$""" + namespace España.Démo + { + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("x")] + public sealed class Plügin日 : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::España.Démo.Plügin日", "\"x\"u8"), + run.SingleGeneratedText); + Assert.Equal(Encoding.UTF8, Assert.Single(run.GeneratedSources).SourceText.Encoding); + run.AssertCompilesClean(); + + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal("España.Démo.Plügin日", bootstrap.LastPluginTypeName); + } + + [Fact] + public void Generator_plugin_named_like_the_file_local_factory_gets_another_factory_name() + { + // 'global::CESDK.PluginFactory' written inside the generated file would bind to the generated file-local + // type of that name (a file-local type wins the lookup in its own file, even through 'global::'), so the + // factory steps aside. CS0029 before the fix. + GeneratorRun run = roslyn.Run($$""" + namespace CESDK + { + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Factory")] + public sealed class PluginFactory : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::CESDK.PluginFactory", "\"Factory\"u8", "GeneratedPluginFactory"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal("CESDK.PluginFactory", bootstrap.LastPluginTypeName); + } + + [Fact] + public void Generator_plugin_nested_in_a_type_named_like_the_factory_gets_another_factory_name() + { + GeneratorRun run = roslyn.Run($$""" + namespace CESDK + { + public static class PluginFactory + { + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Nested")] + public sealed class Inner : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::CESDK.PluginFactory.Inner", "\"Nested\"u8", "GeneratedPluginFactory"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Theory] + [InlineData("Demo", "CESDK")] + [InlineData("CESDK.Samples", "CESDK")] + [InlineData("Demo.CESDK", "Plugin")] + public void Generator_plugin_that_only_resembles_the_entry_point_name_is_still_bootstrapped(string @namespace, + string className) + { + // Reserved is exactly the top-level type CESDK in the namespace CESDK (see NoOutputTests), nothing wider. + GeneratorRun run = roslyn.Run($$""" + namespace {{@namespace}} + { + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Look-alike")] + public sealed class {{className}} : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal(ExpectedBootstrap.Text($"global::{@namespace}.{className}", "\"Look-alike\"u8"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_plugin_whose_name_only_starts_like_the_factory_keeps_the_default_factory_name() + { + GeneratorRun run = roslyn.Run($$""" + namespace CESDK + { + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Prefix")] + public sealed class PluginFactoryPlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::CESDK.PluginFactoryPlugin", "\"Prefix\"u8"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_user_type_named_like_the_file_local_factory_does_not_collide() + { + // A 'file' type may share its name with a type of the same namespace declared in another file, and wins the + // lookup inside its own file. + GeneratorRun run = roslyn.Run( + PluginSources.Nominal, + "namespace CESDK { public sealed class PluginFactory { public static int Marker => 1; } }"); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal("Demo.DemoPlugin", bootstrap.LastPluginTypeName); + } + + [Fact] + public void Generator_plugin_inside_a_CESDK_namespace_still_compiles_thanks_to_global_names() + { + // Discouraged (analyzer CESDK0004) but it must not break the generated file itself. + GeneratorRun run = roslyn.Run($$""" + namespace CESDK.Samples + { + [global::CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Inside")] + public sealed class InsidePlugin : global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::CESDK.Samples.InsidePlugin", "\"Inside\"u8"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_plugin_declared_under_the_sdk_own_namespace_is_bootstrapped() + { + // Only CESDK.CESDK is reserved. A plugin that shares the CheatEngine.SDK root with the SDK namespaces is an + // ordinary plugin: nothing in the generated file binds a simple name to it. + GeneratorRun run = roslyn.Run($$""" + namespace CheatEngine.SDK.Samples + { + [CheatEngine.SDK.Annotations.Plugin.CheatEnginePlugin("Sdk")] + public sealed class SdkNamespacePlugin : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin + { + {{PluginSources.LifecycleOverrides}} + } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::CheatEngine.SDK.Samples.SdkNamespacePlugin", "\"Sdk\"u8"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + + using LoadedBootstrap bootstrap = LoadedBootstrap.Load(roslyn.Environment, run.OutputCompilation); + Assert.Equal(1, bootstrap.Initialize(IntPtr.Zero, 0)); + Assert.Equal("CheatEngine.SDK.Samples.SdkNamespacePlugin", bootstrap.LastPluginTypeName); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs index c9ba58ae..d6c8333c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs @@ -1,7 +1,9 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin; using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -17,89 +19,89 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class RealAssemblyCompilationTests { - private const string PluginSource = """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace Demo; - - [CheatEnginePlugin("Real Assembly Demo")] - public sealed class DemoPlugin : CheatEnginePlugin - { - protected override void OnEnable() { } - - protected override void OnDisable() { } - } - """; - - private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); - - // Same strictness as RoslynEnvironment.CompilationOptions: unsafe OFF (the entry point must compile without - // it), nullable on, every warning wave. - private static readonly CSharpCompilationOptions CompilationOptions = new( - OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable, - allowUnsafe: false, - warningLevel: 9999); - - [Fact] - public void - Generated_bootstrap_compiles_clean_against_the_real_CheatEngine_SDK_Hosting_and_CheatEngine_SDK_Annotations() - { - ImmutableArray references = - [ - .. LocalFrameworkReferences.Load(), - MetadataReference.CreateFromFile(typeof(CheatEnginePluginAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(CheatEnginePlugin).Assembly.Location) - ]; - - var compilation = CSharpCompilation.Create( - "RealAssemblyDemoPlugin", - [ - CSharpSyntaxTree.ParseText(PluginSource, ParseOptions, "Plugin.cs", - cancellationToken: TestContext.Current.CancellationToken) - ], - references, - CompilationOptions); - - GeneratorDriver driver = CSharpGeneratorDriver.Create( - [new EntryPointGenerator().AsSourceGenerator()], - [], - ParseOptions, - TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "true"), - new GeneratorDriverOptions( - IncrementalGeneratorOutputKind.None, - trackIncrementalGeneratorSteps: true)); - - driver = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var generatorDiagnostics, - TestContext.Current.CancellationToken); - - Assert.Empty(generatorDiagnostics); - Assert.Single(driver.GetRunResult().Results.Single().GeneratedSources); - - Diagnostic[] problems = - [ - .. outputCompilation - .GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) - ]; - - Assert.True( - problems.Length == 0, - "Unexpected compiler diagnostics against the real CheatEngine.SDK.Hosting/CheatEngine.SDK.Annotations:\n" + - string.Join('\n', problems.AsEnumerable())); - } - - // Same carve-out as GeneratorRun.AssertCompilesClean: the test plugin is public and undocumented on purpose - // (a short source), which is CS1591 outside the generated file only. - private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) - { - return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); - } + private const string PluginSource = """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace Demo; + + [CheatEnginePlugin("Real Assembly Demo")] + public sealed class DemoPlugin : CheatEnginePlugin + { + protected override void OnEnable() { } + + protected override void OnDisable() { } + } + """; + + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); + + // Same strictness as RoslynEnvironment.CompilationOptions: unsafe OFF (the entry point must compile without + // it), nullable on, every warning wave. + private static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + allowUnsafe: false, + warningLevel: 9999); + + [Fact] + public void + Generated_bootstrap_compiles_clean_against_the_real_CheatEngine_SDK_Hosting_and_CheatEngine_SDK_Annotations() + { + ImmutableArray references = + [ + .. LocalFrameworkReferences.Load(), + MetadataReference.CreateFromFile(typeof(CheatEnginePluginAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CheatEnginePlugin).Assembly.Location) + ]; + + CSharpCompilation compilation = CSharpCompilation.Create( + "RealAssemblyDemoPlugin", + [ + CSharpSyntaxTree.ParseText(PluginSource, ParseOptions, "Plugin.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + references, + CompilationOptions); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new EntryPointGenerator().AsSourceGenerator()], + [], + ParseOptions, + TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "true"), + new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + true)); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray generatorDiagnostics, + TestContext.Current.CancellationToken); + + Assert.Empty(generatorDiagnostics); + Assert.Single(driver.GetRunResult().Results.Single().GeneratedSources); + + Diagnostic[] problems = + [ + .. outputCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && + !IsMissingDocumentationInTestInput(diagnostic)) + ]; + + Assert.True( + problems.Length == 0, + "Unexpected compiler diagnostics against the real CheatEngine.SDK.Hosting/CheatEngine.SDK.Annotations:\n" + + string.Join('\n', problems.AsEnumerable())); + } + + // Same carve-out as GeneratorRun.AssertCompilesClean: the test plugin is public and undocumented on purpose + // (a short source), which is CS1591 outside the generated file only. + private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) + { + return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ValidShapeTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ValidShapeTests.cs index 68ae8dc7..8bfd4ad3 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ValidShapeTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ValidShapeTests.cs @@ -8,193 +8,193 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; /// public sealed class ValidShapeTests(RoslynFixture roslyn) : IClassFixture { - private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; - - private const string Members = "protected override void OnEnable() { } protected override void OnDisable() { }"; - - public static TheoryData ValidShapes => new() - { - { - "implicit parameterless constructor", - $"[CheatEnginePlugin(\"P\")] internal sealed class P : CheatEnginePlugin {{ {Members} }}" - }, - { "unsealed class", $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ {Members} }}" }, - { - "internal constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ internal P() {{ }} {Members} }}" - }, - { - "protected internal constructor", - $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected internal P() {{ }} {Members} }}" - }, - { - "extra constructors", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P() {{ }} public P(int value) {{ _ = value; }} {Members} }}" - }, - { - "indirect derivation", - $"public abstract class Base : CheatEnginePlugin {{ {Members} }} [CheatEnginePlugin(\"P\")] public sealed class P : Base {{ }}" - }, - { - "attribute suffix spelled out", - $"[CheatEnginePluginAttribute(\"P\")] public sealed class P : CheatEnginePlugin {{ {Members} }}" - }, - { - "named argument", - $"[CheatEnginePlugin(name: \"P\")] public sealed class P : CheatEnginePlugin {{ {Members} }}" - }, - { - "constant expression", - $"[CheatEnginePlugin(P.Prefix + \"\")] public sealed class P : CheatEnginePlugin {{ public const string Prefix = \"P\"; {Members} }}" - }, - { - "attribute among others", - $"[System.Serializable, CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ {Members} }}" - } - }; - - public static TheoryData DeclaredDiagnosticIds => new() - { - { - "experimental class", - $"[CheatEnginePlugin(\"P\")] [System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] public sealed class P : CheatEnginePlugin {{ {Members} }}", - "EXP001" - }, - { - "experimental constructor", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Diagnostics.CodeAnalysis.Experimental(\"EXP002\")] public P() {{ }} {Members} }}", - "EXP002" - }, - { - "obsolete class with a custom diagnostic id", - $"[CheatEnginePlugin(\"P\")] [System.Obsolete(\"Old.\", DiagnosticId = \"MY0001\")] public sealed class P : CheatEnginePlugin {{ {Members} }}", - "MY0001" - }, - { - "obsolete constructor with a custom diagnostic id", - $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Obsolete(DiagnosticId = \"MY0002\")] public P() {{ }} {Members} }}", - "MY0002" - }, - { - "class and constructor, same id twice and another one", - $"[CheatEnginePlugin(\"P\")] [System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] public sealed class P : CheatEnginePlugin {{ [System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] [System.Obsolete(DiagnosticId = \"MY0001\")] public P() {{ }} {Members} }}", - "EXP001, MY0001" - } - }; - - [Theory] - [MemberData(nameof(ValidShapes))] - public void Generator_constructible_plugin_shape_emits_compiling_bootstrap(string shape, string declaration) - { - var run = roslyn.Run(Usings + declaration); - - Assert.True(run.GeneratedSources.Length == 1, $"No output for: {shape}"); - Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_obsolete_plugin_class_does_not_warn_in_generated_code() - { - var run = roslyn.Run( - $"{Usings} [CheatEnginePlugin(\"P\")] [System.Obsolete(\"Use the new plugin.\")] public sealed class P : CheatEnginePlugin {{ {Members} }}"); - - Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_required_members_set_by_the_parameterless_constructor_compile() - { - // The constructible half of the 'required' story; the other half is in KnownLimitationTests. - var run = roslyn.Run($$""" - {{Usings}} - [CheatEnginePlugin("P")] - public sealed class P : CheatEnginePlugin - { - [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public P() => Value = 1; - - public required int Value { get; init; } - - {{Members}} - } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_pragmas_follow_the_selected_real_parameterless_constructor_only() - { - var run = roslyn.Run($$""" - {{Usings}} - [CheatEnginePlugin("P")] - public sealed class P : CheatEnginePlugin - { - [System.Diagnostics.CodeAnalysis.Experimental("OPTIONAL01")] - public P(int ignored = 0) => _ = ignored; - - [System.Diagnostics.CodeAnalysis.Experimental("PARAMETERLESS01")] - public P() { } - - {{Members}} - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::P", "\"P\"u8", declaredDiagnosticIds: "PARAMETERLESS01"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Theory] - [MemberData(nameof(DeclaredDiagnosticIds))] - public void Generator_diagnostic_ids_declared_by_the_plugin_class_are_disabled_in_generated_code(string shape, - string declaration, string expectedIds) - { - // [Experimental] is an error by default and [Obsolete(DiagnosticId = ...)] is not CS0612/CS0618: both would - // land in a file the author cannot edit. A pragma does suppress them (they are warnings-as-errors). - var run = roslyn.Run(Usings + declaration); - - Assert.True(run.GeneratedSources.Length == 1, $"No output for: {shape}"); - Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8", declaredDiagnosticIds: expectedIds), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_experimental_containing_type_is_disabled_too() - { - var run = roslyn.Run($$""" - {{Usings}} - [System.Diagnostics.CodeAnalysis.Experimental("OUTER01")] - public static class Outer - { - [CheatEnginePlugin("P")] - [System.Diagnostics.CodeAnalysis.Experimental("INNER01")] - public sealed class P : CheatEnginePlugin { {{Members}} } - } - """); - - Assert.Equal( - ExpectedBootstrap.Text("global::Outer.P", "\"P\"u8", declaredDiagnosticIds: "OUTER01, INNER01"), - run.SingleGeneratedText); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_attribute_through_a_using_alias_is_recognised() - { - var run = roslyn.Run($$""" - using Plugin = CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute; - - [Plugin("Aliased")] - public sealed class P : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { {{Members}} } - """); - - Assert.Equal(ExpectedBootstrap.Text("global::P", "\"Aliased\"u8"), run.SingleGeneratedText); - run.AssertCompilesClean(); - } + private const string Usings = "using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Plugin;\n"; + + private const string Members = "protected override void OnEnable() { } protected override void OnDisable() { }"; + + public static TheoryData ValidShapes => new() + { + { + "implicit parameterless constructor", + $"[CheatEnginePlugin(\"P\")] internal sealed class P : CheatEnginePlugin {{ {Members} }}" + }, + { "unsealed class", $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ {Members} }}" }, + { + "internal constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ internal P() {{ }} {Members} }}" + }, + { + "protected internal constructor", + $"[CheatEnginePlugin(\"P\")] public class P : CheatEnginePlugin {{ protected internal P() {{ }} {Members} }}" + }, + { + "extra constructors", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ public P() {{ }} public P(int value) {{ _ = value; }} {Members} }}" + }, + { + "indirect derivation", + $"public abstract class Base : CheatEnginePlugin {{ {Members} }} [CheatEnginePlugin(\"P\")] public sealed class P : Base {{ }}" + }, + { + "attribute suffix spelled out", + $"[CheatEnginePluginAttribute(\"P\")] public sealed class P : CheatEnginePlugin {{ {Members} }}" + }, + { + "named argument", + $"[CheatEnginePlugin(name: \"P\")] public sealed class P : CheatEnginePlugin {{ {Members} }}" + }, + { + "constant expression", + $"[CheatEnginePlugin(P.Prefix + \"\")] public sealed class P : CheatEnginePlugin {{ public const string Prefix = \"P\"; {Members} }}" + }, + { + "attribute among others", + $"[System.Serializable, CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ {Members} }}" + } + }; + + public static TheoryData DeclaredDiagnosticIds => new() + { + { + "experimental class", + $"[CheatEnginePlugin(\"P\")] [System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] public sealed class P : CheatEnginePlugin {{ {Members} }}", + "EXP001" + }, + { + "experimental constructor", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Diagnostics.CodeAnalysis.Experimental(\"EXP002\")] public P() {{ }} {Members} }}", + "EXP002" + }, + { + "obsolete class with a custom diagnostic id", + $"[CheatEnginePlugin(\"P\")] [System.Obsolete(\"Old.\", DiagnosticId = \"MY0001\")] public sealed class P : CheatEnginePlugin {{ {Members} }}", + "MY0001" + }, + { + "obsolete constructor with a custom diagnostic id", + $"[CheatEnginePlugin(\"P\")] public sealed class P : CheatEnginePlugin {{ [System.Obsolete(DiagnosticId = \"MY0002\")] public P() {{ }} {Members} }}", + "MY0002" + }, + { + "class and constructor, same id twice and another one", + $"[CheatEnginePlugin(\"P\")] [System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] public sealed class P : CheatEnginePlugin {{ [System.Diagnostics.CodeAnalysis.Experimental(\"EXP001\")] [System.Obsolete(DiagnosticId = \"MY0001\")] public P() {{ }} {Members} }}", + "EXP001, MY0001" + } + }; + + [Theory] + [MemberData(nameof(ValidShapes))] + public void Generator_constructible_plugin_shape_emits_compiling_bootstrap(string shape, string declaration) + { + GeneratorRun run = roslyn.Run(Usings + declaration); + + Assert.True(run.GeneratedSources.Length == 1, $"No output for: {shape}"); + Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_obsolete_plugin_class_does_not_warn_in_generated_code() + { + GeneratorRun run = roslyn.Run( + $"{Usings} [CheatEnginePlugin(\"P\")] [System.Obsolete(\"Use the new plugin.\")] public sealed class P : CheatEnginePlugin {{ {Members} }}"); + + Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_required_members_set_by_the_parameterless_constructor_compile() + { + // The constructible half of the 'required' story; the other half is in KnownLimitationTests. + GeneratorRun run = roslyn.Run($$""" + {{Usings}} + [CheatEnginePlugin("P")] + public sealed class P : CheatEnginePlugin + { + [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + public P() => Value = 1; + + public required int Value { get; init; } + + {{Members}} + } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_pragmas_follow_the_selected_real_parameterless_constructor_only() + { + GeneratorRun run = roslyn.Run($$""" + {{Usings}} + [CheatEnginePlugin("P")] + public sealed class P : CheatEnginePlugin + { + [System.Diagnostics.CodeAnalysis.Experimental("OPTIONAL01")] + public P(int ignored = 0) => _ = ignored; + + [System.Diagnostics.CodeAnalysis.Experimental("PARAMETERLESS01")] + public P() { } + + {{Members}} + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::P", "\"P\"u8", declaredDiagnosticIds: "PARAMETERLESS01"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Theory] + [MemberData(nameof(DeclaredDiagnosticIds))] + public void Generator_diagnostic_ids_declared_by_the_plugin_class_are_disabled_in_generated_code(string shape, + string declaration, string expectedIds) + { + // [Experimental] is an error by default and [Obsolete(DiagnosticId = ...)] is not CS0612/CS0618: both would + // land in a file the author cannot edit. A pragma does suppress them (they are warnings-as-errors). + GeneratorRun run = roslyn.Run(Usings + declaration); + + Assert.True(run.GeneratedSources.Length == 1, $"No output for: {shape}"); + Assert.Equal(ExpectedBootstrap.Text("global::P", "\"P\"u8", declaredDiagnosticIds: expectedIds), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_experimental_containing_type_is_disabled_too() + { + GeneratorRun run = roslyn.Run($$""" + {{Usings}} + [System.Diagnostics.CodeAnalysis.Experimental("OUTER01")] + public static class Outer + { + [CheatEnginePlugin("P")] + [System.Diagnostics.CodeAnalysis.Experimental("INNER01")] + public sealed class P : CheatEnginePlugin { {{Members}} } + } + """); + + Assert.Equal( + ExpectedBootstrap.Text("global::Outer.P", "\"P\"u8", declaredDiagnosticIds: "OUTER01, INNER01"), + run.SingleGeneratedText); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_attribute_through_a_using_alias_is_recognised() + { + GeneratorRun run = roslyn.Run($$""" + using Plugin = CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute; + + [Plugin("Aliased")] + public sealed class P : CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin { {{Members}} } + """); + + Assert.Equal(ExpectedBootstrap.Text("global::P", "\"Aliased\"u8"), run.SingleGeneratedText); + run.AssertCompilesClean(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ContractStubs.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ContractStubs.cs index f42343ca..b7a663c7 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ContractStubs.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ContractStubs.cs @@ -15,67 +15,67 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal static class ContractStubs { - public const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; + public const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; - public const string HostingAssemblyName = "CheatEngine.SDK.Hosting"; + public const string HostingAssemblyName = "CheatEngine.SDK.Hosting"; - public const string AnnotationsSource = """ - #nullable enable - namespace CheatEngine.SDK.Annotations.Plugin - { - [global::System.AttributeUsage(global::System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class CheatEnginePluginAttribute : global::System.Attribute - { - public CheatEnginePluginAttribute(string name) => Name = name; + public const string AnnotationsSource = """ + #nullable enable + namespace CheatEngine.SDK.Annotations.Plugin + { + [global::System.AttributeUsage(global::System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class CheatEnginePluginAttribute : global::System.Attribute + { + public CheatEnginePluginAttribute(string name) => Name = name; - public string Name { get; } - } - } - """; + public string Name { get; } + } + } + """; - public const string HostingSource = """ - #nullable enable - namespace CheatEngine.SDK.Hosting.Plugin - { - public abstract class CheatEnginePlugin - { - protected internal abstract void OnEnable(); + public const string HostingSource = """ + #nullable enable + namespace CheatEngine.SDK.Hosting.Plugin + { + public abstract class CheatEnginePlugin + { + protected internal abstract void OnEnable(); - protected internal abstract void OnDisable(); - } + protected internal abstract void OnDisable(); + } - public interface IPluginFactory - { - static abstract CheatEnginePlugin Create(); + public interface IPluginFactory + { + static abstract CheatEnginePlugin Create(); - static abstract global::System.ReadOnlySpan Utf8Name { get; } - } - } + static abstract global::System.ReadOnlySpan Utf8Name { get; } + } + } - namespace CheatEngine.SDK.Hosting.Bootstrap - { - public static class PluginHost - { - public static int CallCount; - public static int LastHostArgument; - public static byte[]? LastUtf8Name; - public static global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin? LastPlugin; + namespace CheatEngine.SDK.Hosting.Bootstrap + { + public static class PluginHost + { + public static int CallCount; + public static int LastHostArgument; + public static byte[]? LastUtf8Name; + public static global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin? LastPlugin; - public static int InitializeManaged(nint initRecord, int hostArgument) - where TFactory : global::CheatEngine.SDK.Hosting.Plugin.IPluginFactory - { - CallCount++; - LastHostArgument = hostArgument; - if (hostArgument < 0) - { - throw new global::System.InvalidOperationException("Stub failure requested by the test."); - } + public static int InitializeManaged(nint initRecord, int hostArgument) + where TFactory : global::CheatEngine.SDK.Hosting.Plugin.IPluginFactory + { + CallCount++; + LastHostArgument = hostArgument; + if (hostArgument < 0) + { + throw new global::System.InvalidOperationException("Stub failure requested by the test."); + } - LastUtf8Name = TFactory.Utf8Name.ToArray(); - LastPlugin = TFactory.Create(); - return 1; - } - } - } - """; + LastUtf8Name = TFactory.Utf8Name.ToArray(); + LastPlugin = TFactory.Create(); + return 1; + } + } + } + """; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ExpectedBootstrap.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ExpectedBootstrap.cs index 78f2a1fd..cfad0613 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ExpectedBootstrap.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ExpectedBootstrap.cs @@ -1,3 +1,5 @@ +using System.Reflection; + namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// @@ -7,81 +9,81 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal static class ExpectedBootstrap { - public const string HintName = "CheatEngine.SDK.EntryPoint.g.cs"; + public const string HintName = "CheatEngine.SDK.EntryPoint.g.cs"; - /// For example global::Demo.DemoPlugin. - /// For example "Demo Plugin"u8. - /// - /// Name of the file-local factory: PluginFactory, unless the plugin class itself is (or is nested in) - /// CESDK.PluginFactory. - /// - /// - /// Comma-separated diagnostic IDs the plugin class declares through [Experimental] or - /// [Obsolete(DiagnosticId = ...)]; they get a second pragma line. Empty: no such line. - /// - public static string Text( - string fullyQualifiedPluginType, - string utf8NameLiteral, - string factoryName = "PluginFactory", - string declaredDiagnosticIds = "") - { - var generator = typeof(EntryPointGenerator).Assembly.GetName(); - var tool = generator.Name!; - var version = generator.Version!.ToString(); - var declaredIdsPragma = declaredDiagnosticIds.Length == 0 - ? string.Empty - : $"#pragma warning disable {declaredDiagnosticIds} // declared by the plugin class: [Experimental] or [Obsolete(DiagnosticId = ...)]\n"; + /// For example global::Demo.DemoPlugin. + /// For example "Demo Plugin"u8. + /// + /// Name of the file-local factory: PluginFactory, unless the plugin class itself is (or is nested in) + /// CESDK.PluginFactory. + /// + /// + /// Comma-separated diagnostic IDs the plugin class declares through [Experimental] or + /// [Obsolete(DiagnosticId = ...)]; they get a second pragma line. Empty: no such line. + /// + public static string Text( + string fullyQualifiedPluginType, + string utf8NameLiteral, + string factoryName = "PluginFactory", + string declaredDiagnosticIds = "") + { + AssemblyName generator = typeof(EntryPointGenerator).Assembly.GetName(); + string tool = generator.Name!; + string version = generator.Version!.ToString(); + string declaredIdsPragma = declaredDiagnosticIds.Length == 0 + ? string.Empty + : $"#pragma warning disable {declaredDiagnosticIds} // declared by the plugin class: [Experimental] or [Obsolete(DiagnosticId = ...)]\n"; - var text = $$""" - // - #nullable enable + string text = $$""" + // + #nullable enable - #pragma warning disable CS0612, CS0618 // the plugin class may be marked [Obsolete] - {{declaredIdsPragma}} - namespace CESDK - { - /// - /// Managed entry point of this plugin assembly. Cheat Engine looks up the type CESDK.CESDK and its - /// method CEPluginInitialize by name, in the plugin assembly itself: both names are imposed by the host. - /// - [global::System.CodeDom.Compiler.GeneratedCode("{{tool}}", "{{version}}")] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - internal static class CESDK - { - /// - /// Called by the host, more than once per load. Forwards to the hosting runtime, which is idempotent, and never - /// lets an exception reach native code. - /// - /// The first opaque value supplied by the host. - /// The second opaque value supplied by the host, forwarded without interpretation. - /// 1 on success, 0 on failure. - public static int CEPluginInitialize(global::System.IntPtr args, int opaqueArgument) - { - try - { - return global::CheatEngine.SDK.Hosting.Bootstrap.PluginHost.InitializeManaged<{{factoryName}}>(args, opaqueArgument); - } - catch (global::System.Exception) - { - return 0; - } - } - } + #pragma warning disable CS0612, CS0618 // the plugin class may be marked [Obsolete] + {{declaredIdsPragma}} + namespace CESDK + { + /// + /// Managed entry point of this plugin assembly. Cheat Engine looks up the type CESDK.CESDK and its + /// method CEPluginInitialize by name, in the plugin assembly itself: both names are imposed by the host. + /// + [global::System.CodeDom.Compiler.GeneratedCode("{{tool}}", "{{version}}")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static class CESDK + { + /// + /// Called by the host, more than once per load. Forwards to the hosting runtime, which is idempotent, and never + /// lets an exception reach native code. + /// + /// The first opaque value supplied by the host. + /// The second opaque value supplied by the host, forwarded without interpretation. + /// 1 on success, 0 on failure. + public static int CEPluginInitialize(global::System.IntPtr args, int opaqueArgument) + { + try + { + return global::CheatEngine.SDK.Hosting.Bootstrap.PluginHost.InitializeManaged<{{factoryName}}>(args, opaqueArgument); + } + catch (global::System.Exception) + { + return 0; + } + } + } - /// Constructs the plugin class without reflection and carries its display name as UTF-8. - [global::System.CodeDom.Compiler.GeneratedCode("{{tool}}", "{{version}}")] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - file sealed class {{factoryName}} : global::CheatEngine.SDK.Hosting.Plugin.IPluginFactory - { - public static global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin Create() => new {{fullyQualifiedPluginType}}(); + /// Constructs the plugin class without reflection and carries its display name as UTF-8. + [global::System.CodeDom.Compiler.GeneratedCode("{{tool}}", "{{version}}")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + file sealed class {{factoryName}} : global::CheatEngine.SDK.Hosting.Plugin.IPluginFactory + { + public static global::CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin Create() => new {{fullyQualifiedPluginType}}(); - public static global::System.ReadOnlySpan Utf8Name => {{utf8NameLiteral}}; - } - } + public static global::System.ReadOnlySpan Utf8Name => {{utf8NameLiteral}}; + } + } - """; + """; - // The generator always writes '\n'; this file may be checked out with either line ending. - return text.ReplaceLineEndings("\n"); - } + // The generator always writes '\n'; this file may be checked out with either line ending. + return text.ReplaceLineEndings("\n"); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs index 363ab13f..ff87ce3a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -6,76 +7,88 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// One execution of the generator: the driver (to run again), the result and the updated compilation. internal sealed class GeneratorRun { - private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics) - { - Driver = driver; - OutputCompilation = outputCompilation; - GeneratorDiagnostics = diagnostics; - Result = driver.GetRunResult().Results.Single(); - } - - /// The driver after the run; feed it to again to test incrementality. - public GeneratorDriver Driver { get; } - - /// Input compilation plus the generated trees. - public Compilation OutputCompilation { get; } - - /// Diagnostics reported by the generator itself (this generator must never report any). - public ImmutableArray GeneratorDiagnostics { get; } - - public GeneratorRunResult Result { get; } - - public ImmutableArray GeneratedSources => Result.GeneratedSources; - - /// Text of the only generated file; fails when there is none or more than one. - public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); - - public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) - { - var updated = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var diagnostics, - TestContext.Current.CancellationToken); - - return new GeneratorRun(updated, outputCompilation, diagnostics); - } - - /// Asserts "silent": no file, no generator diagnostic, no exception swallowed by the driver. - public void AssertNoOutput() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - Assert.Empty(GeneratedSources); - } - - /// - /// Asserts that the updated compilation (user code + stubs + generated code) has no error and no warning, and - /// that the generator reported nothing. - /// - public void AssertCompilesClean() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - - Diagnostic[] problems = - [ - .. OutputCompilation - .GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) - ]; - - Assert.True(problems.Length == 0, - "Unexpected compiler diagnostics:\n" + string.Join('\n', problems.AsEnumerable())); - } - - // The test plugins are public and undocumented on purpose (short sources). CS1591 is ignored for them, and only - // for them: in a generated file it still fails the assertion. - private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) - { - return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); - } + private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics) + { + Driver = driver; + OutputCompilation = outputCompilation; + GeneratorDiagnostics = diagnostics; + Result = driver.GetRunResult().Results.Single(); + } + + /// The driver after the run; feed it to again to test incrementality. + public GeneratorDriver Driver + { + get; + } + + /// Input compilation plus the generated trees. + public Compilation OutputCompilation + { + get; + } + + /// Diagnostics reported by the generator itself (this generator must never report any). + public ImmutableArray GeneratorDiagnostics + { + get; + } + + public GeneratorRunResult Result + { + get; + } + + public ImmutableArray GeneratedSources => Result.GeneratedSources; + + /// Text of the only generated file; fails when there is none or more than one. + public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); + + public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) + { + GeneratorDriver updated = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + + return new GeneratorRun(updated, outputCompilation, diagnostics); + } + + /// Asserts "silent": no file, no generator diagnostic, no exception swallowed by the driver. + public void AssertNoOutput() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + Assert.Empty(GeneratedSources); + } + + /// + /// Asserts that the updated compilation (user code + stubs + generated code) has no error and no warning, and + /// that the generator reported nothing. + /// + public void AssertCompilesClean() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + + Diagnostic[] problems = + [ + .. OutputCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && + !IsMissingDocumentationInTestInput(diagnostic)) + ]; + + Assert.True(problems.Length == 0, + "Unexpected compiler diagnostics:\n" + string.Join('\n', problems.AsEnumerable())); + } + + // The test plugins are public and undocumented on purpose (short sources). CS1591 is ignored for them, and only + // for them: in a generated file it still fails the assertion. + private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) + { + return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs index d5ff82a3..138fd492 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs @@ -1,6 +1,8 @@ using System.Reflection; using System.Runtime.Loader; + using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Emit; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -11,78 +13,79 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal sealed class LoadedBootstrap : IDisposable { - private readonly AssemblyLoadContext _context; - private readonly Func _initialize; - private readonly Type _pluginHost; + private readonly AssemblyLoadContext _context; + private readonly Func _initialize; + private readonly Type _pluginHost; - private LoadedBootstrap(AssemblyLoadContext context, Assembly hosting, Assembly plugin) - { - _context = context; - _pluginHost = hosting.GetType("CheatEngine.SDK.Hosting.Bootstrap.PluginHost", true)!; + private LoadedBootstrap(AssemblyLoadContext context, Assembly hosting, Assembly plugin) + { + _context = context; + _pluginHost = hosting.GetType("CheatEngine.SDK.Hosting.Bootstrap.PluginHost", true)!; - // Same lookup as the host: type 'CESDK.CESDK' in the plugin assembly, static method 'CEPluginInitialize'. - var entryPoint = plugin.GetType("CESDK.CESDK", true)!; - var method = entryPoint.GetMethod("CEPluginInitialize", BindingFlags.Public | BindingFlags.Static) - ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); - _initialize = method.CreateDelegate>(); - } + // Same lookup as the host: type 'CESDK.CESDK' in the plugin assembly, static method 'CEPluginInitialize'. + Type entryPoint = plugin.GetType("CESDK.CESDK", true)!; + MethodInfo method = entryPoint.GetMethod("CEPluginInitialize", BindingFlags.Public | BindingFlags.Static) + ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); + _initialize = method.CreateDelegate>(); + } - /// Number of calls that reached the stub PluginHost.InitializeManaged. - public int HostCallCount => (int)ReadHostField("CallCount")!; + /// Number of calls that reached the stub PluginHost.InitializeManaged. + public int HostCallCount => (int) ReadHostField("CallCount")!; - /// The opaque host value handed to the hosting runtime by the generated bootstrap. - public int LastHostArgument => (int)ReadHostField("LastHostArgument")!; + /// The opaque host value handed to the hosting runtime by the generated bootstrap. + public int LastHostArgument => (int) ReadHostField("LastHostArgument")!; - /// TFactory.Utf8Name as seen by the stub host. - public byte[]? LastUtf8Name => (byte[]?)ReadHostField("LastUtf8Name"); + /// TFactory.Utf8Name as seen by the stub host. + public byte[]? LastUtf8Name => (byte[]?) ReadHostField("LastUtf8Name"); - /// Full type name of the instance returned by TFactory.Create(). - public string? LastPluginTypeName => ReadHostField("LastPlugin")?.GetType().FullName; + /// Full type name of the instance returned by TFactory.Create(). + public string? LastPluginTypeName => ReadHostField("LastPlugin")?.GetType().FullName; - public void Dispose() - { - _context.Unload(); - } + public void Dispose() + { + _context.Unload(); + } - public static LoadedBootstrap Load(RoslynEnvironment environment, Compilation outputCompilation) - { - using MemoryStream pluginImage = new(); - var result = outputCompilation.Emit(pluginImage, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(result.Success, "The plugin compilation does not emit:\n" + string.Join('\n', result.Diagnostics)); - pluginImage.Position = 0; + public static LoadedBootstrap Load(RoslynEnvironment environment, Compilation outputCompilation) + { + using MemoryStream pluginImage = new(); + EmitResult result = + outputCompilation.Emit(pluginImage, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, "The plugin compilation does not emit:\n" + string.Join('\n', result.Diagnostics)); + pluginImage.Position = 0; - AssemblyLoadContext context = new("CheatEngine.SDK.EntryPoint.Tests.Bootstrap", true); - using MemoryStream annotationsImage = new([.. environment.AnnotationsImage]); - var annotations = context.LoadFromStream(annotationsImage); - using MemoryStream hostingImage = new([.. environment.HostingImage]); - var hosting = context.LoadFromStream(hostingImage); - context.Resolving += (_, name) => - string.Equals(name.Name, ContractStubs.AnnotationsAssemblyName, StringComparison.Ordinal) - ? annotations - : string.Equals(name.Name, ContractStubs.HostingAssemblyName, StringComparison.Ordinal) - ? hosting - : null; - var plugin = context.LoadFromStream(pluginImage); + AssemblyLoadContext context = new("CheatEngine.SDK.EntryPoint.Tests.Bootstrap", true); + using MemoryStream annotationsImage = new([.. environment.AnnotationsImage]); + Assembly annotations = context.LoadFromStream(annotationsImage); + using MemoryStream hostingImage = new([.. environment.HostingImage]); + Assembly hosting = context.LoadFromStream(hostingImage); + context.Resolving += (_, name) => + string.Equals(name.Name, ContractStubs.AnnotationsAssemblyName, StringComparison.Ordinal) + ? annotations + : string.Equals(name.Name, ContractStubs.HostingAssemblyName, StringComparison.Ordinal) + ? hosting + : null; + Assembly plugin = context.LoadFromStream(pluginImage); - return new LoadedBootstrap(context, hosting, plugin); - } + return new LoadedBootstrap(context, hosting, plugin); + } - /// Calls the generated CESDK.CESDK.CEPluginInitialize. - public int Initialize(IntPtr args, int opaqueArgument - ) - { - return - _initialize - ( - args - , - opaqueArgument); - } + /// Calls the generated CESDK.CESDK.CEPluginInitialize. + public int Initialize(IntPtr args, int opaqueArgument + ) + { + return + _initialize + ( + args + , + opaqueArgument); + } - private object? ReadHostField(string - name - ) - { - return _pluginHost.GetField(name, BindingFlags.Public | BindingFlags.Static)!.GetValue(null); - } + private object? ReadHostField(string + name + ) + { + return _pluginHost.GetField(name, BindingFlags.Public | BindingFlags.Static)!.GetValue(null); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs index 27d02f60..5cb4b2ef 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -17,81 +18,96 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal static class LocalFrameworkReferences { - private const string TargetFrameworkFolder = "net10.0"; + private const string TargetFrameworkFolder = "net10.0"; - private static string RuntimeDirectory => - Path.GetDirectoryName(typeof(object).Assembly.Location) - ?? throw new InvalidOperationException( - "System.Private.CoreLib has no location: single-file test hosts are not supported."); + private static string RuntimeDirectory => + Path.GetDirectoryName(typeof(object).Assembly.Location) + ?? throw new InvalidOperationException( + "System.Private.CoreLib has no location: single-file test hosts are not supported."); - /// Targeting pack when there is one, the running runtime otherwise. - /// Neither source yields a single assembly. - public static ImmutableArray Load() - { - var references = FromTargetingPack(); - if (references.IsEmpty) references = FromRunningRuntime(); + /// Targeting pack when there is one, the running runtime otherwise. + /// Neither source yields a single assembly. + public static ImmutableArray Load() + { + ImmutableArray references = FromTargetingPack(); + if (references.IsEmpty) + { + references = FromRunningRuntime(); + } - return references.IsEmpty - ? throw new InvalidOperationException( - $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") - : references; - } + return references.IsEmpty + ? throw new InvalidOperationException( + $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") + : references; + } - /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. - public static ImmutableArray FromTargetingPack() - { - // /shared/Microsoft.NETCore.App// -> /packs/Microsoft.NETCore.App.Ref//ref/net10.0/ - var dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); - if (dotnetRoot is null) return []; + /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. + public static ImmutableArray FromTargetingPack() + { + // /shared/Microsoft.NETCore.App// -> /packs/Microsoft.NETCore.App.Ref//ref/net10.0/ + string? dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); + if (dotnetRoot is null) + { + return []; + } - var packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); - if (!Directory.Exists(packs)) return []; + string packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); + if (!Directory.Exists(packs)) + { + return []; + } - string? best = null; - Version? bestVersion = null; - foreach (var pack in Directory.EnumerateDirectories(packs)) - { - var candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); - if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out var version) - && (bestVersion is null || version > bestVersion)) - { - best = candidate; - bestVersion = version; - } - } + string? best = null; + Version? bestVersion = null; + foreach (string pack in Directory.EnumerateDirectories(packs)) + { + string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); + if (Directory.Exists(candidate) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) + { + best = candidate; + bestVersion = version; + } + } - return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); - } + return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); + } - /// Implementation assemblies of the runtime this process runs on (managed ones only). - public static ImmutableArray FromRunningRuntime() - { - // The list also holds the test application's own dependencies (xUnit, Roslyn, the generator): only what - // sits in the shared framework directory is Microsoft.NETCore.App. Native DLLs are not on the list. - var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; - List paths = []; - foreach (var path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) - paths.Add(path); + /// Implementation assemblies of the runtime this process runs on (managed ones only). + public static ImmutableArray FromRunningRuntime() + { + // The list also holds the test application's own dependencies (xUnit, Roslyn, the generator): only what + // sits in the shared framework directory is Microsoft.NETCore.App. Native DLLs are not on the list. + string trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; + List paths = []; + foreach (string path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(path); + } + } - return CreateReferences(paths); - } + return CreateReferences(paths); + } - // "10.0.1", "10.0.0-rc.2.25502.107": the pre-release label does not matter for picking a pack. - private static bool TryParsePackVersion(string directoryName, out Version? version) - { - var label = directoryName.IndexOf('-', StringComparison.Ordinal); - return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); - } + // "10.0.1", "10.0.0-rc.2.25502.107": the pre-release label does not matter for picking a pack. + private static bool TryParsePackVersion(string directoryName, out Version? version) + { + int label = directoryName.IndexOf('-', StringComparison.Ordinal); + return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); + } - // Sorted: the order of references is part of a compilation, and directory enumeration order is not specified. - private static ImmutableArray CreateReferences(IEnumerable paths) - { - var references = ImmutableArray.CreateBuilder(); - foreach (var path in paths.Order(StringComparer.OrdinalIgnoreCase)) - references.Add(MetadataReference.CreateFromFile(path)); + // Sorted: the order of references is part of a compilation, and directory enumeration order is not specified. + private static ImmutableArray CreateReferences(IEnumerable paths) + { + ImmutableArray.Builder references = ImmutableArray.CreateBuilder(); + foreach (string path in paths.Order(StringComparer.OrdinalIgnoreCase)) + { + references.Add(MetadataReference.CreateFromFile(path)); + } - return references.ToImmutable(); - } + return references.ToImmutable(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs index 29b9b4ff..ee75bfd1 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Globalization; using System.Reflection; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -12,51 +13,65 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal static class ModelGraph { - private static readonly Type[] ForbiddenTypes = - [ - typeof(ISymbol), - typeof(SyntaxNode), - typeof(SyntaxTree), - typeof(SyntaxToken), - typeof(SyntaxReference), - typeof(Location), - typeof(SemanticModel), - typeof(Compilation), - typeof(AttributeData) - ]; - - /// Asserts that reaches no Roslyn object; returns the number of objects visited. - public static int AssertFreeOfRoslynObjects(object? value, string stepName) - { - HashSet visited = new(ReferenceEqualityComparer.Instance); - Visit(value, stepName, visited, 0); - return visited.Count; - } - - private static void Visit(object? value, string path, HashSet visited, int depth) - { - if (value is null) return; - - var type = value.GetType(); - Assert.False( - Array.Exists(ForbiddenTypes, forbidden => forbidden.IsAssignableFrom(type)), - $"{path}: a {type.FullName} is held by the pipeline model."); - - if (type.IsPrimitive || type.IsEnum || value is string) return; - - Assert.True(depth < 32, $"{path}: object graph too deep."); - if (!type.IsValueType && !visited.Add(value)) return; - - if (value is IEnumerable sequence) - { - var index = 0; - foreach (var item in sequence) - Visit(item, $"{path}[{index++.ToString(CultureInfo.InvariantCulture)}]", visited, depth + 1); - - return; - } - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); - } + private static readonly Type[] ForbiddenTypes = + [ + typeof(ISymbol), + typeof(SyntaxNode), + typeof(SyntaxTree), + typeof(SyntaxToken), + typeof(SyntaxReference), + typeof(Location), + typeof(SemanticModel), + typeof(Compilation), + typeof(AttributeData) + ]; + + /// Asserts that reaches no Roslyn object; returns the number of objects visited. + public static int AssertFreeOfRoslynObjects(object? value, string stepName) + { + HashSet visited = new(ReferenceEqualityComparer.Instance); + Visit(value, stepName, visited, 0); + return visited.Count; + } + + private static void Visit(object? value, string path, HashSet visited, int depth) + { + if (value is null) + { + return; + } + + Type type = value.GetType(); + Assert.False( + Array.Exists(ForbiddenTypes, forbidden => forbidden.IsAssignableFrom(type)), + $"{path}: a {type.FullName} is held by the pipeline model."); + + if (type.IsPrimitive || type.IsEnum || value is string) + { + return; + } + + Assert.True(depth < 32, $"{path}: object graph too deep."); + if (!type.IsValueType && !visited.Add(value)) + { + return; + } + + if (value is IEnumerable sequence) + { + int index = 0; + foreach (object item in sequence) + { + Visit(item, $"{path}[{index++.ToString(CultureInfo.InvariantCulture)}]", visited, depth + 1); + } + + return; + } + + foreach (FieldInfo field in + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/PluginSources.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/PluginSources.cs index 5d520d10..cad323e4 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/PluginSources.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/PluginSources.cs @@ -3,43 +3,43 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// Plugin source snippets shared by the tests. They compile warning-free against the contract stubs. internal static class PluginSources { - /// Overrides of the two abstract lifecycle methods, to paste into a plugin class body. - public const string LifecycleOverrides = """ - protected override void OnEnable() { } - - protected override void OnDisable() { } - """; - - /// The nominal plugin: Demo.DemoPlugin, display name Demo Plugin. - public const string Nominal = $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace Demo; - - [CheatEnginePlugin("Demo Plugin")] - public sealed class DemoPlugin : CheatEnginePlugin - { - {{LifecycleOverrides}} - } - """; - - /// A valid plugin Demo.<className> whose name argument is . - /// C# expression placed between the attribute's parentheses. - /// Simple name of the plugin class. - public static string WithNameExpression(string nameExpression, string className = "DemoPlugin") - { - return $$""" - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace Demo; - - [CheatEnginePlugin({{nameExpression}})] - public sealed class {{className}} : CheatEnginePlugin - { - {{LifecycleOverrides}} - } - """; - } + /// Overrides of the two abstract lifecycle methods, to paste into a plugin class body. + public const string LifecycleOverrides = """ + protected override void OnEnable() { } + + protected override void OnDisable() { } + """; + + /// The nominal plugin: Demo.DemoPlugin, display name Demo Plugin. + public const string Nominal = $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace Demo; + + [CheatEnginePlugin("Demo Plugin")] + public sealed class DemoPlugin : CheatEnginePlugin + { + {{LifecycleOverrides}} + } + """; + + /// A valid plugin Demo.<className> whose name argument is . + /// C# expression placed between the attribute's parentheses. + /// Simple name of the plugin class. + public static string WithNameExpression(string nameExpression, string className = "DemoPlugin") + { + return $$""" + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace Demo; + + [CheatEnginePlugin({{nameExpression}})] + public sealed class {{className}} : CheatEnginePlugin + { + {{LifecycleOverrides}} + } + """; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynEnvironment.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynEnvironment.cs index ef2b7e2b..17fc721e 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynEnvironment.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynEnvironment.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -11,93 +13,110 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal sealed class RoslynEnvironment { - // Documentation comments are parsed and diagnosed, like in a project with GenerateDocumentationFile: the XML - // comments the generator writes are then checked by the compiler too. - public static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); - - // Strict on purpose: nullable on, unsafe OFF (the entry point must compile without it), every warning wave. - public static readonly CSharpCompilationOptions CompilationOptions = new( - OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable, - allowUnsafe: false, - warningLevel: 9999); - - private static readonly Lazy LazyShared = - new(static () => Create(LocalFrameworkReferences.Load())); - - private RoslynEnvironment( - ImmutableArray frameworkReferences, - ImmutableArray annotationsImage, - ImmutableArray hostingImage) - { - FrameworkReferences = frameworkReferences; - AnnotationsImage = annotationsImage; - HostingImage = hostingImage; - AnnotationsReference = MetadataReference.CreateFromImage( - annotationsImage, - filePath: ContractStubs.AnnotationsAssemblyName + ".dll"); - HostingReference = - MetadataReference.CreateFromImage(hostingImage, filePath: ContractStubs.HostingAssemblyName + ".dll"); - } - - /// The process-wide environment over . - public static RoslynEnvironment Shared => LazyShared.Value; - - /// Microsoft.NETCore.App 10.0: reference assemblies, or the running runtime as a fallback. - public ImmutableArray FrameworkReferences { get; } - - /// The compiled annotations contract, to load for execution tests. - public ImmutableArray AnnotationsImage { get; } - - /// The compiled hosting contract, to load for execution tests. - public ImmutableArray HostingImage { get; } - - /// The annotations contract, as a compilation reference. - public MetadataReference AnnotationsReference { get; } - - /// The hosting contract, as a compilation reference. - public MetadataReference HostingReference { get; } - - /// Framework + SDK contract assemblies: the references of a plugin compilation. - public ImmutableArray PluginReferences => - FrameworkReferences.Add(AnnotationsReference).Add(HostingReference); - - /// Compiles the contract stubs against . - /// The stubs do not compile against these references. - public static RoslynEnvironment Create(ImmutableArray frameworkReferences) - { - var annotations = CSharpCompilation.Create( - ContractStubs.AnnotationsAssemblyName, - [ - CSharpSyntaxTree.ParseText(ContractStubs.AnnotationsSource, ParseOptions, "AnnotationsStubs.cs", - cancellationToken: TestContext.Current.CancellationToken), - ], - frameworkReferences, - CompilationOptions); - var hosting = CSharpCompilation.Create( - ContractStubs.HostingAssemblyName, - [ - CSharpSyntaxTree.ParseText(ContractStubs.HostingSource, ParseOptions, "HostingStubs.cs", - cancellationToken: TestContext.Current.CancellationToken), - ], - frameworkReferences, - CompilationOptions); - - return new RoslynEnvironment( - frameworkReferences, - EmitImage(annotations, ContractStubs.AnnotationsAssemblyName), - EmitImage(hosting, ContractStubs.HostingAssemblyName)); - } - - private static ImmutableArray EmitImage(CSharpCompilation compilation, string assemblyName) - { - using MemoryStream image = new(); - var result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); - if (!result.Success) - throw new InvalidOperationException( - "The " + assemblyName + " contract stubs do not compile: " + - string.Join(Environment.NewLine, result.Diagnostics)); - - return [.. image.ToArray()]; - } + // Documentation comments are parsed and diagnosed, like in a project with GenerateDocumentationFile: the XML + // comments the generator writes are then checked by the compiler too. + public static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); + + // Strict on purpose: nullable on, unsafe OFF (the entry point must compile without it), every warning wave. + public static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + allowUnsafe: false, + warningLevel: 9999); + + private static readonly Lazy LazyShared = + new(static () => Create(LocalFrameworkReferences.Load())); + + private RoslynEnvironment( + ImmutableArray frameworkReferences, + ImmutableArray annotationsImage, + ImmutableArray hostingImage) + { + FrameworkReferences = frameworkReferences; + AnnotationsImage = annotationsImage; + HostingImage = hostingImage; + AnnotationsReference = MetadataReference.CreateFromImage( + annotationsImage, + filePath: ContractStubs.AnnotationsAssemblyName + ".dll"); + HostingReference = + MetadataReference.CreateFromImage(hostingImage, filePath: ContractStubs.HostingAssemblyName + ".dll"); + } + + /// The process-wide environment over . + public static RoslynEnvironment Shared => LazyShared.Value; + + /// Microsoft.NETCore.App 10.0: reference assemblies, or the running runtime as a fallback. + public ImmutableArray FrameworkReferences + { + get; + } + + /// The compiled annotations contract, to load for execution tests. + public ImmutableArray AnnotationsImage + { + get; + } + + /// The compiled hosting contract, to load for execution tests. + public ImmutableArray HostingImage + { + get; + } + + /// The annotations contract, as a compilation reference. + public MetadataReference AnnotationsReference + { + get; + } + + /// The hosting contract, as a compilation reference. + public MetadataReference HostingReference + { + get; + } + + /// Framework + SDK contract assemblies: the references of a plugin compilation. + public ImmutableArray PluginReferences => + FrameworkReferences.Add(AnnotationsReference).Add(HostingReference); + + /// Compiles the contract stubs against . + /// The stubs do not compile against these references. + public static RoslynEnvironment Create(ImmutableArray frameworkReferences) + { + CSharpCompilation annotations = CSharpCompilation.Create( + ContractStubs.AnnotationsAssemblyName, + [ + CSharpSyntaxTree.ParseText(ContractStubs.AnnotationsSource, ParseOptions, "AnnotationsStubs.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + frameworkReferences, + CompilationOptions); + CSharpCompilation hosting = CSharpCompilation.Create( + ContractStubs.HostingAssemblyName, + [ + CSharpSyntaxTree.ParseText(ContractStubs.HostingSource, ParseOptions, "HostingStubs.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + frameworkReferences, + CompilationOptions); + + return new RoslynEnvironment( + frameworkReferences, + EmitImage(annotations, ContractStubs.AnnotationsAssemblyName), + EmitImage(hosting, ContractStubs.HostingAssemblyName)); + } + + private static ImmutableArray EmitImage(CSharpCompilation compilation, string assemblyName) + { + using MemoryStream image = new(); + EmitResult result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + if (!result.Success) + { + throw new InvalidOperationException( + "The " + assemblyName + " contract stubs do not compile: " + + string.Join(Environment.NewLine, result.Diagnostics)); + } + + return [.. image.ToArray()]; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynFixture.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynFixture.cs index 64452144..89b0befe 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynFixture.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/RoslynFixture.cs @@ -1,4 +1,5 @@ using System.Globalization; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -11,86 +12,91 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// public sealed class RoslynFixture { - /// Assembly name of the plugin compilations created here. - internal const string PluginAssemblyName = "TestPlugin"; + /// Assembly name of the plugin compilations created here. + internal const string PluginAssemblyName = "TestPlugin"; - /// - /// Takes the process-wide environment. Nothing to await and nothing to restore: the references come from the - /// local .NET installation. A failure to find them fails the tests of the class with the resolver's message. - /// - public RoslynFixture() - { - Environment = RoslynEnvironment.Shared; - } + /// + /// Takes the process-wide environment. Nothing to await and nothing to restore: the references come from the + /// local .NET installation. A failure to find them fails the tests of the class with the resolver's message. + /// + public RoslynFixture() + { + Environment = RoslynEnvironment.Shared; + } - internal RoslynEnvironment Environment { get; } + internal RoslynEnvironment Environment + { + get; + } - /// A plugin compilation with one syntax tree per source, named Source0.cs, Source1.cs... - internal CSharpCompilation CreateCompilation(params string[] sources) - { - return CreateCompilation(Environment, RoslynEnvironment.ParseOptions, sources); - } + /// A plugin compilation with one syntax tree per source, named Source0.cs, Source1.cs... + internal CSharpCompilation CreateCompilation(params string[] sources) + { + return CreateCompilation(Environment, RoslynEnvironment.ParseOptions, sources); + } - /// Same, against another environment (framework references) or another language version. - internal static CSharpCompilation CreateCompilation(RoslynEnvironment environment, CSharpParseOptions parseOptions, - params string[] sources) - { - var trees = new SyntaxTree[sources.Length]; - for (var i = 0; i < sources.Length; i++) - trees[i] = CSharpSyntaxTree.ParseText( - sources[i], - parseOptions, - $"Source{i.ToString(CultureInfo.InvariantCulture)}.cs", - cancellationToken: TestContext.Current.CancellationToken); + /// Same, against another environment (framework references) or another language version. + internal static CSharpCompilation CreateCompilation(RoslynEnvironment environment, CSharpParseOptions parseOptions, + params string[] sources) + { + SyntaxTree[] trees = new SyntaxTree[sources.Length]; + for (int i = 0; i < sources.Length; i++) + { + trees[i] = CSharpSyntaxTree.ParseText( + sources[i], + parseOptions, + $"Source{i.ToString(CultureInfo.InvariantCulture)}.cs", + cancellationToken: TestContext.Current.CancellationToken); + } - return CSharpCompilation.Create( - PluginAssemblyName, - trees, - environment.PluginReferences, - RoslynEnvironment.CompilationOptions); - } + return CSharpCompilation.Create( + PluginAssemblyName, + trees, + environment.PluginReferences, + RoslynEnvironment.CompilationOptions); + } - /// - /// Creates a driver for the generator; defaults to the explicit direct-package - /// setting that enables bootstrap generation. Pass to - /// model a transitive reference without the package's direct-only build asset. - /// defaults to the strict C# 14 options. - /// - internal static GeneratorDriver CreateDriver( - TestAnalyzerConfigOptionsProvider? options = null, - CSharpParseOptions? parseOptions = null) - { - return CSharpGeneratorDriver.Create( - [new EntryPointGenerator().AsSourceGenerator()], - [], - parseOptions ?? RoslynEnvironment.ParseOptions, - options ?? TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "true"), - new GeneratorDriverOptions( - IncrementalGeneratorOutputKind.None, - true)); - } + /// + /// Creates a driver for the generator; defaults to the explicit direct-package + /// setting that enables bootstrap generation. Pass to + /// model a transitive reference without the package's direct-only build asset. + /// defaults to the strict C# 14 options. + /// + internal static GeneratorDriver CreateDriver( + TestAnalyzerConfigOptionsProvider? options = null, + CSharpParseOptions? parseOptions = null) + { + return CSharpGeneratorDriver.Create( + [new EntryPointGenerator().AsSourceGenerator()], + [], + parseOptions ?? RoslynEnvironment.ParseOptions, + options ?? TestAnalyzerConfigOptionsProvider.WithBuildProperty("CheatEngineSdkGenerateEntryPoint", "true"), + new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + true)); + } - /// Runs the generator once over . - internal GeneratorRun Run(params string[] sources) - { - return Run(CreateCompilation(sources)); - } + /// Runs the generator once over . + internal GeneratorRun Run(params string[] sources) + { + return Run(CreateCompilation(sources)); + } - /// Runs the generator once over . - internal static GeneratorRun Run( - Compilation compilation, - TestAnalyzerConfigOptionsProvider? options = null, - CSharpParseOptions? parseOptions = null) - { - return GeneratorRun.Execute(CreateDriver(options, parseOptions), compilation); - } + /// Runs the generator once over . + internal static GeneratorRun Run( + Compilation compilation, + TestAnalyzerConfigOptionsProvider? options = null, + CSharpParseOptions? parseOptions = null) + { + return GeneratorRun.Execute(CreateDriver(options, parseOptions), compilation); + } - internal static SyntaxTree Parse(string source, string path) - { - return CSharpSyntaxTree.ParseText( - source, - RoslynEnvironment.ParseOptions, - path, - cancellationToken: TestContext.Current.CancellationToken); - } + internal static SyntaxTree Parse(string source, string path) + { + return CSharpSyntaxTree.ParseText( + source, + RoslynEnvironment.ParseOptions, + path, + cancellationToken: TestContext.Current.CancellationToken); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/StepAssert.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/StepAssert.cs index ff930995..8646cd1e 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/StepAssert.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/StepAssert.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -7,59 +9,61 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// Assertions over (the incrementality gate). internal static class StepAssert { - /// Name Roslyn gives to the step of a RegisterSourceOutput callback. - public const string SourceOutputStep = "SourceOutput"; + /// Name Roslyn gives to the step of a RegisterSourceOutput callback. + public const string SourceOutputStep = "SourceOutput"; - /// - /// Every CheatEngine.SDK-named step ran, produced something, and produced only Cached/Unchanged values; - /// the - /// source output was not re-executed. Also fails when a step exists that the generator's name list does not - /// know, so a new step cannot slip past the gate. - /// - public static void NothingWasRecomputed(GeneratorRunResult result) - { - string[] trackedCheatEngineSdkSteps = - [.. result.TrackedSteps.Keys.Where(TrackingNames.IsCheatEngineSdkStep).Order(StringComparer.Ordinal)]; - Assert.Equal(EntryPointTrackingNames.All.Order(StringComparer.Ordinal), trackedCheatEngineSdkSteps, - StringComparer.Ordinal); + /// + /// Every CheatEngine.SDK-named step ran, produced something, and produced only Cached/Unchanged values; + /// the + /// source output was not re-executed. Also fails when a step exists that the generator's name list does not + /// know, so a new step cannot slip past the gate. + /// + public static void NothingWasRecomputed(GeneratorRunResult result) + { + string[] trackedCheatEngineSdkSteps = + [.. result.TrackedSteps.Keys.Where(TrackingNames.IsCheatEngineSdkStep).Order(StringComparer.Ordinal)]; + Assert.Equal(EntryPointTrackingNames.All.Order(StringComparer.Ordinal), trackedCheatEngineSdkSteps, + StringComparer.Ordinal); - foreach (var stepName in EntryPointTrackingNames.All) - Assert.All( - Reasons(result, stepName), - reason => Assert.True( - reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, - $"Step '{stepName}' was recomputed: {reason}.")); + foreach (string stepName in EntryPointTrackingNames.All) + { + Assert.All( + Reasons(result, stepName), + reason => Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Step '{stepName}' was recomputed: {reason}.")); + } - Assert.All(OutputReasons(result), static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); - } + Assert.All(OutputReasons(result), static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); + } - /// Reasons of every output of the step named ; fails when there is none. - public static ImmutableArray Reasons(GeneratorRunResult result, string stepName) - { - Assert.True(result.TrackedSteps.ContainsKey(stepName), $"Step '{stepName}' was not tracked."); + /// Reasons of every output of the step named ; fails when there is none. + public static ImmutableArray Reasons(GeneratorRunResult result, string stepName) + { + Assert.True(result.TrackedSteps.ContainsKey(stepName), $"Step '{stepName}' was not tracked."); - ImmutableArray reasons = - [ - .. result.TrackedSteps[stepName].SelectMany(static step => step.Outputs) - .Select(static output => output.Reason) - ]; + ImmutableArray reasons = + [ + .. result.TrackedSteps[stepName].SelectMany(static step => step.Outputs) + .Select(static output => output.Reason) + ]; - Assert.False(reasons.IsEmpty, $"Step '{stepName}' produced no output: the assertion would be vacuous."); - return reasons; - } + Assert.False(reasons.IsEmpty, $"Step '{stepName}' produced no output: the assertion would be vacuous."); + return reasons; + } - /// Reasons of the source-output step; fails when there is none. - public static ImmutableArray OutputReasons(GeneratorRunResult result) - { - Assert.True(result.TrackedOutputSteps.ContainsKey(SourceOutputStep), "The source output step was not tracked."); + /// Reasons of the source-output step; fails when there is none. + public static ImmutableArray OutputReasons(GeneratorRunResult result) + { + Assert.True(result.TrackedOutputSteps.ContainsKey(SourceOutputStep), "The source output step was not tracked."); - ImmutableArray reasons = - [ - .. result.TrackedOutputSteps[SourceOutputStep].SelectMany(static step => step.Outputs) - .Select(static output => output.Reason) - ]; + ImmutableArray reasons = + [ + .. result.TrackedOutputSteps[SourceOutputStep].SelectMany(static step => step.Outputs) + .Select(static output => output.Reason) + ]; - Assert.False(reasons.IsEmpty, "The source output step produced no output: the assertion would be vacuous."); - return reasons; - } + Assert.False(reasons.IsEmpty, "The source output step produced no output: the assertion would be vacuous."); + return reasons; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptions.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptions.cs index 7b00f9da..69f8e0a9 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptions.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptions.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; @@ -7,12 +8,12 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// Dictionary-backed (keys compare like real editorconfig keys). internal sealed class TestAnalyzerConfigOptions(ImmutableDictionary values) : AnalyzerConfigOptions { - public static readonly TestAnalyzerConfigOptions Empty = - new(ImmutableDictionary.Create(KeyComparer)); + public static readonly TestAnalyzerConfigOptions Empty = + new(ImmutableDictionary.Create(KeyComparer)); - /// - public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) - { - return values.TryGetValue(key, out value); - } + /// + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + return values.TryGetValue(key, out value); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptionsProvider.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptionsProvider.cs index 35b91177..e00b7ba3 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptionsProvider.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/TestAnalyzerConfigOptionsProvider.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -10,33 +11,36 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; /// internal sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider { - public static readonly TestAnalyzerConfigOptionsProvider Empty = new(TestAnalyzerConfigOptions.Empty); - - private TestAnalyzerConfigOptionsProvider(TestAnalyzerConfigOptions globalOptions) - { - GlobalOptions = globalOptions; - } - - /// - public override AnalyzerConfigOptions GlobalOptions { get; } - - /// Global options with the single key build_property.<name>. - public static TestAnalyzerConfigOptionsProvider WithBuildProperty(string name, string value) - { - return new TestAnalyzerConfigOptionsProvider(new TestAnalyzerConfigOptions( - ImmutableDictionary.Create(AnalyzerConfigOptions.KeyComparer) - .Add("build_property." + name, value))); - } - - /// - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) - { - return TestAnalyzerConfigOptions.Empty; - } - - /// - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) - { - return TestAnalyzerConfigOptions.Empty; - } + public static readonly TestAnalyzerConfigOptionsProvider Empty = new(TestAnalyzerConfigOptions.Empty); + + private TestAnalyzerConfigOptionsProvider(TestAnalyzerConfigOptions globalOptions) + { + GlobalOptions = globalOptions; + } + + /// + public override AnalyzerConfigOptions GlobalOptions + { + get; + } + + /// Global options with the single key build_property.<name>. + public static TestAnalyzerConfigOptionsProvider WithBuildProperty(string name, string value) + { + return new TestAnalyzerConfigOptionsProvider(new TestAnalyzerConfigOptions( + ImmutableDictionary.Create(AnalyzerConfigOptions.KeyComparer) + .Add("build_property." + name, value))); + } + + /// + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return TestAnalyzerConfigOptions.Empty; + } + + /// + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return TestAnalyzerConfigOptions.Empty; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs index 0aa9b540..eaf158ee 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs @@ -7,92 +7,92 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Model; /// The "exactly one valid plugin" decision and the value equality the pipeline relies on (no Roslyn needed). public sealed class BootstrapModelTests { - private static readonly EntryPointOptions On = new(true); - private static readonly EntryPointOptions Off = new(false); - - private static readonly PluginModel ValidA = new("global::A", "Plugin A", "", PluginShapeIssues.None); - private static readonly PluginModel ValidB = new("global::B", "Plugin B", "EXP001", PluginShapeIssues.None); - - private static readonly PluginModel Invalid = new("global::C", "Plugin C", "", - PluginShapeIssues.Abstract | PluginShapeIssues.Generic); - - [Fact] - public void Select_single_valid_plugin_yields_its_model() - { - var model = BootstrapModel.Select(new EquatableArray([ValidA]), On); - - Assert.Equal(new BootstrapModel("global::A", "Plugin A", ""), model); - } - - [Fact] - public void Select_no_plugin_yields_null() - { - Assert.Null(BootstrapModel.Select(EquatableArray.Empty, On)); - Assert.Null(BootstrapModel.Select(default, On)); - } - - [Fact] - public void Select_two_valid_plugins_yields_null() - { - Assert.Null(BootstrapModel.Select(new EquatableArray([ValidA, ValidB]), On)); - } - - [Fact] - public void Select_only_invalid_plugins_yields_null() - { - Assert.Null(BootstrapModel.Select(new EquatableArray([Invalid]), On)); - } - - [Fact] - public void Select_invalid_plugins_do_not_count() - { - var model = BootstrapModel.Select(new EquatableArray([Invalid, ValidB, Invalid]), On); - - Assert.Equal(new BootstrapModel("global::B", "Plugin B", "EXP001"), model); - } - - [Fact] - public void Select_generation_switched_off_yields_null() - { - Assert.Null(BootstrapModel.Select(new EquatableArray([ValidA]), Off)); - } - - [Fact] - public void Select_user_entry_point_type_collision_yields_null() - { - Assert.Null(BootstrapModel.Select(new EquatableArray([ValidA]), On, entryPointTypeCollision: true)); - } - - [Fact] - public void PluginModel_is_valid_only_without_issues() - { - Assert.True(ValidA.IsValid); - Assert.False(Invalid.IsValid); - Assert.False((ValidA with { Issues = PluginShapeIssues.InvalidName }).IsValid); - } - - [Fact] - public void Models_compare_by_value() - { - Assert.Equal(new PluginModel("global::A", "Plugin A", "", PluginShapeIssues.None), ValidA); - Assert.NotEqual(ValidA with { DisplayName = "other" }, ValidA); - Assert.NotEqual(ValidA with { FullyQualifiedTypeName = "global::Z" }, ValidA); - Assert.NotEqual(ValidA with { DeclaredDiagnosticIds = "EXP001" }, ValidA); - Assert.NotEqual(ValidA with { Issues = PluginShapeIssues.Static }, ValidA); - Assert.Equal(new EntryPointOptions(true), On); - Assert.NotEqual(Off, On); - Assert.Equal( - new EquatableArray([ValidA, Invalid]), - new EquatableArray([ValidA with { }, Invalid with { }])); - } - - [Fact] - public void PluginShapeIssues_flags_are_distinct_single_bits() - { - PluginShapeIssues[] flags = - [.. Enum.GetValues().Where(static flag => flag != PluginShapeIssues.None)]; - - Assert.All(flags, static flag => Assert.True(int.IsPow2((int)flag), $"{flag} is not a single bit.")); - Assert.Equal(flags.Length, flags.Distinct().Count()); - } + private static readonly EntryPointOptions On = new(true); + private static readonly EntryPointOptions Off = new(false); + + private static readonly PluginModel ValidA = new("global::A", "Plugin A", "", PluginShapeIssues.None); + private static readonly PluginModel ValidB = new("global::B", "Plugin B", "EXP001", PluginShapeIssues.None); + + private static readonly PluginModel Invalid = new("global::C", "Plugin C", "", + PluginShapeIssues.Abstract | PluginShapeIssues.Generic); + + [Fact] + public void Select_single_valid_plugin_yields_its_model() + { + BootstrapModel? model = BootstrapModel.Select(new EquatableArray([ValidA]), On); + + Assert.Equal(new BootstrapModel("global::A", "Plugin A", ""), model); + } + + [Fact] + public void Select_no_plugin_yields_null() + { + Assert.Null(BootstrapModel.Select(EquatableArray.Empty, On)); + Assert.Null(BootstrapModel.Select(default, On)); + } + + [Fact] + public void Select_two_valid_plugins_yields_null() + { + Assert.Null(BootstrapModel.Select(new EquatableArray([ValidA, ValidB]), On)); + } + + [Fact] + public void Select_only_invalid_plugins_yields_null() + { + Assert.Null(BootstrapModel.Select(new EquatableArray([Invalid]), On)); + } + + [Fact] + public void Select_invalid_plugins_do_not_count() + { + BootstrapModel? model = BootstrapModel.Select(new EquatableArray([Invalid, ValidB, Invalid]), On); + + Assert.Equal(new BootstrapModel("global::B", "Plugin B", "EXP001"), model); + } + + [Fact] + public void Select_generation_switched_off_yields_null() + { + Assert.Null(BootstrapModel.Select(new EquatableArray([ValidA]), Off)); + } + + [Fact] + public void Select_user_entry_point_type_collision_yields_null() + { + Assert.Null(BootstrapModel.Select(new EquatableArray([ValidA]), On, true)); + } + + [Fact] + public void PluginModel_is_valid_only_without_issues() + { + Assert.True(ValidA.IsValid); + Assert.False(Invalid.IsValid); + Assert.False((ValidA with { Issues = PluginShapeIssues.InvalidName }).IsValid); + } + + [Fact] + public void Models_compare_by_value() + { + Assert.Equal(new PluginModel("global::A", "Plugin A", "", PluginShapeIssues.None), ValidA); + Assert.NotEqual(ValidA with { DisplayName = "other" }, ValidA); + Assert.NotEqual(ValidA with { FullyQualifiedTypeName = "global::Z" }, ValidA); + Assert.NotEqual(ValidA with { DeclaredDiagnosticIds = "EXP001" }, ValidA); + Assert.NotEqual(ValidA with { Issues = PluginShapeIssues.Static }, ValidA); + Assert.Equal(new EntryPointOptions(true), On); + Assert.NotEqual(Off, On); + Assert.Equal( + new EquatableArray([ValidA, Invalid]), + new EquatableArray([ValidA with { }, Invalid with { }])); + } + + [Fact] + public void PluginShapeIssues_flags_are_distinct_single_bits() + { + PluginShapeIssues[] flags = + [.. Enum.GetValues().Where(static flag => flag != PluginShapeIssues.None)]; + + Assert.All(flags, static flag => Assert.True(int.IsPow2((int) flag), $"{flag} is not a single bit.")); + Assert.Equal(flags.Length, flags.Distinct().Count()); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/AnnotationsMetadataNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/AnnotationsMetadataNamesTests.cs index 5bb20ef9..3125ada2 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/AnnotationsMetadataNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/AnnotationsMetadataNamesTests.cs @@ -14,17 +14,17 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; /// public sealed class AnnotationsMetadataNamesTests { - [Fact] - public void Plugin_names_match_the_real_types() - { - Assert.Equal(AnnotationsMetadataNames.CheatEnginePluginAttribute, typeof(CheatEnginePluginAttribute).FullName); - Assert.Equal(AnnotationsMetadataNames.CheatEnginePluginBase, typeof(CheatEnginePlugin).FullName); - } + [Fact] + public void Plugin_names_match_the_real_types() + { + Assert.Equal(AnnotationsMetadataNames.CheatEnginePluginAttribute, typeof(CheatEnginePluginAttribute).FullName); + Assert.Equal(AnnotationsMetadataNames.CheatEnginePluginBase, typeof(CheatEnginePlugin).FullName); + } - [Fact] - public void Lua_binding_attribute_names_match_the_real_types() - { - Assert.Equal(AnnotationsMetadataNames.LuaFunctionAttribute, typeof(LuaFunctionAttribute).FullName); - Assert.Equal(AnnotationsMetadataNames.LuaGlobalAttribute, typeof(LuaGlobalAttribute).FullName); - } + [Fact] + public void Lua_binding_attribute_names_match_the_real_types() + { + Assert.Equal(AnnotationsMetadataNames.LuaFunctionAttribute, typeof(LuaFunctionAttribute).FullName); + Assert.Equal(AnnotationsMetadataNames.LuaGlobalAttribute, typeof(LuaGlobalAttribute).FullName); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/BuildPropertyTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/BuildPropertyTests.cs index 5146ed3b..e2e7f021 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/BuildPropertyTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/BuildPropertyTests.cs @@ -1,66 +1,68 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Infrastructure; using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis.Diagnostics; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; public sealed class BuildPropertyTests { - private const string Key = BuildProperty.KeyPrefix + "CheatEngineSdkSwitch"; + private const string Key = BuildProperty.KeyPrefix + "CheatEngineSdkSwitch"; - [Fact] - public void KeyPrefix_matches_the_compiler_visible_property_convention() - { - Assert.Equal("build_property.", BuildProperty.KeyPrefix); - } + [Fact] + public void KeyPrefix_matches_the_compiler_visible_property_convention() + { + Assert.Equal("build_property.", BuildProperty.KeyPrefix); + } - [Theory] - [InlineData("true", true)] - [InlineData("TRUE", true)] - [InlineData(" True ", true)] - [InlineData("false", false)] - [InlineData("False", false)] - [InlineData("\tfalse ", false)] - public void ReadBoolean_boolean_text_is_parsed_whatever_the_default(string raw, bool expected) - { - AnalyzerConfigOptions options = Options(Key, raw); + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData(" True ", true)] + [InlineData("false", false)] + [InlineData("False", false)] + [InlineData("\tfalse ", false)] + public void ReadBoolean_boolean_text_is_parsed_whatever_the_default(string raw, bool expected) + { + AnalyzerConfigOptions options = Options(Key, raw); - Assert.Equal(expected, BuildProperty.ReadBoolean(options, Key, true)); - Assert.Equal(expected, BuildProperty.ReadBoolean(options, Key, false)); - } + Assert.Equal(expected, BuildProperty.ReadBoolean(options, Key, true)); + Assert.Equal(expected, BuildProperty.ReadBoolean(options, Key, false)); + } - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("0")] - [InlineData("1")] - [InlineData("no")] - [InlineData("disable")] - public void ReadBoolean_non_boolean_text_yields_the_default(string raw) - { - AnalyzerConfigOptions options = Options(Key, raw); + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("0")] + [InlineData("1")] + [InlineData("no")] + [InlineData("disable")] + public void ReadBoolean_non_boolean_text_yields_the_default(string raw) + { + AnalyzerConfigOptions options = Options(Key, raw); - Assert.True(BuildProperty.ReadBoolean(options, Key, true)); - Assert.False(BuildProperty.ReadBoolean(options, Key, false)); - } + Assert.True(BuildProperty.ReadBoolean(options, Key, true)); + Assert.False(BuildProperty.ReadBoolean(options, Key, false)); + } - [Fact] - public void ReadBoolean_missing_key_yields_the_default() - { - Assert.True(BuildProperty.ReadBoolean(TestAnalyzerConfigOptions.Empty, Key, true)); - Assert.False(BuildProperty.ReadBoolean(TestAnalyzerConfigOptions.Empty, Key, false)); - } + [Fact] + public void ReadBoolean_missing_key_yields_the_default() + { + Assert.True(BuildProperty.ReadBoolean(TestAnalyzerConfigOptions.Empty, Key, true)); + Assert.False(BuildProperty.ReadBoolean(TestAnalyzerConfigOptions.Empty, Key, false)); + } - [Fact] - public void ReadBoolean_null_options_throws() - { - Assert.Throws(() => BuildProperty.ReadBoolean(null!, Key, true)); - } + [Fact] + public void ReadBoolean_null_options_throws() + { + Assert.Throws(() => BuildProperty.ReadBoolean(null!, Key, true)); + } - private static TestAnalyzerConfigOptions Options(string key, string value) - { - return new TestAnalyzerConfigOptions(ImmutableDictionary - .Create(AnalyzerConfigOptions.KeyComparer).Add(key, value)); - } + private static TestAnalyzerConfigOptions Options(string key, string value) + { + return new TestAnalyzerConfigOptions(ImmutableDictionary + .Create(AnalyzerConfigOptions.KeyComparer).Add(key, value)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/CSharpLiteralTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/CSharpLiteralTests.cs index 11a86fd4..7670549a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/CSharpLiteralTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/CSharpLiteralTests.cs @@ -1,5 +1,7 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -7,103 +9,104 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; public sealed class CSharpLiteralTests { - // Values with unpaired surrogates are kept out of theory data: they do not survive test-case serialisation. - private static readonly string[] RoundTripValues = - [ - string.Empty, - "plain ASCII ~!@#$%^&*()_+-=[]{};':,./<>?|`", - "quote \" backslash \\ both \\\"", - "\0\a\b\f\n\r\t\v", - "nul before digit \01", - "\u0001\u001B\u001F\u007F\u0080\u0085\u009F\u00A0", - "\u2028\u2029\uFEFF\uFFFD\uFFFF", - "Caf\u00E9 \u65E5\u672C\u8A9E \u0416", - "\U0001F600\U0001D11E\U0010FFFF", - "lone high \uD800 lone low \uDC00 reversed \uDE00\uD83D end \uD83D" - ]; - - [Theory] - [InlineData("", "\"\"")] - [InlineData("abc", "\"abc\"")] - [InlineData("a\"b", "\"a\\\"b\"")] - [InlineData("a\\b", "\"a\\\\b\"")] - [InlineData("\n\r\t", "\"\\n\\r\\t\"")] - [InlineData("\u00E9", "\"\\u00E9\"")] - [InlineData("\U0001F600", "\"\\U0001F600\"")] - [InlineData("\u2028", "\"\\u2028\"")] - public void ToStringLiteral_escapes_to_printable_ascii(string value, string expected) - { - Assert.Equal(expected, CSharpLiteral.ToStringLiteral(value)); - } - - [Fact] - public void ToStringLiteral_keeps_unpaired_surrogates_as_escapes() - { - Assert.Equal("\"\\uD800x\\uDC00\"", CSharpLiteral.ToStringLiteral("\uD800x\uDC00")); - } - - [Fact] - public void ToUtf8Literal_replaces_unpaired_surrogates_with_the_replacement_character() - { - Assert.Equal("\"\\uFFFDx\\uFFFD\"u8", CSharpLiteral.ToUtf8Literal("\uD800x\uDC00")); - } - - [Fact] - public void ToUtf8Literal_appends_the_u8_suffix() - { - Assert.Equal("\"name\"u8", CSharpLiteral.ToUtf8Literal("name")); - } - - [Fact] - public void ToStringLiteral_output_is_lexed_back_to_the_same_value_by_the_compiler() - { - foreach (var value in RoundTripValues) - { - var literal = CSharpLiteral.ToStringLiteral(value); - - Assert.All(literal, static c => Assert.InRange(c, ' ', '~')); - var expression = Assert.IsType(SyntaxFactory.ParseExpression(literal)); - Assert.Empty(expression.GetDiagnostics()); - Assert.Equal(SyntaxKind.StringLiteralExpression, expression.Kind()); - Assert.Equal(value, expression.Token.ValueText); - } - } - - [Fact] - public void ToUtf8Literal_output_is_lexed_back_to_the_utf8_encoding_of_the_value() - { - foreach (var value in RoundTripValues) - { - var literal = CSharpLiteral.ToUtf8Literal(value); - - Assert.All(literal, static c => Assert.InRange(c, ' ', '~')); - var expression = Assert.IsType( - SyntaxFactory.ParseExpression(literal, options: new CSharpParseOptions(LanguageVersion.CSharp14))); - Assert.Empty(expression.GetDiagnostics()); - Assert.Equal(SyntaxKind.Utf8StringLiteralExpression, expression.Kind()); - - // Same bytes as the runtime encoder, replacement of unpaired surrogates included. - Assert.Equal(Encoding.UTF8.GetBytes(value), Encoding.UTF8.GetBytes(expression.Token.ValueText)); - } - } - - [Fact] - public void Append_overloads_write_into_the_given_builder() - { - StringBuilder builder = new("x = "); - - CSharpLiteral.AppendStringLiteral(builder, "a"); - builder.Append(", "); - CSharpLiteral.AppendUtf8Literal(builder, "b"); - - Assert.Equal("x = \"a\", \"b\"u8", builder.ToString()); - } - - [Fact] - public void Null_arguments_throw() - { - Assert.Throws(() => CSharpLiteral.ToStringLiteral(null!)); - Assert.Throws(() => CSharpLiteral.ToUtf8Literal(null!)); - Assert.Throws(() => CSharpLiteral.AppendStringLiteral(null!, "a")); - } + // Values with unpaired surrogates are kept out of theory data: they do not survive test-case serialisation. + private static readonly string[] RoundTripValues = + [ + string.Empty, + "plain ASCII ~!@#$%^&*()_+-=[]{};':,./<>?|`", + "quote \" backslash \\ both \\\"", + "\0\a\b\f\n\r\t\v", + "nul before digit \01", + "\u0001\u001B\u001F\u007F\u0080\u0085\u009F\u00A0", + "\u2028\u2029\uFEFF\uFFFD\uFFFF", + "Caf\u00E9 \u65E5\u672C\u8A9E \u0416", + "\U0001F600\U0001D11E\U0010FFFF", + "lone high \uD800 lone low \uDC00 reversed \uDE00\uD83D end \uD83D" + ]; + + [Theory] + [InlineData("", "\"\"")] + [InlineData("abc", "\"abc\"")] + [InlineData("a\"b", "\"a\\\"b\"")] + [InlineData("a\\b", "\"a\\\\b\"")] + [InlineData("\n\r\t", "\"\\n\\r\\t\"")] + [InlineData("\u00E9", "\"\\u00E9\"")] + [InlineData("\U0001F600", "\"\\U0001F600\"")] + [InlineData("\u2028", "\"\\u2028\"")] + public void ToStringLiteral_escapes_to_printable_ascii(string value, string expected) + { + Assert.Equal(expected, CSharpLiteral.ToStringLiteral(value)); + } + + [Fact] + public void ToStringLiteral_keeps_unpaired_surrogates_as_escapes() + { + Assert.Equal("\"\\uD800x\\uDC00\"", CSharpLiteral.ToStringLiteral("\uD800x\uDC00")); + } + + [Fact] + public void ToUtf8Literal_replaces_unpaired_surrogates_with_the_replacement_character() + { + Assert.Equal("\"\\uFFFDx\\uFFFD\"u8", CSharpLiteral.ToUtf8Literal("\uD800x\uDC00")); + } + + [Fact] + public void ToUtf8Literal_appends_the_u8_suffix() + { + Assert.Equal("\"name\"u8", CSharpLiteral.ToUtf8Literal("name")); + } + + [Fact] + public void ToStringLiteral_output_is_lexed_back_to_the_same_value_by_the_compiler() + { + foreach (string value in RoundTripValues) + { + string literal = CSharpLiteral.ToStringLiteral(value); + + Assert.All(literal, static c => Assert.InRange(c, ' ', '~')); + LiteralExpressionSyntax expression = + Assert.IsType(SyntaxFactory.ParseExpression(literal)); + Assert.Empty(expression.GetDiagnostics()); + Assert.Equal(SyntaxKind.StringLiteralExpression, expression.Kind()); + Assert.Equal(value, expression.Token.ValueText); + } + } + + [Fact] + public void ToUtf8Literal_output_is_lexed_back_to_the_utf8_encoding_of_the_value() + { + foreach (string value in RoundTripValues) + { + string literal = CSharpLiteral.ToUtf8Literal(value); + + Assert.All(literal, static c => Assert.InRange(c, ' ', '~')); + LiteralExpressionSyntax expression = Assert.IsType( + SyntaxFactory.ParseExpression(literal, options: new CSharpParseOptions(LanguageVersion.CSharp14))); + Assert.Empty(expression.GetDiagnostics()); + Assert.Equal(SyntaxKind.Utf8StringLiteralExpression, expression.Kind()); + + // Same bytes as the runtime encoder, replacement of unpaired surrogates included. + Assert.Equal(Encoding.UTF8.GetBytes(value), Encoding.UTF8.GetBytes(expression.Token.ValueText)); + } + } + + [Fact] + public void Append_overloads_write_into_the_given_builder() + { + StringBuilder builder = new("x = "); + + CSharpLiteral.AppendStringLiteral(builder, "a"); + builder.Append(", "); + CSharpLiteral.AppendUtf8Literal(builder, "b"); + + Assert.Equal("x = \"a\", \"b\"u8", builder.ToString()); + } + + [Fact] + public void Null_arguments_throw() + { + Assert.Throws(() => CSharpLiteral.ToStringLiteral(null!)); + Assert.Throws(() => CSharpLiteral.ToUtf8Literal(null!)); + Assert.Throws(() => CSharpLiteral.AppendStringLiteral(null!, "a")); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/EquatableArrayTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/EquatableArrayTests.cs index fbe0a1a9..b48dd3b7 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/EquatableArrayTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/EquatableArrayTests.cs @@ -1,123 +1,127 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; public sealed class EquatableArrayTests { - [Fact] - public void Equals_same_elements_in_different_arrays_is_true() - { - EquatableArray left = new(["a", "b", "c"]); - EquatableArray right = new(["a", "b", "c"]); - - Assert.True(left.Equals(right)); - Assert.True(left == right); - Assert.False(left != right); - Assert.True(left.Equals((object)right)); - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - } - - [Fact] - public void Equals_different_order_is_false() - { - EquatableArray left = new(["a", "b"]); - EquatableArray right = new(["b", "a"]); - - Assert.False(left.Equals(right)); - Assert.True(left != right); - Assert.NotEqual(left.GetHashCode(), right.GetHashCode()); - } - - [Fact] - public void Equals_different_length_is_false() - { - EquatableArray left = new([1, 2]); - EquatableArray right = new([1, 2, 3]); - - Assert.False(left.Equals(right)); - Assert.False(right.Equals(left)); - } - - [Fact] - public void Equals_other_type_is_false() - { - EquatableArray array = new([1]); - - Assert.False(array.Equals("not an array")); - Assert.False(array.Equals(null)); - } - - [Fact] - public void Default_value_behaves_as_the_empty_array() - { - EquatableArray defaulted = default; - - Assert.Equal(0, defaulted.Length); - Assert.True(defaulted.IsEmpty); - Assert.True(defaulted.Equals(EquatableArray.Empty)); - Assert.True(defaulted.Equals(new EquatableArray([]))); - Assert.Equal(EquatableArray.Empty.GetHashCode(), defaulted.GetHashCode()); - Assert.False(defaulted.AsImmutableArray().IsDefault); - Assert.True(defaulted.AsSpan().IsEmpty); - Assert.Empty(defaulted); - } - - [Fact] - public void Equals_null_elements_are_compared_without_throwing() - { - // The constraint asks for non-nullable elements; a null that slips through must still not crash the pipeline. - EquatableArray left = new(["a", null!]); - EquatableArray right = new(["a", null!]); - EquatableArray other = new([null!, "a"]); - - Assert.True(left.Equals(right)); - Assert.False(left.Equals(other)); - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - } - - [Fact] - public void Indexer_length_span_and_enumeration_expose_the_elements_in_order() - { - ImmutableArray source = [3, 1, 2]; - EquatableArray array = source; - - Assert.Equal(3, array.Length); - Assert.False(array.IsEmpty); - Assert.Equal(1, array[1]); - Assert.Equal([3, 1, 2], array.AsSpan().ToArray()); - Assert.Equal(source, array.AsImmutableArray()); - - List enumerated = []; - foreach (var item in array) enumerated.Add(item); - - Assert.Equal([3, 1, 2], enumerated); - Assert.Equal([3, 1, 2], array.ToList()); - } - - [Fact] - public void Record_holding_an_array_gets_value_equality() - { - // The reason the type exists: a record with an ImmutableArray member would compare by reference here. - Holder left = new("name", new EquatableArray(["x", "y"])); - Holder right = new("name", new EquatableArray(["x", "y"])); - Holder different = new("name", new EquatableArray(["x", "z"])); - - Assert.Equal(left, right); - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - Assert.NotEqual(left, different); - } - - [Fact] - public void Nested_arrays_compare_by_value() - { - EquatableArray> left = new([new EquatableArray([1, 2]), new EquatableArray([3])]); - EquatableArray> - right = new([new EquatableArray([1, 2]), new EquatableArray([3])]); - - Assert.True(left.Equals(right)); - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - } - - private sealed record Holder(string Name, EquatableArray Items); + [Fact] + public void Equals_same_elements_in_different_arrays_is_true() + { + EquatableArray left = new(["a", "b", "c"]); + EquatableArray right = new(["a", "b", "c"]); + + Assert.True(left.Equals(right)); + Assert.True(left == right); + Assert.False(left != right); + Assert.True(left.Equals((object) right)); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void Equals_different_order_is_false() + { + EquatableArray left = new(["a", "b"]); + EquatableArray right = new(["b", "a"]); + + Assert.False(left.Equals(right)); + Assert.True(left != right); + Assert.NotEqual(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void Equals_different_length_is_false() + { + EquatableArray left = new([1, 2]); + EquatableArray right = new([1, 2, 3]); + + Assert.False(left.Equals(right)); + Assert.False(right.Equals(left)); + } + + [Fact] + public void Equals_other_type_is_false() + { + EquatableArray array = new([1]); + + Assert.False(array.Equals("not an array")); + Assert.False(array.Equals(null)); + } + + [Fact] + public void Default_value_behaves_as_the_empty_array() + { + EquatableArray defaulted = default; + + Assert.Equal(0, defaulted.Length); + Assert.True(defaulted.IsEmpty); + Assert.True(defaulted.Equals(EquatableArray.Empty)); + Assert.True(defaulted.Equals(new EquatableArray([]))); + Assert.Equal(EquatableArray.Empty.GetHashCode(), defaulted.GetHashCode()); + Assert.False(defaulted.AsImmutableArray().IsDefault); + Assert.True(defaulted.AsSpan().IsEmpty); + Assert.Empty(defaulted); + } + + [Fact] + public void Equals_null_elements_are_compared_without_throwing() + { + // The constraint asks for non-nullable elements; a null that slips through must still not crash the pipeline. + EquatableArray left = new(["a", null!]); + EquatableArray right = new(["a", null!]); + EquatableArray other = new([null!, "a"]); + + Assert.True(left.Equals(right)); + Assert.False(left.Equals(other)); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void Indexer_length_span_and_enumeration_expose_the_elements_in_order() + { + ImmutableArray source = [3, 1, 2]; + EquatableArray array = source; + + Assert.Equal(3, array.Length); + Assert.False(array.IsEmpty); + Assert.Equal(1, array[1]); + Assert.Equal([3, 1, 2], array.AsSpan().ToArray()); + Assert.Equal(source, array.AsImmutableArray()); + + List enumerated = []; + foreach (int item in array) + { + enumerated.Add(item); + } + + Assert.Equal([3, 1, 2], enumerated); + Assert.Equal([3, 1, 2], array.ToList()); + } + + [Fact] + public void Record_holding_an_array_gets_value_equality() + { + // The reason the type exists: a record with an ImmutableArray member would compare by reference here. + Holder left = new("name", new EquatableArray(["x", "y"])); + Holder right = new("name", new EquatableArray(["x", "y"])); + Holder different = new("name", new EquatableArray(["x", "z"])); + + Assert.Equal(left, right); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + Assert.NotEqual(left, different); + } + + [Fact] + public void Nested_arrays_compare_by_value() + { + EquatableArray> left = new([new EquatableArray([1, 2]), new EquatableArray([3])]); + EquatableArray> + right = new([new EquatableArray([1, 2]), new EquatableArray([3])]); + + Assert.True(left.Equals(right)); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + private sealed record Holder(string Name, EquatableArray Items); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/GeneratedCodeTextTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/GeneratedCodeTextTests.cs index 688b54e4..2eb508c0 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/GeneratedCodeTextTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/GeneratedCodeTextTests.cs @@ -1,47 +1,49 @@ +using System.Reflection; + using CheatEngine.SDK.SourceGenerators.Shared; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; public sealed class GeneratedCodeTextTests { - [Fact] - public void WriteFileHeader_starts_with_the_auto_generated_marker_then_enables_nullable() - { - SourceWriter writer = new(); - - GeneratedCodeText.WriteFileHeader(writer); - - Assert.Equal("// \n#nullable enable\n\n", writer.ToString()); - } - - [Fact] - public void CreateGeneratedCodeAttribute_names_the_generator_assembly_and_its_assembly_version() - { - var generator = typeof(EntryPointGenerator).Assembly.GetName(); - - var attribute = GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(EntryPointGenerator)); - - Assert.Equal( - $"[global::System.CodeDom.Compiler.GeneratedCode(\"CheatEngine.SDK.SourceGenerators.EntryPoint\", \"{generator.Version}\")]", - attribute); - } - - [Fact] - public void CreateGeneratedCodeAttribute_version_is_stable_across_commits() - { - // MinVer keeps the ASSEMBLY version at major.0.0.0; only that keeps consumers' generated files from - // changing with every build of the SDK. - var version = typeof(EntryPointGenerator).Assembly.GetName().Version!; - - Assert.Equal(0, version.Minor); - Assert.Equal(0, version.Build); - Assert.Equal(0, version.Revision); - } - - [Fact] - public void Null_arguments_throw() - { - Assert.Throws(() => GeneratedCodeText.WriteFileHeader(null!)); - Assert.Throws(() => GeneratedCodeText.CreateGeneratedCodeAttribute(null!)); - } + [Fact] + public void WriteFileHeader_starts_with_the_auto_generated_marker_then_enables_nullable() + { + SourceWriter writer = new(); + + GeneratedCodeText.WriteFileHeader(writer); + + Assert.Equal("// \n#nullable enable\n\n", writer.ToString()); + } + + [Fact] + public void CreateGeneratedCodeAttribute_names_the_generator_assembly_and_its_assembly_version() + { + AssemblyName generator = typeof(EntryPointGenerator).Assembly.GetName(); + + string attribute = GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(EntryPointGenerator)); + + Assert.Equal( + $"[global::System.CodeDom.Compiler.GeneratedCode(\"CheatEngine.SDK.SourceGenerators.EntryPoint\", \"{generator.Version}\")]", + attribute); + } + + [Fact] + public void CreateGeneratedCodeAttribute_version_is_stable_across_commits() + { + // MinVer keeps the ASSEMBLY version at major.0.0.0; only that keeps consumers' generated files from + // changing with every build of the SDK. + Version version = typeof(EntryPointGenerator).Assembly.GetName().Version!; + + Assert.Equal(0, version.Minor); + Assert.Equal(0, version.Build); + Assert.Equal(0, version.Revision); + } + + [Fact] + public void Null_arguments_throw() + { + Assert.Throws(() => GeneratedCodeText.WriteFileHeader(null!)); + Assert.Throws(() => GeneratedCodeText.CreateGeneratedCodeAttribute(null!)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/SourceWriterTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/SourceWriterTests.cs index b44a3673..0cc05862 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/SourceWriterTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/SourceWriterTests.cs @@ -1,208 +1,211 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.Shared; +using Microsoft.CodeAnalysis.Text; + namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; public sealed class SourceWriterTests { - [Fact] - public void WriteLine_at_level_zero_writes_text_and_lf() - { - SourceWriter writer = new(); - - writer.WriteLine("class C;"); - - Assert.Equal("class C;\n", writer.ToString()); - Assert.Equal(9, writer.Length); - } - - [Fact] - public void Blocks_indent_by_four_spaces_per_level() - { - SourceWriter writer = new(); - - writer.WriteLine("namespace N"); - writer.OpenBlock(); - writer.WriteLine("class C"); - writer.OpenBlock(); - writer.WriteLine("int _f;"); - writer.CloseBlock(); - writer.CloseBlock(); - - Assert.Equal("namespace N\n{\n class C\n {\n int _f;\n }\n}\n", writer.ToString()); - Assert.Equal(0, writer.IndentLevel); - } - - [Fact] - public void Empty_line_inside_a_block_carries_no_trailing_whitespace() - { - SourceWriter writer = new(); - - writer.OpenBlock(); - writer.WriteLine("a;"); - writer.WriteLine(); - writer.WriteLine("b;"); - writer.CloseBlock(); - - Assert.Equal("{\n a;\n\n b;\n}\n", writer.ToString()); - } - - [Fact] - public void Write_fragments_indent_only_the_start_of_the_line() - { - SourceWriter writer = new(); - - writer.Indent(); - writer.Write("return "); - writer.Write("42"); - writer.Write(';'); - writer.WriteLine(); - - Assert.Equal(" return 42;\n", writer.ToString()); - } - - [Fact] - public void Write_multi_line_text_reindents_every_line_and_normalises_crlf() - { - SourceWriter writer = new(); - - writer.Indent(); - writer.Write("if (x)\r\n{\r\n y();\n\n}"); - writer.WriteLine(); - - Assert.Equal(" if (x)\n {\n y();\n\n }\n", writer.ToString()); - } - - [Fact] - public void Write_lone_carriage_return_is_a_line_break_like_for_the_csharp_lexer() - { - // A lone CR ends a line for the C# lexer: dropping it would glue "// comment\rcode();" into one comment, - // keeping it would put a second kind of line ending into the output. - SourceWriter writer = new(); - - writer.Indent(); - writer.Write("// comment\rcode();\r\rdone();"); - - Assert.Equal(" // comment\n code();\n\n done();", writer.ToString()); - } - - [Fact] - public void Write_crlf_split_across_two_writes_is_one_line_break() - { - SourceWriter writer = new(); - - writer.Write("a\r"); - writer.Write("\nb"); - writer.Write('\r'); - writer.Write('\n'); - writer.Write("c"); - - Assert.Equal("a\nb\nc", writer.ToString()); - } - - [Fact] - public void Write_carriage_return_character_leaves_no_indentation_behind() - { - SourceWriter writer = new(); - - writer.Indent(); - writer.Write('\r'); - writer.Write("x"); - - Assert.Equal("\n x", writer.ToString()); - } - - [Fact] - public void WriteLine_after_text_ending_in_carriage_return_adds_its_own_line_break() - { - // Same result as for text ending in '\n': the text ended a line, the call ends another one. The explicit - // call also forgets the pending CR, so a following '\n' is a line break again. - SourceWriter writer = new(); - - writer.Write("a\r"); - writer.WriteLine(); - writer.Write("\nb"); - - Assert.Equal("a\n\n\nb", writer.ToString()); - } - - [Fact] - public void Output_never_contains_a_carriage_return() - { - SourceWriter writer = new(); - - writer.WriteLine("a\r\nb\rc\n\rd\r"); - writer.Write('\r'); - - Assert.DoesNotContain('\r', writer.ToString()); - } - - [Fact] - public void Write_newline_character_ends_the_line() - { - SourceWriter writer = new(); - - writer.Indent(); - writer.Write('a'); - writer.Write('\n'); - writer.Write('b'); - - Assert.Equal(" a\n b", writer.ToString()); - } - - [Fact] - public void CloseBlock_with_suffix_appends_it_after_the_brace() - { - SourceWriter writer = new(); - - writer.WriteLine("int[] values ="); - writer.OpenBlock(); - writer.WriteLine("1,"); - writer.CloseBlock(";"); - - Assert.Equal("int[] values =\n{\n 1,\n};\n", writer.ToString()); - } - - [Fact] - public void Unindent_below_zero_throws() - { - SourceWriter writer = new(); - - Assert.Throws(writer.Unindent); - Assert.Throws(writer.CloseBlock); - } - - [Fact] - public void Write_null_text_throws() - { - SourceWriter writer = new(); - - Assert.Throws(() => writer.Write(null!)); - } - - [Fact] - public void Clear_resets_text_and_indentation() - { - SourceWriter writer = new(); - writer.OpenBlock(); - writer.Write("pending"); - - writer.Clear(); - writer.WriteLine("fresh"); - - Assert.Equal("fresh\n", writer.ToString()); - Assert.Equal(0, writer.IndentLevel); - } - - [Fact] - public void ToSourceText_is_utf8_and_round_trips_the_text() - { - SourceWriter writer = new(); - writer.WriteLine("// caf\u00E9"); - - var text = writer.ToSourceText(); - - Assert.Equal(Encoding.UTF8, text.Encoding); - Assert.Equal(writer.ToString(), text.ToString()); - } + [Fact] + public void WriteLine_at_level_zero_writes_text_and_lf() + { + SourceWriter writer = new(); + + writer.WriteLine("class C;"); + + Assert.Equal("class C;\n", writer.ToString()); + Assert.Equal(9, writer.Length); + } + + [Fact] + public void Blocks_indent_by_four_spaces_per_level() + { + SourceWriter writer = new(); + + writer.WriteLine("namespace N"); + writer.OpenBlock(); + writer.WriteLine("class C"); + writer.OpenBlock(); + writer.WriteLine("int _f;"); + writer.CloseBlock(); + writer.CloseBlock(); + + Assert.Equal("namespace N\n{\n class C\n {\n int _f;\n }\n}\n", writer.ToString()); + Assert.Equal(0, writer.IndentLevel); + } + + [Fact] + public void Empty_line_inside_a_block_carries_no_trailing_whitespace() + { + SourceWriter writer = new(); + + writer.OpenBlock(); + writer.WriteLine("a;"); + writer.WriteLine(); + writer.WriteLine("b;"); + writer.CloseBlock(); + + Assert.Equal("{\n a;\n\n b;\n}\n", writer.ToString()); + } + + [Fact] + public void Write_fragments_indent_only_the_start_of_the_line() + { + SourceWriter writer = new(); + + writer.Indent(); + writer.Write("return "); + writer.Write("42"); + writer.Write(';'); + writer.WriteLine(); + + Assert.Equal(" return 42;\n", writer.ToString()); + } + + [Fact] + public void Write_multi_line_text_reindents_every_line_and_normalises_crlf() + { + SourceWriter writer = new(); + + writer.Indent(); + writer.Write("if (x)\r\n{\r\n y();\n\n}"); + writer.WriteLine(); + + Assert.Equal(" if (x)\n {\n y();\n\n }\n", writer.ToString()); + } + + [Fact] + public void Write_lone_carriage_return_is_a_line_break_like_for_the_csharp_lexer() + { + // A lone CR ends a line for the C# lexer: dropping it would glue "// comment\rcode();" into one comment, + // keeping it would put a second kind of line ending into the output. + SourceWriter writer = new(); + + writer.Indent(); + writer.Write("// comment\rcode();\r\rdone();"); + + Assert.Equal(" // comment\n code();\n\n done();", writer.ToString()); + } + + [Fact] + public void Write_crlf_split_across_two_writes_is_one_line_break() + { + SourceWriter writer = new(); + + writer.Write("a\r"); + writer.Write("\nb"); + writer.Write('\r'); + writer.Write('\n'); + writer.Write("c"); + + Assert.Equal("a\nb\nc", writer.ToString()); + } + + [Fact] + public void Write_carriage_return_character_leaves_no_indentation_behind() + { + SourceWriter writer = new(); + + writer.Indent(); + writer.Write('\r'); + writer.Write("x"); + + Assert.Equal("\n x", writer.ToString()); + } + + [Fact] + public void WriteLine_after_text_ending_in_carriage_return_adds_its_own_line_break() + { + // Same result as for text ending in '\n': the text ended a line, the call ends another one. The explicit + // call also forgets the pending CR, so a following '\n' is a line break again. + SourceWriter writer = new(); + + writer.Write("a\r"); + writer.WriteLine(); + writer.Write("\nb"); + + Assert.Equal("a\n\n\nb", writer.ToString()); + } + + [Fact] + public void Output_never_contains_a_carriage_return() + { + SourceWriter writer = new(); + + writer.WriteLine("a\r\nb\rc\n\rd\r"); + writer.Write('\r'); + + Assert.DoesNotContain('\r', writer.ToString()); + } + + [Fact] + public void Write_newline_character_ends_the_line() + { + SourceWriter writer = new(); + + writer.Indent(); + writer.Write('a'); + writer.Write('\n'); + writer.Write('b'); + + Assert.Equal(" a\n b", writer.ToString()); + } + + [Fact] + public void CloseBlock_with_suffix_appends_it_after_the_brace() + { + SourceWriter writer = new(); + + writer.WriteLine("int[] values ="); + writer.OpenBlock(); + writer.WriteLine("1,"); + writer.CloseBlock(";"); + + Assert.Equal("int[] values =\n{\n 1,\n};\n", writer.ToString()); + } + + [Fact] + public void Unindent_below_zero_throws() + { + SourceWriter writer = new(); + + Assert.Throws(writer.Unindent); + Assert.Throws(writer.CloseBlock); + } + + [Fact] + public void Write_null_text_throws() + { + SourceWriter writer = new(); + + Assert.Throws(() => writer.Write(null!)); + } + + [Fact] + public void Clear_resets_text_and_indentation() + { + SourceWriter writer = new(); + writer.OpenBlock(); + writer.Write("pending"); + + writer.Clear(); + writer.WriteLine("fresh"); + + Assert.Equal("fresh\n", writer.ToString()); + Assert.Equal(0, writer.IndentLevel); + } + + [Fact] + public void ToSourceText_is_utf8_and_round_trips_the_text() + { + SourceWriter writer = new(); + writer.WriteLine("// caf\u00E9"); + + SourceText text = writer.ToSourceText(); + + Assert.Equal(Encoding.UTF8, text.Encoding); + Assert.Equal(writer.ToString(), text.ToString()); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/TrackingNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/TrackingNamesTests.cs index 4b74117f..cec68c31 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/TrackingNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/SharedCode/TrackingNamesTests.cs @@ -4,26 +4,26 @@ namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.SharedCode; public sealed class TrackingNamesTests { - [Theory] - [InlineData("CheatEngine.SDK.EntryPoint.Plugin", true)] - [InlineData("CheatEngine.SDK.", true)] - [InlineData("cheatengine.sdk.EntryPoint.Plugin", false)] - [InlineData("SourceOutput", false)] - [InlineData("", false)] - [InlineData(null, false)] - public void IsCheatEngineSdkStep_recognises_the_prefix_ordinally(string? stepName, bool expected) - { - Assert.Equal(expected, TrackingNames.IsCheatEngineSdkStep(stepName)); - } + [Theory] + [InlineData("CheatEngine.SDK.EntryPoint.Plugin", true)] + [InlineData("CheatEngine.SDK.", true)] + [InlineData("cheatengine.sdk.EntryPoint.Plugin", false)] + [InlineData("SourceOutput", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsCheatEngineSdkStep_recognises_the_prefix_ordinally(string? stepName, bool expected) + { + Assert.Equal(expected, TrackingNames.IsCheatEngineSdkStep(stepName)); + } - [Fact] - public void Entry_point_step_names_follow_the_convention_and_are_unique() - { - Assert.Equal(8, EntryPointTrackingNames.All.Length); - Assert.Equal(EntryPointTrackingNames.All.Length, - EntryPointTrackingNames.All.Distinct(StringComparer.Ordinal).Count()); - Assert.All( - EntryPointTrackingNames.All, - static name => Assert.StartsWith(TrackingNames.Prefix + "EntryPoint.", name, StringComparison.Ordinal)); - } + [Fact] + public void Entry_point_step_names_follow_the_convention_and_are_unique() + { + Assert.Equal(8, EntryPointTrackingNames.All.Length); + Assert.Equal(EntryPointTrackingNames.All.Length, + EntryPointTrackingNames.All.Distinct(StringComparer.Ordinal).Count()); + Assert.All( + EntryPointTrackingNames.All, + static name => Assert.StartsWith(TrackingNames.Prefix + "EntryPoint.", name, StringComparison.Ordinal)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs index 12a807a2..59b728f0 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Registration; using CheatEngine.SDK.Lua.State; @@ -15,284 +17,288 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; [Trait("Category", "NativeLua")] public sealed class LuaFunctionEndToEndTests(RoslynFixture roslyn) : IClassFixture { - private const string SuiteType = "Demo.Suite"; - - [Fact] - public void Registered_functions_are_callable_from_lua_with_marshalled_values() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var assembly = LoadSuite(roslyn); - - Register(assembly, L); - - Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); - Assert.Equal("hello, Lua", LuaTest.RunForString(L, "return greet('Lua')"u8)); - Assert.Equal("abc", LuaTest.RunForString(L, "return echo('abc')"u8)); - Assert.Equal("2.5", LuaTest.RunForString(L, "return tostring(half(5))"u8)); - Assert.Equal("true", LuaTest.RunForString(L, "return tostring(negate(false))"u8)); - Assert.Equal(0x1004, LuaTest.RunForInteger(L, "return step(0x1000)"u8)); - Assert.Equal(7, LuaTest.RunForInteger(L, "return small(7)"u8)); - Assert.Equal("3.0", LuaTest.RunForString(L, "return tostring(scale(1.5))"u8)); - Assert.Equal("yes", LuaTest.RunForString(L, "return maybe(true)"u8)); - Assert.Equal("nil", LuaTest.RunForString(L, "return tostring(maybe(false))"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Void_function_returns_no_value_and_runs_the_target() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var assembly = LoadSuite(roslyn); - Register(assembly, L); - - Assert.Equal(0, LuaTest.RunForInteger(L, "return select('#', ping())"u8)); - LuaTest.Run(L, "ping() ping()"u8); - - var pings = assembly.Assembly.GetType(SuiteType, true)!.GetField("Pings")!; - Assert.Equal(3, (int)pings.GetValue(null)!); - } - - [Fact] - public void State_parameter_receives_the_callback_state() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Register(LoadSuite(roslyn), L); - - // IsInteger(LuaState L, double value) asks the state whether argument 1 is an integer subtype: only the - // state Lua passed can answer that, and the state is not counted as a Lua argument. - Assert.Equal("true", LuaTest.RunForString(L, "return tostring(isint(3))"u8)); - Assert.Equal("false", LuaTest.RunForString(L, "return tostring(isint(3.5))"u8)); - } - - [Fact] - public void Wrong_argument_kind_is_a_catchable_lua_error_naming_the_argument_and_the_received_type() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Register(LoadSuite(roslyn), L); - - // Not tail calls on purpose: 'error(message, 2)' blames the caller of the thunk, which a tail call would erase. - Assert.Equal("test:1: bad argument #1 (integer expected, got string)", - LuaTest.RunForError(L, "return pcall(function() add('x', 2) end)"u8)); - Assert.Equal("test:1: bad argument #2 (integer expected, got nil)", - LuaTest.RunForError(L, "return pcall(function() add(1, nil) end)"u8)); - Assert.Equal("test:1: bad argument #1 (string expected, got number)", - LuaTest.RunForError(L, "return pcall(function() greet(42) end)"u8)); - Assert.Equal("test:1: bad argument #1 (boolean expected, got nil)", - LuaTest.RunForError(L, "return pcall(function() negate(nil) end)"u8)); - Assert.Equal("test:1: bad argument #1 (integer expected, got number)", - LuaTest.RunForError(L, "return pcall(function() small(2.5) end)"u8)); - Assert.Equal("test:1: bad argument #1 (integer expected, got number)", - LuaTest.RunForError(L, "return pcall(function() small(2^40) end)"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Wrong_argument_count_is_a_catchable_lua_error_naming_the_function() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Register(LoadSuite(roslyn), L); - - Assert.Equal("test:1: wrong number of arguments to 'add' (2 expected)", - LuaTest.RunForError(L, "return pcall(function() add(1) end)"u8)); - Assert.Equal("test:1: wrong number of arguments to 'add' (2 expected)", - LuaTest.RunForError(L, "return pcall(function() add(1, 2, 3) end)"u8)); - Assert.Equal("test:1: wrong number of arguments to 'ping' (0 expected)", - LuaTest.RunForError(L, "return pcall(function() ping(1) end)"u8)); - Assert.Equal("test:1: wrong number of arguments to 'isint' (1 expected)", - LuaTest.RunForError(L, "return pcall(function() isint() end)"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Throwing_target_is_a_catchable_lua_error_carrying_the_exception() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Register(LoadSuite(roslyn), L); - - Assert.Equal("test:1: System.InvalidOperationException: managed boom", - LuaTest.RunForError(L, "return pcall(function() boom() end)"u8)); - - // The state is intact afterwards: the next call works. - Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Unregister_removes_the_globals_and_registration_can_run_again() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var assembly = LoadSuite(roslyn); - Register(assembly, L); - Assert.Equal("function", LuaTest.RunForString(L, "return type(add)"u8)); - - var unregistered = Invoke(assembly, "UnregisterLuaFunctions", L); - - Assert.True(unregistered.IsOk); - Assert.Equal(0, L.Top); - Assert.Equal("nil", LuaTest.RunForString(L, "return type(add)"u8)); - Assert.Equal("nil", LuaTest.RunForString(L, "return type(greet)"u8)); - - Register(assembly, L); - Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); - } - - [Fact] - public void Lease_registration_preserves_a_later_global_replacement_and_is_idempotent() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var assembly = LoadSuite(roslyn); - var registration = (LuaRegistrationResult)assembly.Method(SuiteType, "TryRegisterLuaFunctions", - [typeof(LuaState), typeof(LuaRegistrationCollisionPolicy)]).Invoke(null, - [L, LuaRegistrationCollisionPolicy.RejectExisting])!; - var lease = Assert.IsType(registration.Lease); - - Assert.True(registration.IsSuccess); - Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); - LuaTest.Run(L, "add = function() return 99 end"u8); - var released = lease.ReleaseWithOutcome(L); - - Assert.Equal(LuaRegistrationReleaseKind.Released, released.Kind); - Assert.Equal(1, released.ReplacementCount); - Assert.Equal(99, LuaTest.RunForInteger(L, "return add()"u8)); - Assert.Equal(LuaRegistrationReleaseKind.AlreadyReleased, lease.ReleaseWithOutcome(L).Kind); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Registration_failure_follows_the_status_protocol() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var assembly = LoadSuite(roslyn); - - // A globals table whose __newindex raises: the first TrySetGlobal fails, the error value is on top. - LuaTest.Run(L, "setmetatable(_G, { __newindex = function(t, k, v) error('sealed: ' .. k) end })"u8); - var status = Invoke(assembly, "RegisterLuaFunctions", L); - - Assert.Equal(LuaStatus.RuntimeError, status); - Assert.Equal(1, L.Top); - Assert.Contains("sealed: add", LuaTest.ReadString(L, -1), StringComparison.Ordinal); - } - - [Fact] - public void Retained_generated_closure_is_rejected_after_disable_and_reenable() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - var assembly = LoadSuite(roslyn); - - using (new RuntimeScope(state)) - { - Register(assembly, L); - LuaTest.Run(L, "savedAdd = add"u8); - } - - using (new RuntimeScope(state)) - { - Register(assembly, L); - - var message = LuaTest.RunForError(L, "return pcall(function() return savedAdd(1, 2) end)"u8); - Assert.Contains("Lua function registration has expired", message, StringComparison.Ordinal); - Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); - } - } - - [Fact] - public void Nil_is_rejected_for_a_nullable_string_argument_like_any_other_string_argument() - { - // Documented, deliberate behavior, not a gap: CheatEngine.SDK.Lua's string reads are strict (TryReadUtf8 checks - // LUA_TSTRING; nil is "not a string" regardless of the C# parameter's nullable annotation), and a thunk uses - // the same marshaller for 'string' and 'string?' arguments (StringMarshaller.TryRead does not consult - // IsNullable - only the emitted local's declared type does, and that is always 'string?' for flow purposes). - // A nullable string parameter therefore rejects nil exactly like a non-nullable one: 'nil' means "no value", - // not "the null string", uniformly with how a [LuaGlobal] Try form treats a nil result as call failure. - const string Source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public static partial class NullableArgs - { - [LuaFunction("describe")] - public static string Describe(string? name) => name is null ? "nobody" : "hello, " + name; - } - """; - - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var assembly = GeneratedAssembly.Load(roslyn.Run(Source)); - var registered = (LuaStatus)assembly.Method("Demo.NullableArgs", "RegisterLuaFunctions").Invoke(null, [L])!; - Assert.True(registered.IsOk); - - Assert.Equal("hello, Lua", LuaTest.RunForString(L, "return describe('Lua')"u8)); - Assert.Equal("test:1: bad argument #1 (string expected, got nil)", - LuaTest.RunForError(L, "return pcall(function() describe(nil) end)"u8)); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Calling_a_thunk_from_lua_allocates_nothing_on_the_managed_side() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - Register(LoadSuite(roslyn), L); - LuaTest.Run(L, "function loop() local s = 0 for i = 1, 100 do s = add(s, i) end return s end"u8); - - AllocationGate.AssertZero(() => - { - using LuaFrame frame = new(L); - var status = L.TryGetGlobal("loop"u8); - if (!status.IsOk || !L.TryCall(0, 1).IsOk || !L.TryReadInteger(-1, out var sum) || sum != 5050) - throw new InvalidOperationException("the loop did not run"); - }); - } - - private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) - { - return GeneratedAssembly.Load(roslyn.Run(BindingSources.FunctionSuite)); - } - - private static void Register(GeneratedAssembly assembly, LuaState L) - { - var status = Invoke(assembly, "RegisterLuaFunctions", L); - Assert.True(status.IsOk, - "Registration failed: " + (status.IsOk ? string.Empty : LuaError.FromStack(L, status).ToString())); - Assert.Equal(0, L.Top); - } - - private static LuaStatus Invoke(GeneratedAssembly assembly, string methodName, LuaState L) - { - return (LuaStatus)assembly.Method(SuiteType, methodName).Invoke(null, [L])!; - } + private const string SuiteType = "Demo.Suite"; + + [Fact] + public void Registered_functions_are_callable_from_lua_with_marshalled_values() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + + Register(assembly, L); + + Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); + Assert.Equal("hello, Lua", LuaTest.RunForString(L, "return greet('Lua')"u8)); + Assert.Equal("abc", LuaTest.RunForString(L, "return echo('abc')"u8)); + Assert.Equal("2.5", LuaTest.RunForString(L, "return tostring(half(5))"u8)); + Assert.Equal("true", LuaTest.RunForString(L, "return tostring(negate(false))"u8)); + Assert.Equal(0x1004, LuaTest.RunForInteger(L, "return step(0x1000)"u8)); + Assert.Equal(7, LuaTest.RunForInteger(L, "return small(7)"u8)); + Assert.Equal("3.0", LuaTest.RunForString(L, "return tostring(scale(1.5))"u8)); + Assert.Equal("yes", LuaTest.RunForString(L, "return maybe(true)"u8)); + Assert.Equal("nil", LuaTest.RunForString(L, "return tostring(maybe(false))"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Void_function_returns_no_value_and_runs_the_target() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + Register(assembly, L); + + Assert.Equal(0, LuaTest.RunForInteger(L, "return select('#', ping())"u8)); + LuaTest.Run(L, "ping() ping()"u8); + + FieldInfo pings = assembly.Assembly.GetType(SuiteType, true)!.GetField("Pings")!; + Assert.Equal(3, (int) pings.GetValue(null)!); + } + + [Fact] + public void State_parameter_receives_the_callback_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Register(LoadSuite(roslyn), L); + + // IsInteger(LuaState L, double value) asks the state whether argument 1 is an integer subtype: only the + // state Lua passed can answer that, and the state is not counted as a Lua argument. + Assert.Equal("true", LuaTest.RunForString(L, "return tostring(isint(3))"u8)); + Assert.Equal("false", LuaTest.RunForString(L, "return tostring(isint(3.5))"u8)); + } + + [Fact] + public void Wrong_argument_kind_is_a_catchable_lua_error_naming_the_argument_and_the_received_type() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Register(LoadSuite(roslyn), L); + + // Not tail calls on purpose: 'error(message, 2)' blames the caller of the thunk, which a tail call would erase. + Assert.Equal("test:1: bad argument #1 (integer expected, got string)", + LuaTest.RunForError(L, "return pcall(function() add('x', 2) end)"u8)); + Assert.Equal("test:1: bad argument #2 (integer expected, got nil)", + LuaTest.RunForError(L, "return pcall(function() add(1, nil) end)"u8)); + Assert.Equal("test:1: bad argument #1 (string expected, got number)", + LuaTest.RunForError(L, "return pcall(function() greet(42) end)"u8)); + Assert.Equal("test:1: bad argument #1 (boolean expected, got nil)", + LuaTest.RunForError(L, "return pcall(function() negate(nil) end)"u8)); + Assert.Equal("test:1: bad argument #1 (integer expected, got number)", + LuaTest.RunForError(L, "return pcall(function() small(2.5) end)"u8)); + Assert.Equal("test:1: bad argument #1 (integer expected, got number)", + LuaTest.RunForError(L, "return pcall(function() small(2^40) end)"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Wrong_argument_count_is_a_catchable_lua_error_naming_the_function() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Register(LoadSuite(roslyn), L); + + Assert.Equal("test:1: wrong number of arguments to 'add' (2 expected)", + LuaTest.RunForError(L, "return pcall(function() add(1) end)"u8)); + Assert.Equal("test:1: wrong number of arguments to 'add' (2 expected)", + LuaTest.RunForError(L, "return pcall(function() add(1, 2, 3) end)"u8)); + Assert.Equal("test:1: wrong number of arguments to 'ping' (0 expected)", + LuaTest.RunForError(L, "return pcall(function() ping(1) end)"u8)); + Assert.Equal("test:1: wrong number of arguments to 'isint' (1 expected)", + LuaTest.RunForError(L, "return pcall(function() isint() end)"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Throwing_target_is_a_catchable_lua_error_carrying_the_exception() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Register(LoadSuite(roslyn), L); + + Assert.Equal("test:1: System.InvalidOperationException: managed boom", + LuaTest.RunForError(L, "return pcall(function() boom() end)"u8)); + + // The state is intact afterwards: the next call works. + Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Unregister_removes_the_globals_and_registration_can_run_again() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + Register(assembly, L); + Assert.Equal("function", LuaTest.RunForString(L, "return type(add)"u8)); + + LuaStatus unregistered = Invoke(assembly, "UnregisterLuaFunctions", L); + + Assert.True(unregistered.IsOk); + Assert.Equal(0, L.Top); + Assert.Equal("nil", LuaTest.RunForString(L, "return type(add)"u8)); + Assert.Equal("nil", LuaTest.RunForString(L, "return type(greet)"u8)); + + Register(assembly, L); + Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); + } + + [Fact] + public void Lease_registration_preserves_a_later_global_replacement_and_is_idempotent() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + LuaRegistrationResult registration = (LuaRegistrationResult) assembly.Method(SuiteType, + "TryRegisterLuaFunctions", + [typeof(LuaState), typeof(LuaRegistrationCollisionPolicy)]).Invoke(null, + [L, LuaRegistrationCollisionPolicy.RejectExisting])!; + LuaRegistrationLease lease = Assert.IsType(registration.Lease); + + Assert.True(registration.IsSuccess); + Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); + LuaTest.Run(L, "add = function() return 99 end"u8); + LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(L); + + Assert.Equal(LuaRegistrationReleaseKind.Released, released.Kind); + Assert.Equal(1, released.ReplacementCount); + Assert.Equal(99, LuaTest.RunForInteger(L, "return add()"u8)); + Assert.Equal(LuaRegistrationReleaseKind.AlreadyReleased, lease.ReleaseWithOutcome(L).Kind); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Registration_failure_follows_the_status_protocol() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + + // A globals table whose __newindex raises: the first TrySetGlobal fails, the error value is on top. + LuaTest.Run(L, "setmetatable(_G, { __newindex = function(t, k, v) error('sealed: ' .. k) end })"u8); + LuaStatus status = Invoke(assembly, "RegisterLuaFunctions", L); + + Assert.Equal(LuaStatus.RuntimeError, status); + Assert.Equal(1, L.Top); + Assert.Contains("sealed: add", LuaTest.ReadString(L, -1), StringComparison.Ordinal); + } + + [Fact] + public void Retained_generated_closure_is_rejected_after_disable_and_reenable() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + + using (new RuntimeScope(state)) + { + Register(assembly, L); + LuaTest.Run(L, "savedAdd = add"u8); + } + + using (new RuntimeScope(state)) + { + Register(assembly, L); + + string message = LuaTest.RunForError(L, "return pcall(function() return savedAdd(1, 2) end)"u8); + Assert.Contains("Lua function registration has expired", message, StringComparison.Ordinal); + Assert.Equal(3, LuaTest.RunForInteger(L, "return add(1, 2)"u8)); + } + } + + [Fact] + public void Nil_is_rejected_for_a_nullable_string_argument_like_any_other_string_argument() + { + // Documented, deliberate behavior, not a gap: CheatEngine.SDK.Lua's string reads are strict (TryReadUtf8 checks + // LUA_TSTRING; nil is "not a string" regardless of the C# parameter's nullable annotation), and a thunk uses + // the same marshaller for 'string' and 'string?' arguments (StringMarshaller.TryRead does not consult + // IsNullable - only the emitted local's declared type does, and that is always 'string?' for flow purposes). + // A nullable string parameter therefore rejects nil exactly like a non-nullable one: 'nil' means "no value", + // not "the null string", uniformly with how a [LuaGlobal] Try form treats a nil result as call failure. + const string Source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class NullableArgs + { + [LuaFunction("describe")] + public static string Describe(string? name) => name is null ? "nobody" : "hello, " + name; + } + """; + + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = GeneratedAssembly.Load(roslyn.Run(Source)); + LuaStatus registered = + (LuaStatus) assembly.Method("Demo.NullableArgs", "RegisterLuaFunctions").Invoke(null, [L])!; + Assert.True(registered.IsOk); + + Assert.Equal("hello, Lua", LuaTest.RunForString(L, "return describe('Lua')"u8)); + Assert.Equal("test:1: bad argument #1 (string expected, got nil)", + LuaTest.RunForError(L, "return pcall(function() describe(nil) end)"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Calling_a_thunk_from_lua_allocates_nothing_on_the_managed_side() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Register(LoadSuite(roslyn), L); + LuaTest.Run(L, "function loop() local s = 0 for i = 1, 100 do s = add(s, i) end return s end"u8); + + AllocationGate.AssertZero(() => + { + using LuaFrame frame = new(L); + LuaStatus status = L.TryGetGlobal("loop"u8); + if (!status.IsOk || !L.TryCall(0, 1).IsOk || !L.TryReadInteger(-1, out long sum) || sum != 5050) + { + throw new InvalidOperationException("the loop did not run"); + } + }); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(BindingSources.FunctionSuite)); + } + + private static void Register(GeneratedAssembly assembly, LuaState L) + { + LuaStatus status = Invoke(assembly, "RegisterLuaFunctions", L); + Assert.True(status.IsOk, + "Registration failed: " + (status.IsOk ? string.Empty : LuaError.FromStack(L, status).ToString())); + Assert.Equal(0, L.Top); + } + + private static LuaStatus Invoke(GeneratedAssembly assembly, string methodName, LuaState L) + { + return (LuaStatus) assembly.Method(SuiteType, methodName).Invoke(null, [L])!; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalEndToEndTests.cs index 1a342b37..b0a69c5a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalEndToEndTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalEndToEndTests.cs @@ -1,4 +1,5 @@ using System.Text; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -17,379 +18,392 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; [Trait("Category", "NativeLua")] public sealed class LuaGlobalEndToEndTests(RoslynFixture roslyn) : IClassFixture { - private const string BindingsType = "Demo.Bindings"; - - private static ReadOnlySpan StandIns => """ - local memory = { [0x1000] = 42, [0x1004] = -7, [0x1008] = 0x100000000, [0x100C] = 2.5, [0x2000] = 'hello, world' } - function readInteger(address) - if address == 0xDEAD then error('access violation') end - return memory[address] - end - function readString(address, maxLength) - local s = memory[address] - if type(s) ~= 'string' then return nil end - return string.sub(s, 1, maxLength) - end - beeps = 0 - function beep() beeps = beeps + 1 end - function isKeyPressed(key) return key == 13 end - function divide(a, b) - if b == 0 then return nil end - return a // b, a % b - end - function describe(value, flag) return 'v=' .. tostring(value) .. ' f=' .. tostring(flag), value * 2 end - function add(a, b) return a + b end - function upper(s) if s == nil then return nil end return string.upper(s) end - """u8; - - [Fact] - public void Try_form_reads_values_and_reports_nil_wrong_kind_and_raising_global_as_false() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); - - Assert.True(tryRead(0x1000, out var value)); - Assert.Equal(42, value); - Assert.True(tryRead(0x1004, out var negative)); - Assert.Equal(-7, negative); - - Assert.False(tryRead(0x2000, out var text)); // a string, not an integer - Assert.Equal(0, text); - Assert.False(tryRead(0x3000, out var missing)); // nil - Assert.Equal(0, missing); - Assert.False(tryRead(0x1008, out _)); // does not fit 32 bits - Assert.False(tryRead(0x100C, out _)); // 2.5 - Assert.False(tryRead(0xDEAD, out var raised)); // error('access violation') - Assert.Equal(0, raised); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Outcome_form_preserves_the_factual_lua_cause_without_reading_error_text() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - var detailed = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32Detailed"); - - LuaOperationStatus missing; - using (RuntimeScope missingScope = new(state)) - { - missing = detailed(0x1000, out _); - } - - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - LuaOperationStatus[] actual = - [ - missing, - detailed(0xDEAD, out _), - detailed(0x3000, out _), - detailed(0x100C, out _), - detailed(0x1000, out var value), - ]; - LuaOperationStatusKind[] expected = - [ - LuaOperationStatusKind.GlobalUnavailable, - LuaOperationStatusKind.LuaFailure, - LuaOperationStatusKind.NilResult, - LuaOperationStatusKind.InvalidResult, - LuaOperationStatusKind.Success, - ]; - - for (var i = 0; i < actual.Length; i++) Assert.Equal(expected[i], actual[i].Kind); - - Assert.Equal(LuaStatus.RuntimeError, actual[1].LuaStatus); - Assert.Equal(42, value); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Outcome_void_form_preserves_resolution_call_failure_and_success() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - var detailed = LoadSuite(roslyn).Delegate(BindingsType, "BeepDetailed"); - - LuaOperationStatus missing; - using (RuntimeScope missingScope = new(state)) - { - missing = detailed(); - } - - LuaOperationStatus success; - using (RuntimeScope successScope = new(state)) - { - LuaTest.Run(L, StandIns); - success = detailed(); - Assert.Equal(1, LuaTest.RunForInteger(L, "return beeps"u8)); - } - - LuaOperationStatus failure; - using (RuntimeScope failureScope = new(state)) - { - LuaTest.Run(L, "function beep() error('beep failed') end"u8); - failure = detailed(); - } - - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, missing.Kind); - Assert.Equal(LuaOperationStatusKind.Success, success.Kind); - Assert.Equal(LuaOperationStatusKind.LuaFailure, failure.Kind); - Assert.Equal(LuaStatus.RuntimeError, failure.LuaStatus); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Throwing_form_returns_the_value_and_throws_once_per_exit_with_the_lua_message() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - var read = LoadSuite(roslyn).Delegate(BindingsType, "ReadInt32"); - - // Exit 1: the global is not defined yet. - var unresolved = Assert.Throws(() => read(0x1000)); - Assert.Equal("The Lua global 'readInteger' is undefined or is not a function.", unresolved.Message); - Assert.Equal(0, L.Top); - - LuaTest.Run(L, StandIns); - Assert.Equal(42, read(0x1000)); - Assert.Equal(-7, read(0x1004)); - - // Exit 2: the call raised. - var raised = Assert.Throws(() => read(0xDEAD)); - Assert.Equal(LuaStatus.RuntimeError, raised.Status); - Assert.Contains("access violation", raised.Message, StringComparison.Ordinal); - - // Exit 3: nil, or not an integer. - Assert.Equal("The Lua global 'readInteger' returned a nil value, not an integer.", - Assert.Throws(() => read(0x3000)).Message); - Assert.Equal("The Lua global 'readInteger' returned a number value, not an integer.", - Assert.Throws(() => read(0x100C)).Message); - Assert.Equal("The Lua global 'readInteger' returned a string value, not an integer.", - Assert.Throws(() => read(0x2000)).Message); - Assert.Equal(0, L.Top); - } - - [Fact] - public void String_results_come_back_copied_out_as_string_and_as_return_value() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var copyOut = assembly.Delegate(BindingsType, "TryReadString"); - var asString = assembly.Delegate(BindingsType, "TryReadString"); - var throwing = assembly.Delegate(BindingsType, "ReadString"); - - Span buffer = stackalloc byte[32]; - Assert.True(copyOut(0x2000, 5, buffer, out var written)); - Assert.Equal("hello", Encoding.UTF8.GetString(buffer[..written])); - Assert.False(copyOut(0x2000, 100, buffer[..3], out var tooSmall)); // 12 bytes do not fit 3 - Assert.Equal(0, tooSmall); - Assert.False(copyOut(0x1000, 5, buffer, out _)); // a number is not a string - Assert.False(copyOut(0x3000, 5, buffer, out _)); // nil - - Assert.True(asString(0x2000, 12, out var text)); - Assert.Equal("hello, world", text); - Assert.False(asString(0x3000, 12, out var missing)); - Assert.Null(missing); - - Assert.Equal("hello, w", throwing(0x2000, 8)); - Assert.Equal("The Lua global 'readString' returned a nil value, not a string.", - Assert.Throws(() => throwing(0x3000, 8)).Message); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Void_and_boolean_throwing_forms_run_the_global() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var beep = assembly.Delegate(BindingsType, "Beep"); - var isKeyPressed = assembly.Delegate(BindingsType, "IsKeyPressed"); - - beep(); - beep(); - Assert.Equal(2, LuaTest.RunForInteger(L, "return beeps"u8)); - Assert.True(isKeyPressed(13)); - Assert.False(isKeyPressed(27)); - Assert.Equal(0, L.Top); - - // A boolean wrapper is strict: a nil result is not false, it is an unexpected result. - LuaTest.Run(L, "function isKeyPressed(key) return nil end"u8); - using (new RuntimeScope(state)) - { - // A fresh epoch, so the cached function is resolved again and sees the redefinition. - Assert.Equal("The Lua global 'isKeyPressed' returned a nil value, not a boolean.", - Assert.Throws(() => isKeyPressed(13)).Message); - } - } - - [Fact] - public void Several_results_are_read_and_all_defaulted_together_on_failure() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var divide = assembly.Delegate(BindingsType, "TryDivide"); - var describe = assembly.Delegate(BindingsType, "TryDescribe"); - - Assert.True(divide(17, 5, out var quotient, out var remainder)); - Assert.Equal(3, quotient); - Assert.Equal(2, remainder); - - // divide(17, 0) returns a single nil: quotient fails to read, and the remainder is defaulted with it. - Assert.False(divide(17, 0, out var noQuotient, out var noRemainder)); - Assert.Equal(0, noQuotient); - Assert.Equal(0, noRemainder); - - Assert.True(describe(1.5, true, out var text, out var doubled)); - Assert.Equal("v=1.5 f=true", text); - Assert.Equal(3.0, doubled); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Leading_state_parameter_is_used_instead_of_the_runtime() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var add = assembly.Delegate(BindingsType, "AddOn"); - var tryAdd = assembly.Delegate(BindingsType, "TryAddOn"); - - // The runtime must be attached for the epoch-checked cache, but the state comes from the argument. - using RuntimeScope scope = new(state); - Assert.Equal(5, add(L, 2, 3)); - Assert.True(tryAdd(L, 40, 2, out var sum)); - Assert.Equal(42, sum); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Utf8_and_nullable_string_arguments_are_pushed() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var assembly = LoadSuite(roslyn); - var upper = assembly.Delegate(BindingsType, "Upper"); - var upperOrNull = assembly.Delegate(BindingsType, "UpperOrNull"); - - Assert.Equal("ABC", upper("abc"u8)); - Assert.Equal("XYZ", upperOrNull("xyz")); - - // null is pushed as nil; the stand-in returns nil, which the string? wrapper still reports as unexpected. - Assert.Equal("The Lua global 'upper' returned a nil value, not a string.", - Assert.Throws(() => upperOrNull(null)).Message); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Wrappers_throw_while_the_runtime_is_detached() - { - LuaTest.RequireNativeLua(); - LuaRuntime.Detach(); - var tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); - - Assert.Throws(() => tryRead(0x1000, out _)); - } - - [Fact] - public void Warm_try_form_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); - long sink = 0; - - AllocationGate.AssertZero(() => - { - if (!tryRead(0x1000, out var value) || value != 42) throw new InvalidOperationException("wrong value"); - - if (tryRead(0x3000, out _)) throw new InvalidOperationException("unexpected value"); - - sink += value; - }); - - Assert.NotEqual(0, sink); - Assert.Equal(0, L.Top); - } - - [Fact] - public void Warm_copy_out_string_form_allocates_nothing() - { - LuaTest.RequireNativeLua(); - using NativeLuaState state = new(); - var L = LuaTest.View(state); - using RuntimeScope scope = new(state); - LuaTest.Run(L, StandIns); - var copyOut = LoadSuite(roslyn).Delegate(BindingsType, "TryReadString"); - var buffer = new byte[32]; - - AllocationGate.AssertZero(() => - { - if (!copyOut(0x2000, 5, buffer, out var written) || written != 5) - throw new InvalidOperationException("wrong value"); - }); - } - - private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) - { - return GeneratedAssembly.Load(roslyn.Run(BindingSources.GlobalSuite)); - } - - private delegate bool TryReadInt32Delegate(nuint address, out int value); - - private delegate LuaOperationStatus TryReadInt32DetailedDelegate(nuint address, out int value); - - private delegate LuaOperationStatus BeepDetailedDelegate(); - - private delegate int ReadInt32Delegate(nuint address); - - private delegate bool TryReadStringCopyDelegate(nuint address, int maxLength, Span destination, - out int written); - - private delegate bool TryReadStringDelegate(nuint address, int maxLength, out string? value); - - private delegate string ReadStringDelegate(nuint address, int maxLength); - - private delegate void BeepDelegate(); - - private delegate bool IsKeyPressedDelegate(int key); - - private delegate bool TryDivideDelegate(long dividend, long divisor, out long quotient, out long remainder); + private const string BindingsType = "Demo.Bindings"; + + private static ReadOnlySpan StandIns => """ + local memory = { [0x1000] = 42, [0x1004] = -7, [0x1008] = 0x100000000, [0x100C] = 2.5, [0x2000] = 'hello, world' } + function readInteger(address) + if address == 0xDEAD then error('access violation') end + return memory[address] + end + function readString(address, maxLength) + local s = memory[address] + if type(s) ~= 'string' then return nil end + return string.sub(s, 1, maxLength) + end + beeps = 0 + function beep() beeps = beeps + 1 end + function isKeyPressed(key) return key == 13 end + function divide(a, b) + if b == 0 then return nil end + return a // b, a % b + end + function describe(value, flag) return 'v=' .. tostring(value) .. ' f=' .. tostring(flag), value * 2 end + function add(a, b) return a + b end + function upper(s) if s == nil then return nil end return string.upper(s) end + """u8; + + [Fact] + public void Try_form_reads_values_and_reports_nil_wrong_kind_and_raising_global_as_false() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + TryReadInt32Delegate tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); + + Assert.True(tryRead(0x1000, out int value)); + Assert.Equal(42, value); + Assert.True(tryRead(0x1004, out int negative)); + Assert.Equal(-7, negative); + + Assert.False(tryRead(0x2000, out int text)); // a string, not an integer + Assert.Equal(0, text); + Assert.False(tryRead(0x3000, out int missing)); // nil + Assert.Equal(0, missing); + Assert.False(tryRead(0x1008, out _)); // does not fit 32 bits + Assert.False(tryRead(0x100C, out _)); // 2.5 + Assert.False(tryRead(0xDEAD, out int raised)); // error('access violation') + Assert.Equal(0, raised); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Outcome_form_preserves_the_factual_lua_cause_without_reading_error_text() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + TryReadInt32DetailedDelegate detailed = + LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32Detailed"); + + LuaOperationStatus missing; + using (RuntimeScope missingScope = new(state)) + { + missing = detailed(0x1000, out _); + } + + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + LuaOperationStatus[] actual = + [ + missing, + detailed(0xDEAD, out _), + detailed(0x3000, out _), + detailed(0x100C, out _), + detailed(0x1000, out int value) + ]; + LuaOperationStatusKind[] expected = + [ + LuaOperationStatusKind.GlobalUnavailable, + LuaOperationStatusKind.LuaFailure, + LuaOperationStatusKind.NilResult, + LuaOperationStatusKind.InvalidResult, + LuaOperationStatusKind.Success + ]; + + for (int i = 0; i < actual.Length; i++) + { + Assert.Equal(expected[i], actual[i].Kind); + } + + Assert.Equal(LuaStatus.RuntimeError, actual[1].LuaStatus); + Assert.Equal(42, value); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Outcome_void_form_preserves_resolution_call_failure_and_success() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + BeepDetailedDelegate detailed = LoadSuite(roslyn).Delegate(BindingsType, "BeepDetailed"); + + LuaOperationStatus missing; + using (RuntimeScope missingScope = new(state)) + { + missing = detailed(); + } + + LuaOperationStatus success; + using (RuntimeScope successScope = new(state)) + { + LuaTest.Run(L, StandIns); + success = detailed(); + Assert.Equal(1, LuaTest.RunForInteger(L, "return beeps"u8)); + } + + LuaOperationStatus failure; + using (RuntimeScope failureScope = new(state)) + { + LuaTest.Run(L, "function beep() error('beep failed') end"u8); + failure = detailed(); + } + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, missing.Kind); + Assert.Equal(LuaOperationStatusKind.Success, success.Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, failure.Kind); + Assert.Equal(LuaStatus.RuntimeError, failure.LuaStatus); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Throwing_form_returns_the_value_and_throws_once_per_exit_with_the_lua_message() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + ReadInt32Delegate read = LoadSuite(roslyn).Delegate(BindingsType, "ReadInt32"); + + // Exit 1: the global is not defined yet. + LuaException unresolved = Assert.Throws(() => read(0x1000)); + Assert.Equal("The Lua global 'readInteger' is undefined or is not a function.", unresolved.Message); + Assert.Equal(0, L.Top); + + LuaTest.Run(L, StandIns); + Assert.Equal(42, read(0x1000)); + Assert.Equal(-7, read(0x1004)); + + // Exit 2: the call raised. + LuaException raised = Assert.Throws(() => read(0xDEAD)); + Assert.Equal(LuaStatus.RuntimeError, raised.Status); + Assert.Contains("access violation", raised.Message, StringComparison.Ordinal); + + // Exit 3: nil, or not an integer. + Assert.Equal("The Lua global 'readInteger' returned a nil value, not an integer.", + Assert.Throws(() => read(0x3000)).Message); + Assert.Equal("The Lua global 'readInteger' returned a number value, not an integer.", + Assert.Throws(() => read(0x100C)).Message); + Assert.Equal("The Lua global 'readInteger' returned a string value, not an integer.", + Assert.Throws(() => read(0x2000)).Message); + Assert.Equal(0, L.Top); + } + + [Fact] + public void String_results_come_back_copied_out_as_string_and_as_return_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryReadStringCopyDelegate copyOut = assembly.Delegate(BindingsType, "TryReadString"); + TryReadStringDelegate asString = assembly.Delegate(BindingsType, "TryReadString"); + ReadStringDelegate throwing = assembly.Delegate(BindingsType, "ReadString"); + + Span buffer = stackalloc byte[32]; + Assert.True(copyOut(0x2000, 5, buffer, out int written)); + Assert.Equal("hello", Encoding.UTF8.GetString(buffer[..written])); + Assert.False(copyOut(0x2000, 100, buffer[..3], out int tooSmall)); // 12 bytes do not fit 3 + Assert.Equal(0, tooSmall); + Assert.False(copyOut(0x1000, 5, buffer, out _)); // a number is not a string + Assert.False(copyOut(0x3000, 5, buffer, out _)); // nil + + Assert.True(asString(0x2000, 12, out string? text)); + Assert.Equal("hello, world", text); + Assert.False(asString(0x3000, 12, out string? missing)); + Assert.Null(missing); + + Assert.Equal("hello, w", throwing(0x2000, 8)); + Assert.Equal("The Lua global 'readString' returned a nil value, not a string.", + Assert.Throws(() => throwing(0x3000, 8)).Message); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Void_and_boolean_throwing_forms_run_the_global() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + BeepDelegate beep = assembly.Delegate(BindingsType, "Beep"); + IsKeyPressedDelegate isKeyPressed = assembly.Delegate(BindingsType, "IsKeyPressed"); + + beep(); + beep(); + Assert.Equal(2, LuaTest.RunForInteger(L, "return beeps"u8)); + Assert.True(isKeyPressed(13)); + Assert.False(isKeyPressed(27)); + Assert.Equal(0, L.Top); + + // A boolean wrapper is strict: a nil result is not false, it is an unexpected result. + LuaTest.Run(L, "function isKeyPressed(key) return nil end"u8); + using (new RuntimeScope(state)) + { + // A fresh epoch, so the cached function is resolved again and sees the redefinition. + Assert.Equal("The Lua global 'isKeyPressed' returned a nil value, not a boolean.", + Assert.Throws(() => isKeyPressed(13)).Message); + } + } + + [Fact] + public void Several_results_are_read_and_all_defaulted_together_on_failure() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryDivideDelegate divide = assembly.Delegate(BindingsType, "TryDivide"); + TryDescribeDelegate describe = assembly.Delegate(BindingsType, "TryDescribe"); + + Assert.True(divide(17, 5, out long quotient, out long remainder)); + Assert.Equal(3, quotient); + Assert.Equal(2, remainder); + + // divide(17, 0) returns a single nil: quotient fails to read, and the remainder is defaulted with it. + Assert.False(divide(17, 0, out long noQuotient, out long noRemainder)); + Assert.Equal(0, noQuotient); + Assert.Equal(0, noRemainder); + + Assert.True(describe(1.5, true, out string? text, out double doubled)); + Assert.Equal("v=1.5 f=true", text); + Assert.Equal(3.0, doubled); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Leading_state_parameter_is_used_instead_of_the_runtime() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + AddOnDelegate add = assembly.Delegate(BindingsType, "AddOn"); + TryAddOnDelegate tryAdd = assembly.Delegate(BindingsType, "TryAddOn"); + + // The runtime must be attached for the epoch-checked cache, but the state comes from the argument. + using RuntimeScope scope = new(state); + Assert.Equal(5, add(L, 2, 3)); + Assert.True(tryAdd(L, 40, 2, out long sum)); + Assert.Equal(42, sum); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Utf8_and_nullable_string_arguments_are_pushed() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + UpperDelegate upper = assembly.Delegate(BindingsType, "Upper"); + UpperOrNullDelegate upperOrNull = assembly.Delegate(BindingsType, "UpperOrNull"); + + Assert.Equal("ABC", upper("abc"u8)); + Assert.Equal("XYZ", upperOrNull("xyz")); + + // null is pushed as nil; the stand-in returns nil, which the string? wrapper still reports as unexpected. + Assert.Equal("The Lua global 'upper' returned a nil value, not a string.", + Assert.Throws(() => upperOrNull(null)).Message); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Wrappers_throw_while_the_runtime_is_detached() + { + LuaTest.RequireNativeLua(); + LuaRuntime.Detach(); + TryReadInt32Delegate tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); + + Assert.Throws(() => tryRead(0x1000, out _)); + } + + [Fact] + public void Warm_try_form_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + TryReadInt32Delegate tryRead = LoadSuite(roslyn).Delegate(BindingsType, "TryReadInt32"); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!tryRead(0x1000, out int value) || value != 42) + { + throw new InvalidOperationException("wrong value"); + } + + if (tryRead(0x3000, out _)) + { + throw new InvalidOperationException("unexpected value"); + } + + sink += value; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Warm_copy_out_string_form_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + TryReadStringCopyDelegate copyOut = + LoadSuite(roslyn).Delegate(BindingsType, "TryReadString"); + byte[] buffer = new byte[32]; + + AllocationGate.AssertZero(() => + { + if (!copyOut(0x2000, 5, buffer, out int written) || written != 5) + { + throw new InvalidOperationException("wrong value"); + } + }); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(BindingSources.GlobalSuite)); + } + + private delegate bool TryReadInt32Delegate(nuint address, out int value); + + private delegate LuaOperationStatus TryReadInt32DetailedDelegate(nuint address, out int value); + + private delegate LuaOperationStatus BeepDetailedDelegate(); + + private delegate int ReadInt32Delegate(nuint address); + + private delegate bool TryReadStringCopyDelegate(nuint address, int maxLength, Span destination, + out int written); + + private delegate bool TryReadStringDelegate(nuint address, int maxLength, out string? value); + + private delegate string ReadStringDelegate(nuint address, int maxLength); + + private delegate void BeepDelegate(); + + private delegate bool IsKeyPressedDelegate(int key); + + private delegate bool TryDivideDelegate(long dividend, long divisor, out long quotient, out long remainder); - private delegate bool TryDescribeDelegate(double value, bool flag, out string? text, out double doubled); + private delegate bool TryDescribeDelegate(double value, bool flag, out string? text, out double doubled); - private delegate long AddOnDelegate(LuaState state, long a, long b); + private delegate long AddOnDelegate(LuaState state, long a, long b); - private delegate bool TryAddOnDelegate(LuaState state, long a, long b, out long sum); + private delegate bool TryAddOnDelegate(LuaState state, long a, long b, out long sum); - private delegate string UpperDelegate(ReadOnlySpan text); + private delegate string UpperDelegate(ReadOnlySpan text); - private delegate string? UpperOrNullDelegate(string? text); + private delegate string? UpperOrNullDelegate(string? text); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs index 33c1eca0..19d9becc 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs @@ -8,224 +8,225 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class ContainingTypeTests(RoslynFixture roslyn) : IClassFixture { - private const string Usings = "using CheatEngine.SDK.Annotations.Lua;\n"; - - [Fact] - public void Generator_nested_types_reopen_every_level() - { - const string Source = Usings + """ - namespace Demo.Inner - { - public partial class Outer - { - internal static partial class Bindings - { - [LuaFunction("f")] private static int F(int a) => a; - [LuaGlobal("g")] internal static partial bool TryG(nuint a, out int v); - } - } - } - """; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Equal(["Demo.Inner.Outer.Bindings.LuaFunctions.g.cs", "Demo.Inner.Outer.Bindings.LuaGlobals.g.cs"], - run.HintNames.Order(StringComparer.Ordinal), StringComparer.Ordinal); - var functions = run.GeneratedText("Demo.Inner.Outer.Bindings.LuaFunctions.g.cs"); - Assert.Contains( - "namespace Demo.Inner\n{\n partial class Outer\n {\n partial class Bindings\n {\n", - functions, StringComparison.Ordinal); - Assert.Contains("global::Demo.Inner.Outer.Bindings.F(__arg0)", functions, StringComparison.Ordinal); - Assert.Contains("internal static partial bool TryG(nuint a, out int v)", - run.GeneratedText("Demo.Inner.Outer.Bindings.LuaGlobals.g.cs"), StringComparison.Ordinal); - } - - [Fact] - public void Generator_global_namespace_has_no_namespace_block() - { - var run = roslyn.Run(Usings + - "public static partial class Top { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - - run.AssertCompilesClean(); - var text = run.GeneratedText("Top.LuaFunctions.g.cs"); - Assert.DoesNotContain("namespace", text, StringComparison.Ordinal); - Assert.Contains("\npartial class Top\n{\n", text, StringComparison.Ordinal); - Assert.Contains("global::Top.F(__arg0)", text, StringComparison.Ordinal); - } - - [Theory] - [InlineData("public partial struct", "partial struct")] - [InlineData("public readonly partial struct", "readonly partial struct")] - [InlineData("public partial record", "partial record")] - [InlineData("public partial record class", "partial record")] - [InlineData("public partial record struct", "partial record struct")] - [InlineData("public sealed partial class", "partial class")] - [InlineData("public static partial class", "partial class")] - [InlineData("internal static partial class", "partial class")] - public void Generator_type_kinds_are_reopened_with_their_keyword(string declaration, string expectedPart) - { - var run = roslyn.Run(Usings + "namespace Demo; " + declaration + - " Holder { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); }"); - - run.AssertCompilesClean(); - Assert.Contains("\n " + expectedPart + " Holder\n {\n", - run.GeneratedText("Demo.Holder.LuaFunctions.g.cs"), StringComparison.Ordinal); - Assert.Contains("\n " + expectedPart + " Holder\n {\n", run.GeneratedText("Demo.Holder.LuaGlobals.g.cs"), - StringComparison.Ordinal); - } - - [Fact] - public void Generator_keyword_identifiers_are_escaped() - { - const string Source = Usings + """ - namespace @class.Demo - { - public static partial class @event - { - [LuaFunction("f")] public static int @string(int @int) => @int; - [LuaGlobal("g")] public static partial bool @object(nuint @base, out int @this); - } - } - """; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - var functions = run.GeneratedText("class.Demo.event.LuaFunctions.g.cs"); - Assert.Contains("namespace @class.Demo\n", functions, StringComparison.Ordinal); - Assert.Contains("partial class @event\n", functions, StringComparison.Ordinal); - Assert.Contains("global::@class.Demo.@event.@string(__arg0)", functions, StringComparison.Ordinal); - Assert.Contains("public static partial bool @object(nuint @base, out int @this)", - run.GeneratedText("class.Demo.event.LuaGlobals.g.cs"), StringComparison.Ordinal); - Assert.Contains("LuaCallSupport.Fail(__L, __top, out @this)", - run.GeneratedText("class.Demo.event.LuaGlobals.g.cs"), StringComparison.Ordinal); - } - - [Fact] - public void Generator_types_differing_only_by_case_get_distinct_hint_names() - { - // Roslyn's AdditionalSourcesCollection compares hint names case-insensitively; HintNames.ForType alone - // cannot tell "DemoType" and "demoType" apart (neither has a replaced character), so without disambiguation - // AddSource throws ArgumentException on the second one and the whole generation pass crashes. - const string Source = Usings + - "namespace Demo; public static partial class DemoType { [LuaFunction(\"f1\")] public static int F(int a) => a; } public static partial class demoType { [LuaFunction(\"f2\")] public static int F(int a) => a; }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Equal(2, run.GeneratedSources.Length); - Assert.Contains("Demo.DemoType.LuaFunctions.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.DoesNotContain("Demo.demoType.LuaFunctions.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.NotEqual(run.HintNames[0], run.HintNames[1], StringComparer.OrdinalIgnoreCase); - } - - [Fact] - public void Generator_non_ascii_type_name_gets_a_hashed_hint_name_and_an_unescaped_declaration() - { - var run = roslyn.Run(Usings + - "namespace Demo; public static partial class Caf\u00E9 { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - - run.AssertCompilesClean(); - var hintName = Assert.Single(run.HintNames); - Assert.StartsWith("Demo.Caf__", hintName, StringComparison.Ordinal); - Assert.EndsWith(".LuaFunctions.g.cs", hintName, StringComparison.Ordinal); - Assert.Contains("partial class Caf\u00E9\n", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_several_types_get_one_file_each_in_name_order() - { - const string Source = Usings + """ - namespace Demo; - public static partial class Zeta { [LuaFunction("z")] public static int Z(int a) => a; } - public static partial class Alpha { [LuaFunction("a")] public static int A(int a) => a; } - public static partial class Mid { [LuaGlobal("m")] public static partial bool TryM(nuint a, out int v); } - """; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Equal(["Demo.Alpha.LuaFunctions.g.cs", "Demo.Zeta.LuaFunctions.g.cs", "Demo.Mid.LuaGlobals.g.cs"], - run.HintNames, StringComparer.Ordinal); - } - - [Fact] - public void Generator_both_kinds_in_one_type_give_two_files_that_compile_together() - { - var run = roslyn.Run(BindingSources.Mixed); - - run.AssertCompilesClean(); - Assert.Equal(["Demo.Mixed.LuaFunctions.g.cs", "Demo.Mixed.LuaGlobals.g.cs"], - run.HintNames.Order(StringComparer.Ordinal), StringComparer.Ordinal); - } - - [Fact] - public void Generator_type_split_across_files_gets_one_file() - { - var run = roslyn.Run( - Usings + - "namespace Demo; public static partial class Split { [LuaFunction(\"a\")] public static int A(int a) => a; }", - Usings + - "namespace Demo; public static partial class Split { [LuaFunction(\"b\")] public static int B(int a) => a; }"); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains("__LuaThunk_a", text, StringComparison.Ordinal); - Assert.Contains("__LuaThunk_b", text, StringComparison.Ordinal); - } - - [Theory] - [InlineData("public static partial", "public static partial")] - [InlineData("internal static partial", "internal static partial")] - [InlineData("private static partial", "private static partial")] - [InlineData("protected internal static partial", "protected internal static partial")] - [InlineData("private protected static partial", "private protected static partial")] - [InlineData("static public partial", "public static partial")] - [InlineData("public static unsafe partial", "public static unsafe partial")] - public void Generator_repeats_the_defining_declarations_modifiers(string declared, string emitted) - { - var run = roslyn.Run(Usings + "namespace Demo; public partial class Holder { [LuaGlobal(\"g\")] " + declared + - " bool TryG(nuint a, out int v); }"); - - run.AssertCompilesClean(); - Assert.Contains("\n " + emitted + " bool TryG(nuint a, out int v)\n", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_repeats_the_new_modifier_of_a_hiding_declaration() - { - const string Source = Usings + """ - namespace Demo; - public class Base { public static bool TryG(nuint a, out int v) { v = 0; return false; } } - public partial class Holder : Base { [LuaGlobal("g")] public new static partial bool TryG(nuint a, out int v); } - """; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("\n public new static partial bool TryG(nuint a, out int v)\n", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_old_style_partial_void_without_accessibility_is_implemented_without_one() - { - var run = roslyn.Run(Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"beep\")] static partial void Beep(); }"); - - run.AssertCompilesClean(); - Assert.Contains("\n static partial void Beep()\n", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_private_target_is_reachable_from_the_thunk() - { - var run = roslyn.Run(Usings + - "namespace Demo; internal static partial class Holder { [LuaFunction(\"f\")] private static int F(int a) => a; }"); - - run.AssertCompilesClean(); - Assert.Contains("global::Demo.Holder.F(__arg0)", run.SingleGeneratedText, StringComparison.Ordinal); - } + private const string Usings = "using CheatEngine.SDK.Annotations.Lua;\n"; + + [Fact] + public void Generator_nested_types_reopen_every_level() + { + const string Source = Usings + """ + namespace Demo.Inner + { + public partial class Outer + { + internal static partial class Bindings + { + [LuaFunction("f")] private static int F(int a) => a; + [LuaGlobal("g")] internal static partial bool TryG(nuint a, out int v); + } + } + } + """; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Equal(["Demo.Inner.Outer.Bindings.LuaFunctions.g.cs", "Demo.Inner.Outer.Bindings.LuaGlobals.g.cs"], + run.HintNames.Order(StringComparer.Ordinal), StringComparer.Ordinal); + string functions = run.GeneratedText("Demo.Inner.Outer.Bindings.LuaFunctions.g.cs"); + Assert.Contains( + "namespace Demo.Inner\n{\n partial class Outer\n {\n partial class Bindings\n {\n", + functions, StringComparison.Ordinal); + Assert.Contains("global::Demo.Inner.Outer.Bindings.F(__arg0)", functions, StringComparison.Ordinal); + Assert.Contains("internal static partial bool TryG(nuint a, out int v)", + run.GeneratedText("Demo.Inner.Outer.Bindings.LuaGlobals.g.cs"), StringComparison.Ordinal); + } + + [Fact] + public void Generator_global_namespace_has_no_namespace_block() + { + GeneratorRun run = roslyn.Run(Usings + + "public static partial class Top { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + + run.AssertCompilesClean(); + string text = run.GeneratedText("Top.LuaFunctions.g.cs"); + Assert.DoesNotContain("namespace", text, StringComparison.Ordinal); + Assert.Contains("\npartial class Top\n{\n", text, StringComparison.Ordinal); + Assert.Contains("global::Top.F(__arg0)", text, StringComparison.Ordinal); + } + + [Theory] + [InlineData("public partial struct", "partial struct")] + [InlineData("public readonly partial struct", "readonly partial struct")] + [InlineData("public partial record", "partial record")] + [InlineData("public partial record class", "partial record")] + [InlineData("public partial record struct", "partial record struct")] + [InlineData("public sealed partial class", "partial class")] + [InlineData("public static partial class", "partial class")] + [InlineData("internal static partial class", "partial class")] + public void Generator_type_kinds_are_reopened_with_their_keyword(string declaration, string expectedPart) + { + GeneratorRun run = roslyn.Run(Usings + "namespace Demo; " + declaration + + " Holder { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); }"); + + run.AssertCompilesClean(); + Assert.Contains("\n " + expectedPart + " Holder\n {\n", + run.GeneratedText("Demo.Holder.LuaFunctions.g.cs"), StringComparison.Ordinal); + Assert.Contains("\n " + expectedPart + " Holder\n {\n", run.GeneratedText("Demo.Holder.LuaGlobals.g.cs"), + StringComparison.Ordinal); + } + + [Fact] + public void Generator_keyword_identifiers_are_escaped() + { + const string Source = Usings + """ + namespace @class.Demo + { + public static partial class @event + { + [LuaFunction("f")] public static int @string(int @int) => @int; + [LuaGlobal("g")] public static partial bool @object(nuint @base, out int @this); + } + } + """; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + string functions = run.GeneratedText("class.Demo.event.LuaFunctions.g.cs"); + Assert.Contains("namespace @class.Demo\n", functions, StringComparison.Ordinal); + Assert.Contains("partial class @event\n", functions, StringComparison.Ordinal); + Assert.Contains("global::@class.Demo.@event.@string(__arg0)", functions, StringComparison.Ordinal); + Assert.Contains("public static partial bool @object(nuint @base, out int @this)", + run.GeneratedText("class.Demo.event.LuaGlobals.g.cs"), StringComparison.Ordinal); + Assert.Contains("LuaCallSupport.Fail(__L, __top, out @this)", + run.GeneratedText("class.Demo.event.LuaGlobals.g.cs"), StringComparison.Ordinal); + } + + [Fact] + public void Generator_types_differing_only_by_case_get_distinct_hint_names() + { + // Roslyn's AdditionalSourcesCollection compares hint names case-insensitively; HintNames.ForType alone + // cannot tell "DemoType" and "demoType" apart (neither has a replaced character), so without disambiguation + // AddSource throws ArgumentException on the second one and the whole generation pass crashes. + const string Source = Usings + + "namespace Demo; public static partial class DemoType { [LuaFunction(\"f1\")] public static int F(int a) => a; } public static partial class demoType { [LuaFunction(\"f2\")] public static int F(int a) => a; }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Equal(2, run.GeneratedSources.Length); + Assert.Contains("Demo.DemoType.LuaFunctions.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.DoesNotContain("Demo.demoType.LuaFunctions.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.NotEqual(run.HintNames[0], run.HintNames[1], StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Generator_non_ascii_type_name_gets_a_hashed_hint_name_and_an_unescaped_declaration() + { + GeneratorRun run = roslyn.Run(Usings + + "namespace Demo; public static partial class Caf\u00E9 { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + + run.AssertCompilesClean(); + string hintName = Assert.Single(run.HintNames); + Assert.StartsWith("Demo.Caf__", hintName, StringComparison.Ordinal); + Assert.EndsWith(".LuaFunctions.g.cs", hintName, StringComparison.Ordinal); + Assert.Contains("partial class Caf\u00E9\n", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_several_types_get_one_file_each_in_name_order() + { + const string Source = Usings + """ + namespace Demo; + public static partial class Zeta { [LuaFunction("z")] public static int Z(int a) => a; } + public static partial class Alpha { [LuaFunction("a")] public static int A(int a) => a; } + public static partial class Mid { [LuaGlobal("m")] public static partial bool TryM(nuint a, out int v); } + """; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Equal(["Demo.Alpha.LuaFunctions.g.cs", "Demo.Zeta.LuaFunctions.g.cs", "Demo.Mid.LuaGlobals.g.cs"], + run.HintNames, StringComparer.Ordinal); + } + + [Fact] + public void Generator_both_kinds_in_one_type_give_two_files_that_compile_together() + { + GeneratorRun run = roslyn.Run(BindingSources.Mixed); + + run.AssertCompilesClean(); + Assert.Equal(["Demo.Mixed.LuaFunctions.g.cs", "Demo.Mixed.LuaGlobals.g.cs"], + run.HintNames.Order(StringComparer.Ordinal), StringComparer.Ordinal); + } + + [Fact] + public void Generator_type_split_across_files_gets_one_file() + { + GeneratorRun run = roslyn.Run( + Usings + + "namespace Demo; public static partial class Split { [LuaFunction(\"a\")] public static int A(int a) => a; }", + Usings + + "namespace Demo; public static partial class Split { [LuaFunction(\"b\")] public static int B(int a) => a; }"); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains("__LuaThunk_a", text, StringComparison.Ordinal); + Assert.Contains("__LuaThunk_b", text, StringComparison.Ordinal); + } + + [Theory] + [InlineData("public static partial", "public static partial")] + [InlineData("internal static partial", "internal static partial")] + [InlineData("private static partial", "private static partial")] + [InlineData("protected internal static partial", "protected internal static partial")] + [InlineData("private protected static partial", "private protected static partial")] + [InlineData("static public partial", "public static partial")] + [InlineData("public static unsafe partial", "public static unsafe partial")] + public void Generator_repeats_the_defining_declarations_modifiers(string declared, string emitted) + { + GeneratorRun run = roslyn.Run(Usings + "namespace Demo; public partial class Holder { [LuaGlobal(\"g\")] " + + declared + + " bool TryG(nuint a, out int v); }"); + + run.AssertCompilesClean(); + Assert.Contains("\n " + emitted + " bool TryG(nuint a, out int v)\n", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_repeats_the_new_modifier_of_a_hiding_declaration() + { + const string Source = Usings + """ + namespace Demo; + public class Base { public static bool TryG(nuint a, out int v) { v = 0; return false; } } + public partial class Holder : Base { [LuaGlobal("g")] public new static partial bool TryG(nuint a, out int v); } + """; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("\n public new static partial bool TryG(nuint a, out int v)\n", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_old_style_partial_void_without_accessibility_is_implemented_without_one() + { + GeneratorRun run = roslyn.Run(Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"beep\")] static partial void Beep(); }"); + + run.AssertCompilesClean(); + Assert.Contains("\n static partial void Beep()\n", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_private_target_is_reachable_from_the_thunk() + { + GeneratorRun run = roslyn.Run(Usings + + "namespace Demo; internal static partial class Holder { [LuaFunction(\"f\")] private static int F(int a) => a; }"); + + run.AssertCompilesClean(); + Assert.Contains("global::Demo.Holder.F(__arg0)", run.SingleGeneratedText, StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs index e9833efc..b6415ec7 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs @@ -1,5 +1,7 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; + using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; @@ -14,49 +16,48 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class DefaultVerifierTests { - [Fact] - public async Task Verifier_two_functions_match_expected_source_and_compile() - { - var test = CreateTest(BindingSources.Functions); - test.TestState.GeneratedSources.Add(( - typeof(LuaBindingsGenerator), - ExpectedFiles.FunctionsHintName, - SourceText.From(ExpectedFiles.Functions(), Encoding.UTF8))); - - Assert.Single(test.TestState.GeneratedSources); - await test.RunAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Verifier_two_globals_match_expected_source_and_compile() - { - var test = CreateTest(BindingSources.Globals); - test.TestState.GeneratedSources.Add(( - typeof(LuaBindingsGenerator), - ExpectedFiles.GlobalsHintName, - SourceText.From(ExpectedFiles.Globals(), Encoding.UTF8))); - - Assert.Single(test.TestState.GeneratedSources); - await test.RunAsync(TestContext.Current.CancellationToken); - } - - private static CSharpSourceGeneratorTest CreateTest(string source) - { - var environment = RoslynEnvironment.Shared; - - CSharpSourceGeneratorTest test = new() - { - // A framework moniker WITHOUT a reference-assembly package: nothing is resolved through NuGet; the - // framework and the SDK come from the local installation and this process (no network). - ReferenceAssemblies = new ReferenceAssemblies("net10.0"), - CompilerDiagnostics = CompilerDiagnostics.Warnings - }; - - // The test declarations are undocumented public members: CS1591 is theirs, not the generated file's. - test.DisabledDiagnostics.Add("CS1591"); - test.TestState.Sources.Add(source); - test.TestState.AdditionalReferences.AddRange(environment.FrameworkReferences); - test.TestState.AdditionalReferences.AddRange(environment.SdkReferences); - return test; - } + [Fact] + public async Task Verifier_two_functions_match_expected_source_and_compile() + { + CSharpSourceGeneratorTest test = CreateTest(BindingSources.Functions); + test.TestState.GeneratedSources.Add(( + typeof(LuaBindingsGenerator), + ExpectedFiles.FunctionsHintName, + SourceText.From(ExpectedFiles.Functions(), Encoding.UTF8))); + + Assert.Single(test.TestState.GeneratedSources); + await test.RunAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Verifier_two_globals_match_expected_source_and_compile() + { + CSharpSourceGeneratorTest test = CreateTest(BindingSources.Globals); + test.TestState.GeneratedSources.Add(( + typeof(LuaBindingsGenerator), + ExpectedFiles.GlobalsHintName, + SourceText.From(ExpectedFiles.Globals(), Encoding.UTF8))); + + Assert.Single(test.TestState.GeneratedSources); + await test.RunAsync(TestContext.Current.CancellationToken); + } + + private static CSharpSourceGeneratorTest CreateTest(string source) + { + RoslynEnvironment environment = RoslynEnvironment.Shared; + + CSharpSourceGeneratorTest test = new() + { + // A framework moniker WITHOUT a reference-assembly package: nothing is resolved through NuGet; the + // framework and the SDK come from the local installation and this process (no network). + ReferenceAssemblies = new ReferenceAssemblies("net10.0"), CompilerDiagnostics = CompilerDiagnostics.Warnings + }; + + // The test declarations are undocumented public members: CS1591 is theirs, not the generated file's. + test.DisabledDiagnostics.Add("CS1591"); + test.TestState.Sources.Add(source); + test.TestState.AdditionalReferences.AddRange(environment.FrameworkReferences); + test.TestState.AdditionalReferences.AddRange(environment.SdkReferences); + return test; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs index 4d9b7beb..3d61398c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs @@ -1,6 +1,10 @@ +using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; @@ -11,294 +15,303 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class IncrementalityTests(RoslynFixture roslyn) : IClassFixture { - private const string ObjectBindings = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Fixture")] - public readonly partial struct Fixture - { - [LuaMethod("getValue")] - public partial int GetValue(); - - [LuaProperty("Value")] - public partial int Value { get; } - } - """; - - [Fact] - public void Pipeline_first_run_tracks_every_named_step() - { - var run = roslyn.Run(BindingSources.Functions, BindingSources.Globals, ObjectBindings); - - foreach (var stepName in run.Result.TrackedSteps.Keys - .Where(TrackingNames.IsCheatEngineSdkStep) - .Order(StringComparer.Ordinal)) - Assert.All(StepAssert.Reasons(run.Result, stepName), - static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); - - Assert.Equal(4, StepAssert.OutputReasons(run.Result).Length); - Assert.All(StepAssert.OutputReasons(run.Result), - static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); - } - - [Fact] - public void Pipeline_unrelated_class_added_in_another_file_recomputes_nothing() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var second = GeneratorRun.Execute( - first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse("namespace Demo; public sealed class Unrelated { }", - "Unrelated.cs"))); - - StepAssert.NothingWasRecomputed(second.Result); - Assert.Equal(first.GeneratedText(ExpectedFiles.FunctionsHintName), - second.GeneratedText(ExpectedFiles.FunctionsHintName)); - Assert.Equal(first.GeneratedText(ExpectedFiles.GlobalsHintName), - second.GeneratedText(ExpectedFiles.GlobalsHintName)); - } - - [Fact] - public void Pipeline_comment_added_to_a_binding_file_recomputes_nothing() - { - // The attributed nodes are re-parsed, so both transforms run again: the steps must come out Unchanged - // because the models compare by value. - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.First(); - var edited = RoslynFixture.Parse("// an unrelated comment\n" + BindingSources.Functions + "\n// trailing", - original.FilePath); - var second = GeneratorRun.Execute(first.Driver, compilation.ReplaceSyntaxTree(original, edited)); - - StepAssert.NothingWasRecomputed(second.Result); - Assert.Contains(IncrementalStepRunReason.Unchanged, - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunction)); - } - - [Fact] - public void Pipeline_unattributed_member_added_to_a_binding_type_recomputes_nothing() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.First(); - var edited = BindingSources.Functions.Replace( - "[LuaFunction(\"add\")]", - "public static int Unrelated;\n\n [LuaFunction(\"add\")]", - StringComparison.Ordinal); - Assert.NotEqual(BindingSources.Functions, edited, StringComparer.Ordinal); - var second = GeneratorRun.Execute(first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); - - StepAssert.NothingWasRecomputed(second.Result); - } - - [Fact] - public void Pipeline_identical_compilation_recomputes_nothing() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var second = GeneratorRun.Execute(first.Driver, compilation); - - StepAssert.NothingWasRecomputed(second.Result); - } - - [Fact] - public void Pipeline_function_attribute_argument_edited_reruns_the_function_output_only() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.First(); - var edited = BindingSources.Functions.Replace("[LuaFunction(\"add\")]", "[LuaFunction(\"plus\")]", - StringComparison.Ordinal); - var second = GeneratorRun.Execute(first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); - - Assert.Contains(IncrementalStepRunReason.Modified, - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunction)); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionTables)); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput)); - AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaGlobal, LuaBindingsTrackingNames.LuaGlobalTables, - LuaBindingsTrackingNames.LuaGlobalOutput); - Assert.Contains("__LuaThunk_plus", second.GeneratedText(ExpectedFiles.FunctionsHintName), - StringComparison.Ordinal); - Assert.Equal(first.GeneratedText(ExpectedFiles.GlobalsHintName), - second.GeneratedText(ExpectedFiles.GlobalsHintName)); - } - - [Fact] - public void Pipeline_function_signature_edited_reruns_the_function_output_only() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.First(); - var edited = BindingSources.Functions.Replace("public static long Add(long a, long b)", - "public static int Add(int a, int b)", StringComparison.Ordinal); - var second = GeneratorRun.Execute(first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); - - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput)); - AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput); - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, 1, out int __arg0)", - second.GeneratedText(ExpectedFiles.FunctionsHintName), StringComparison.Ordinal); - } - - [Fact] - public void Pipeline_global_signature_edited_reruns_the_global_output_only() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var original = compilation.SyntaxTrees.Last(); - var edited = BindingSources.Globals.Replace("out int value", "out long value", StringComparison.Ordinal); - var second = GeneratorRun.Execute(first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); - - Assert.Contains(IncrementalStepRunReason.Modified, - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaGlobal)); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput)); - AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaFunction, LuaBindingsTrackingNames.LuaFunctionTables, - LuaBindingsTrackingNames.LuaFunctionOutput); - Assert.Contains("TryReadInt32(nuint address, out long value)", - second.GeneratedText(ExpectedFiles.GlobalsHintName), StringComparison.Ordinal); - } - - [Fact] - public void Pipeline_invalid_member_added_keeps_the_table_unchanged_and_the_output_cached() - { - // The collected models change, the table does not: this is what the grouping projection buys. - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var second = GeneratorRun.Execute( - first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse( - "namespace Demo; public static partial class Functions { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"bad\")] public static int Bad(object o) => 0; }", - "Bad.cs"))); - - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.CollectedLuaFunctions)); - Assert.Equal([IncrementalStepRunReason.Unchanged], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionTables)); - Assert.All(StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput), - static reason => - Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); - Assert.All(StepAssert.OutputReasons(second.Result), - static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); - Assert.Equal(first.GeneratedText(ExpectedFiles.FunctionsHintName), - second.GeneratedText(ExpectedFiles.FunctionsHintName)); - } - - [Fact] - public void Pipeline_second_binding_type_added_emits_its_file_next_to_the_first() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - - var second = GeneratorRun.Execute( - first.Driver, - compilation.AddSyntaxTrees(RoslynFixture.Parse( - "namespace Demo; public static partial class More { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"more\")] public static int M(int a) => a; }", - "More.cs"))); - - Assert.Equal(3, second.GeneratedSources.Length); - Assert.Equal(first.GeneratedText(ExpectedFiles.FunctionsHintName), - second.GeneratedText(ExpectedFiles.FunctionsHintName)); - Assert.Contains("__LuaThunk_more", second.GeneratedText("Demo.More.LuaFunctions.g.cs"), - StringComparison.Ordinal); - AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput); - } - - [Fact] - public void Pipeline_editing_one_of_two_containing_types_leaves_the_others_output_cached() - { - // Other tests either exercise one [LuaFunction] type at a time, or add the second type between runs (which - // Roslyn's SelectMany reports as a new output for every table, by design). This one covers two [LuaFunction] - // types already stable in the compilation, then an edit to only one of them. - const string Source = - "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\npublic static partial class Alpha { [LuaFunction(\"a\")] public static int A(int x) => x; }\npublic static partial class Beta { [LuaFunction(\"b\")] public static int B(int x) => x; }\n"; - var compilation = roslyn.CreateCompilation(Source); - var first = RoslynFixture.Run(compilation); - Assert.Equal(2, first.GeneratedSources.Length); - - var original = compilation.SyntaxTrees.First(); - var edited = Source.Replace("[LuaFunction(\"a\")]", "[LuaFunction(\"aa\")]", StringComparison.Ordinal); - var second = GeneratorRun.Execute(first.Driver, - compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); - - Assert.Contains("__LuaThunk_aa", second.GeneratedText("Demo.Alpha.LuaFunctions.g.cs"), - StringComparison.Ordinal); - Assert.Equal(first.GeneratedText("Demo.Beta.LuaFunctions.g.cs"), - second.GeneratedText("Demo.Beta.LuaFunctions.g.cs"), StringComparer.Ordinal); - - var outputReasons = StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput); - Assert.Equal(2, outputReasons.Length); - Assert.Contains(IncrementalStepRunReason.Modified, outputReasons); - Assert.Contains(outputReasons, - static reason => reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged); - } - - [Fact] - public void Pipeline_unsafe_switched_off_removes_only_function_output() - { - var compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); - var first = RoslynFixture.Run(compilation); - Assert.Equal(2, first.GeneratedSources.Length); - - var second = GeneratorRun.Execute(first.Driver, - compilation.WithOptions(RoslynEnvironment.SafeCompilationOptions)); - - Assert.Single(second.GeneratedSources); - Assert.Equal(ExpectedFiles.GlobalsHintName, second.HintNames[0]); - Assert.Equal([IncrementalStepRunReason.Modified], - StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.Facts)); - AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaFunctionTables, - LuaBindingsTrackingNames.LuaGlobalTables); - } - - [Fact] - public void Pipeline_step_values_hold_no_roslyn_objects() - { - var run = roslyn.Run( - BindingSources.FunctionSuite, - BindingSources.GlobalSuite, - "namespace Demo; public static partial class Broken { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"bad\")] public static int Bad(object o) => 0; [CheatEngine.SDK.Annotations.Lua.LuaGlobal(\"bad\")] public static partial bool TryBad(out object o); }", - ObjectBindings); - - var visited = 0; - foreach (var stepName in run.Result.TrackedSteps.Keys - .Where(TrackingNames.IsCheatEngineSdkStep) - .Order(StringComparer.Ordinal)) - { - Assert.True(run.Result.TrackedSteps.TryGetValue(stepName, out var steps), - $"Tracked step '{stepName}' was not present."); - - foreach (var step in steps) - foreach (var (value, _) in step.Outputs) - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); - } - - Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); - } - - private static void AssertUntouched(GeneratorRunResult result, params string[] stepNames) - { - foreach (var stepName in stepNames) - Assert.All( - StepAssert.Reasons(result, stepName), - reason => Assert.True( - reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, - $"Step '{stepName}' was recomputed: {reason}.")); - } + private const string ObjectBindings = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Fixture")] + public readonly partial struct Fixture + { + [LuaMethod("getValue")] + public partial int GetValue(); + + [LuaProperty("Value")] + public partial int Value { get; } + } + """; + + [Fact] + public void Pipeline_first_run_tracks_every_named_step() + { + GeneratorRun run = roslyn.Run(BindingSources.Functions, BindingSources.Globals, ObjectBindings); + + foreach (string stepName in run.Result.TrackedSteps.Keys + .Where(TrackingNames.IsCheatEngineSdkStep) + .Order(StringComparer.Ordinal)) + { + Assert.All(StepAssert.Reasons(run.Result, stepName), + static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + } + + Assert.Equal(4, StepAssert.OutputReasons(run.Result).Length); + Assert.All(StepAssert.OutputReasons(run.Result), + static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + } + + [Fact] + public void Pipeline_unrelated_class_added_in_another_file_recomputes_nothing() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse("namespace Demo; public sealed class Unrelated { }", + "Unrelated.cs"))); + + StepAssert.NothingWasRecomputed(second.Result); + Assert.Equal(first.GeneratedText(ExpectedFiles.FunctionsHintName), + second.GeneratedText(ExpectedFiles.FunctionsHintName)); + Assert.Equal(first.GeneratedText(ExpectedFiles.GlobalsHintName), + second.GeneratedText(ExpectedFiles.GlobalsHintName)); + } + + [Fact] + public void Pipeline_comment_added_to_a_binding_file_recomputes_nothing() + { + // The attributed nodes are re-parsed, so both transforms run again: the steps must come out Unchanged + // because the models compare by value. + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.First(); + SyntaxTree edited = RoslynFixture.Parse( + "// an unrelated comment\n" + BindingSources.Functions + "\n// trailing", + original.FilePath); + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation.ReplaceSyntaxTree(original, edited)); + + StepAssert.NothingWasRecomputed(second.Result); + Assert.Contains(IncrementalStepRunReason.Unchanged, + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunction)); + } + + [Fact] + public void Pipeline_unattributed_member_added_to_a_binding_type_recomputes_nothing() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.First(); + string edited = BindingSources.Functions.Replace( + "[LuaFunction(\"add\")]", + "public static int Unrelated;\n\n [LuaFunction(\"add\")]", + StringComparison.Ordinal); + Assert.NotEqual(BindingSources.Functions, edited, StringComparer.Ordinal); + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); + + StepAssert.NothingWasRecomputed(second.Result); + } + + [Fact] + public void Pipeline_identical_compilation_recomputes_nothing() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation); + + StepAssert.NothingWasRecomputed(second.Result); + } + + [Fact] + public void Pipeline_function_attribute_argument_edited_reruns_the_function_output_only() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.First(); + string edited = BindingSources.Functions.Replace("[LuaFunction(\"add\")]", "[LuaFunction(\"plus\")]", + StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); + + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunction)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionTables)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput)); + AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaGlobal, LuaBindingsTrackingNames.LuaGlobalTables, + LuaBindingsTrackingNames.LuaGlobalOutput); + Assert.Contains("__LuaThunk_plus", second.GeneratedText(ExpectedFiles.FunctionsHintName), + StringComparison.Ordinal); + Assert.Equal(first.GeneratedText(ExpectedFiles.GlobalsHintName), + second.GeneratedText(ExpectedFiles.GlobalsHintName)); + } + + [Fact] + public void Pipeline_function_signature_edited_reruns_the_function_output_only() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.First(); + string edited = BindingSources.Functions.Replace("public static long Add(long a, long b)", + "public static int Add(int a, int b)", StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); + + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput)); + AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, 1, out int __arg0)", + second.GeneratedText(ExpectedFiles.FunctionsHintName), StringComparison.Ordinal); + } + + [Fact] + public void Pipeline_global_signature_edited_reruns_the_global_output_only() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Last(); + string edited = BindingSources.Globals.Replace("out int value", "out long value", StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); + + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaGlobal)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput)); + AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaFunction, LuaBindingsTrackingNames.LuaFunctionTables, + LuaBindingsTrackingNames.LuaFunctionOutput); + Assert.Contains("TryReadInt32(nuint address, out long value)", + second.GeneratedText(ExpectedFiles.GlobalsHintName), StringComparison.Ordinal); + } + + [Fact] + public void Pipeline_invalid_member_added_keeps_the_table_unchanged_and_the_output_cached() + { + // The collected models change, the table does not: this is what the grouping projection buys. + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse( + "namespace Demo; public static partial class Functions { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"bad\")] public static int Bad(object o) => 0; }", + "Bad.cs"))); + + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.CollectedLuaFunctions)); + Assert.Equal([IncrementalStepRunReason.Unchanged], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionTables)); + Assert.All(StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput), + static reason => + Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); + Assert.All(StepAssert.OutputReasons(second.Result), + static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); + Assert.Equal(first.GeneratedText(ExpectedFiles.FunctionsHintName), + second.GeneratedText(ExpectedFiles.FunctionsHintName)); + } + + [Fact] + public void Pipeline_second_binding_type_added_emits_its_file_next_to_the_first() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + + GeneratorRun second = GeneratorRun.Execute( + first.Driver, + compilation.AddSyntaxTrees(RoslynFixture.Parse( + "namespace Demo; public static partial class More { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"more\")] public static int M(int a) => a; }", + "More.cs"))); + + Assert.Equal(3, second.GeneratedSources.Length); + Assert.Equal(first.GeneratedText(ExpectedFiles.FunctionsHintName), + second.GeneratedText(ExpectedFiles.FunctionsHintName)); + Assert.Contains("__LuaThunk_more", second.GeneratedText("Demo.More.LuaFunctions.g.cs"), + StringComparison.Ordinal); + AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput); + } + + [Fact] + public void Pipeline_editing_one_of_two_containing_types_leaves_the_others_output_cached() + { + // Other tests either exercise one [LuaFunction] type at a time, or add the second type between runs (which + // Roslyn's SelectMany reports as a new output for every table, by design). This one covers two [LuaFunction] + // types already stable in the compilation, then an edit to only one of them. + const string Source = + "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\npublic static partial class Alpha { [LuaFunction(\"a\")] public static int A(int x) => x; }\npublic static partial class Beta { [LuaFunction(\"b\")] public static int B(int x) => x; }\n"; + CSharpCompilation compilation = roslyn.CreateCompilation(Source); + GeneratorRun first = RoslynFixture.Run(compilation); + Assert.Equal(2, first.GeneratedSources.Length); + + SyntaxTree original = compilation.SyntaxTrees.First(); + string edited = Source.Replace("[LuaFunction(\"a\")]", "[LuaFunction(\"aa\")]", StringComparison.Ordinal); + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); + + Assert.Contains("__LuaThunk_aa", second.GeneratedText("Demo.Alpha.LuaFunctions.g.cs"), + StringComparison.Ordinal); + Assert.Equal(first.GeneratedText("Demo.Beta.LuaFunctions.g.cs"), + second.GeneratedText("Demo.Beta.LuaFunctions.g.cs"), StringComparer.Ordinal); + + ImmutableArray outputReasons = + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaFunctionOutput); + Assert.Equal(2, outputReasons.Length); + Assert.Contains(IncrementalStepRunReason.Modified, outputReasons); + Assert.Contains(outputReasons, + static reason => reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged); + } + + [Fact] + public void Pipeline_unsafe_switched_off_removes_only_function_output() + { + CSharpCompilation compilation = roslyn.CreateCompilation(BindingSources.Functions, BindingSources.Globals); + GeneratorRun first = RoslynFixture.Run(compilation); + Assert.Equal(2, first.GeneratedSources.Length); + + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.WithOptions(RoslynEnvironment.SafeCompilationOptions)); + + Assert.Single(second.GeneratedSources); + Assert.Equal(ExpectedFiles.GlobalsHintName, second.HintNames[0]); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.Facts)); + AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaFunctionTables, + LuaBindingsTrackingNames.LuaGlobalTables); + } + + [Fact] + public void Pipeline_step_values_hold_no_roslyn_objects() + { + GeneratorRun run = roslyn.Run( + BindingSources.FunctionSuite, + BindingSources.GlobalSuite, + "namespace Demo; public static partial class Broken { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"bad\")] public static int Bad(object o) => 0; [CheatEngine.SDK.Annotations.Lua.LuaGlobal(\"bad\")] public static partial bool TryBad(out object o); }", + ObjectBindings); + + int visited = 0; + foreach (string stepName in run.Result.TrackedSteps.Keys + .Where(TrackingNames.IsCheatEngineSdkStep) + .Order(StringComparer.Ordinal)) + { + Assert.True( + run.Result.TrackedSteps.TryGetValue(stepName, out ImmutableArray steps), + $"Tracked step '{stepName}' was not present."); + + foreach (IncrementalGeneratorRunStep step in steps) + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } + } + + Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); + } + + private static void AssertUntouched(GeneratorRunResult result, params string[] stepNames) + { + foreach (string stepName in stepNames) + { + Assert.All( + StepAssert.Reasons(result, stepName), + reason => Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Step '{stepName}' was recomputed: {reason}.")); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs index 5c06c854..9097b1ac 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs @@ -1,6 +1,9 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using Microsoft.CodeAnalysis; + namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// @@ -9,149 +12,149 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class LuaFunctionOutputTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Generator_two_functions_emit_exact_file() - { - var run = roslyn.Run(BindingSources.Functions); - - var generated = Assert.Single(run.GeneratedSources); - Assert.Equal(ExpectedFiles.FunctionsHintName, generated.HintName); - Assert.Equal(ExpectedFiles.Functions(), generated.SourceText.ToString()); - } - - [Fact] - public void Generator_two_functions_output_compiles_without_errors_or_warnings() - { - var run = roslyn.Run(BindingSources.Functions); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_output_is_utf8_with_lf_line_endings() - { - var run = roslyn.Run(BindingSources.Functions); - - var generated = Assert.Single(run.GeneratedSources); - Assert.Equal(Encoding.UTF8, generated.SourceText.Encoding); - Assert.DoesNotContain('\r', generated.SourceText.ToString()); - } - - [Fact] - public void Generator_function_suite_compiles_clean_and_declares_one_thunk_per_function() - { - var run = roslyn.Run(BindingSources.FunctionSuite); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - foreach (var name in new[] - { - "add", "greet", "ping", "isint", "boom", "echo", "half", "negate", "step", "small", "scale", - "maybe" - }) - { - Assert.Contains("private static int __LuaThunk_" + name + "(nint __handle)", text, - StringComparison.Ordinal); - Assert.Contains("state.TrySetGlobal(\"" + name + "\"u8);", text, StringComparison.Ordinal); - } - - // The state parameter is passed first and not counted as a Lua argument. - Assert.Contains("if (__L.Top != 1)", text, StringComparison.Ordinal); - Assert.Contains("global::Demo.Suite.IsInteger(__L, __arg0)", text, StringComparison.Ordinal); - - // void: no result pushed; ReadOnlySpan: read and pushed through the UTF-8 marshaller. - Assert.Contains("global::Demo.Suite.Ping();\n return 0;", text, StringComparison.Ordinal); - Assert.Contains( - "global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.TryRead(__L, 1, out global::System.ReadOnlySpan __arg0)", - text, - StringComparison.Ordinal); - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Push(__L, __result);", text, - StringComparison.Ordinal); - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.TryRead(__L, 1, out nuint __arg0)", - text, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_registration_is_ordered_by_lua_name() - { - var run = roslyn.Run(BindingSources.FunctionSuite); - - var text = run.SingleGeneratedText; - var add = text.IndexOf("TrySetGlobal(\"add\"u8)", StringComparison.Ordinal); - var boom = text.IndexOf("TrySetGlobal(\"boom\"u8)", StringComparison.Ordinal); - var step = text.IndexOf("TrySetGlobal(\"step\"u8)", StringComparison.Ordinal); - Assert.True(add >= 0 && add < boom && boom < step, "Registrations are not sorted by Lua name."); - } - - [Fact] - public void Generator_obsolete_target_compiles_clean() - { - const string Source = - "using System;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\npublic static partial class Holder\n{\n [Obsolete] [LuaFunction(\"f\")] public static int F(int a) => a;\n}\n"; - - var run = roslyn.Run(Source); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - Assert.Contains("#pragma warning disable CS0612, CS0618", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_experimental_target_compiles_clean() - { - const string Source = - "using System.Diagnostics.CodeAnalysis;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\npublic static partial class Holder\n{\n [Experimental(\"DEMO001\")] [LuaFunction(\"f\")] public static int F(int a) => a;\n}\n"; - - var run = roslyn.Run(Source); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - Assert.Contains("#pragma warning disable DEMO001", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_experimental_containing_type_compiles_clean() - { - const string Source = - "using System.Diagnostics.CodeAnalysis;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\n[Experimental(\"DEMO002\")]\npublic static partial class Holder\n{\n [LuaFunction(\"f\")] public static int F(int a) => a;\n}\n"; - - var run = roslyn.Run(Source); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - Assert.Contains("#pragma warning disable DEMO002", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_target_parameters_named_like_generated_thunk_locals_do_not_collide() - { - // Unlike a [LuaGlobal] implementing declaration (PartialMethodSignatureTests), a thunk never spells the - // target's own parameter names: it calls positionally (__arg0, __arg1, ...), so the target is free to name - // its parameters anything, including names that look like the thunk's own locals. - const string Source = - "using CheatEngine.SDK.Annotations.Lua;\nusing CheatEngine.SDK.Lua.State;\nnamespace Demo;\npublic static partial class Holder\n{\n [LuaFunction(\"f\")] public static int F(LuaState L, int top, int result, int __arg0, int __handle) => L.IsInteger(1) ? top + result + __arg0 + __handle : 0;\n}\n"; - - var run = roslyn.Run(Source); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - Assert.Contains("global::Demo.Holder.F(__L, __arg0, __arg1, __arg2, __arg3)", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_any_input_reports_no_diagnostics() - { - var valid = roslyn.Run(BindingSources.Functions); - var invalid = - roslyn.Run(BindingSources.Functions.Replace("public static long Add", "public long Add", - StringComparison.Ordinal)); - - Assert.Empty(valid.GeneratorDiagnostics); - Assert.Empty(valid.Result.Diagnostics); - Assert.Empty(invalid.GeneratorDiagnostics); - Assert.Empty(invalid.Result.Diagnostics); - } + [Fact] + public void Generator_two_functions_emit_exact_file() + { + GeneratorRun run = roslyn.Run(BindingSources.Functions); + + GeneratedSourceResult generated = Assert.Single(run.GeneratedSources); + Assert.Equal(ExpectedFiles.FunctionsHintName, generated.HintName); + Assert.Equal(ExpectedFiles.Functions(), generated.SourceText.ToString()); + } + + [Fact] + public void Generator_two_functions_output_compiles_without_errors_or_warnings() + { + GeneratorRun run = roslyn.Run(BindingSources.Functions); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_output_is_utf8_with_lf_line_endings() + { + GeneratorRun run = roslyn.Run(BindingSources.Functions); + + GeneratedSourceResult generated = Assert.Single(run.GeneratedSources); + Assert.Equal(Encoding.UTF8, generated.SourceText.Encoding); + Assert.DoesNotContain('\r', generated.SourceText.ToString()); + } + + [Fact] + public void Generator_function_suite_compiles_clean_and_declares_one_thunk_per_function() + { + GeneratorRun run = roslyn.Run(BindingSources.FunctionSuite); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + foreach (string name in new[] + { + "add", "greet", "ping", "isint", "boom", "echo", "half", "negate", "step", "small", "scale", + "maybe" + }) + { + Assert.Contains("private static int __LuaThunk_" + name + "(nint __handle)", text, + StringComparison.Ordinal); + Assert.Contains("state.TrySetGlobal(\"" + name + "\"u8);", text, StringComparison.Ordinal); + } + + // The state parameter is passed first and not counted as a Lua argument. + Assert.Contains("if (__L.Top != 1)", text, StringComparison.Ordinal); + Assert.Contains("global::Demo.Suite.IsInteger(__L, __arg0)", text, StringComparison.Ordinal); + + // void: no result pushed; ReadOnlySpan: read and pushed through the UTF-8 marshaller. + Assert.Contains("global::Demo.Suite.Ping();\n return 0;", text, StringComparison.Ordinal); + Assert.Contains( + "global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.TryRead(__L, 1, out global::System.ReadOnlySpan __arg0)", + text, + StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Push(__L, __result);", text, + StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.TryRead(__L, 1, out nuint __arg0)", + text, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_registration_is_ordered_by_lua_name() + { + GeneratorRun run = roslyn.Run(BindingSources.FunctionSuite); + + string text = run.SingleGeneratedText; + int add = text.IndexOf("TrySetGlobal(\"add\"u8)", StringComparison.Ordinal); + int boom = text.IndexOf("TrySetGlobal(\"boom\"u8)", StringComparison.Ordinal); + int step = text.IndexOf("TrySetGlobal(\"step\"u8)", StringComparison.Ordinal); + Assert.True(add >= 0 && add < boom && boom < step, "Registrations are not sorted by Lua name."); + } + + [Fact] + public void Generator_obsolete_target_compiles_clean() + { + const string Source = + "using System;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\npublic static partial class Holder\n{\n [Obsolete] [LuaFunction(\"f\")] public static int F(int a) => a;\n}\n"; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + Assert.Contains("#pragma warning disable CS0612, CS0618", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_experimental_target_compiles_clean() + { + const string Source = + "using System.Diagnostics.CodeAnalysis;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\npublic static partial class Holder\n{\n [Experimental(\"DEMO001\")] [LuaFunction(\"f\")] public static int F(int a) => a;\n}\n"; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + Assert.Contains("#pragma warning disable DEMO001", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_experimental_containing_type_compiles_clean() + { + const string Source = + "using System.Diagnostics.CodeAnalysis;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\n[Experimental(\"DEMO002\")]\npublic static partial class Holder\n{\n [LuaFunction(\"f\")] public static int F(int a) => a;\n}\n"; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + Assert.Contains("#pragma warning disable DEMO002", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_target_parameters_named_like_generated_thunk_locals_do_not_collide() + { + // Unlike a [LuaGlobal] implementing declaration (PartialMethodSignatureTests), a thunk never spells the + // target's own parameter names: it calls positionally (__arg0, __arg1, ...), so the target is free to name + // its parameters anything, including names that look like the thunk's own locals. + const string Source = + "using CheatEngine.SDK.Annotations.Lua;\nusing CheatEngine.SDK.Lua.State;\nnamespace Demo;\npublic static partial class Holder\n{\n [LuaFunction(\"f\")] public static int F(LuaState L, int top, int result, int __arg0, int __handle) => L.IsInteger(1) ? top + result + __arg0 + __handle : 0;\n}\n"; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + Assert.Contains("global::Demo.Holder.F(__L, __arg0, __arg1, __arg2, __arg3)", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_any_input_reports_no_diagnostics() + { + GeneratorRun valid = roslyn.Run(BindingSources.Functions); + GeneratorRun invalid = + roslyn.Run(BindingSources.Functions.Replace("public static long Add", "public long Add", + StringComparison.Ordinal)); + + Assert.Empty(valid.GeneratorDiagnostics); + Assert.Empty(valid.Result.Diagnostics); + Assert.Empty(invalid.GeneratorDiagnostics); + Assert.Empty(invalid.Result.Diagnostics); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs index d075fe8b..c65929b2 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; + using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; @@ -9,264 +11,265 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class LuaGlobalOutputTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Generator_try_and_throwing_forms_emit_exact_file_with_one_shared_cache() - { - var run = roslyn.Run(BindingSources.Globals); - - var generated = Assert.Single(run.GeneratedSources); - Assert.Equal(ExpectedFiles.GlobalsHintName, generated.HintName); - Assert.Equal(ExpectedFiles.Globals(), generated.SourceText.ToString()); - } - - [Fact] - public void Generator_try_and_throwing_forms_compile_without_errors_or_warnings() - { - var run = roslyn.Run(BindingSources.Globals); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_global_suite_compiles_clean() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_outcome_form_preserves_resolution_call_and_result_categories() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var body = Section(run.SingleGeneratedText, - "public static partial global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus TryReadInt32Detailed(nuint address, out int value)", - "\n }\n"); - Assert.Contains("LuaGlobalFunctions.TryPushWithOutcome", body, StringComparison.Ordinal); - Assert.Contains("__resolution.ToOperationStatus()", body, StringComparison.Ordinal); - Assert.Contains("LuaOperationStatus.LuaFailure(__status)", body, StringComparison.Ordinal); - Assert.Contains("LuaOperationStatus.NilResult : global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus.InvalidResult", - body, StringComparison.Ordinal); - Assert.Contains("return global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Success;", body, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_copy_out_result_copies_before_restoring_the_stack() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var text = run.SingleGeneratedText; - Assert.Contains( - "public static partial bool TryReadString(nuint address, int maxLength, global::System.Span destination, out int written)", - text, - StringComparison.Ordinal); - Assert.Contains("bool __ok = __L.TryCopyUtf8(-1, destination, out written);\n return __ok;", - text, - StringComparison.Ordinal); - Assert.Contains("finally\n {\n __L.SetTop(__top);", text, StringComparison.Ordinal); - } - - [Fact] - public void Generator_string_result_reads_through_the_string_marshaller() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var text = run.SingleGeneratedText; - Assert.Contains("public static partial bool TryReadString(nuint address, int maxLength, out string value)", - text, StringComparison.Ordinal); - Assert.Contains( - "bool __ok = global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(__L, -1, out value);", text, - StringComparison.Ordinal); - Assert.Contains("public static partial string ReadString(nuint address, int maxLength)", text, - StringComparison.Ordinal); - Assert.Contains( - "global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(__L, __top, -1, \"readString\", \"a string\");", - text, StringComparison.Ordinal); - } - - [Fact] - public void Generator_void_throwing_form_keeps_no_result_and_restores_the_stack_in_finally() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var text = run.SingleGeneratedText; - var body = Section(text, "public static partial void Beep()", "\n }\n"); - Assert.Contains("global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(0, 0);", body, - StringComparison.Ordinal); - Assert.Contains("finally\n {\n __L.SetTop(__top);", body, StringComparison.Ordinal); - Assert.DoesNotContain("return", body, StringComparison.Ordinal); - } - - [Fact] - public void Generator_bool_return_without_results_is_the_throwing_form() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var body = Section(run.SingleGeneratedText, "public static partial bool IsKeyPressed(int key)", - "\n }\n"); - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.TryRead(__L, -1, out bool __result)", - body, - StringComparison.Ordinal); - Assert.Contains("ThrowUnexpectedResult(__L, __top, -1, \"isKeyPressed\", \"a boolean\")", body, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_two_results_are_read_from_the_bottom_up_and_default_each_other_on_failure() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var body = Section(run.SingleGeneratedText, - "public static partial bool TryDivide(long dividend, long divisor, out long quotient, out long remainder)", - "\n }\n"); - Assert.Contains("__L.TryCall(2, 2)", body, StringComparison.Ordinal); - Assert.Contains( - "remainder = default;\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out quotient);", - body, StringComparison.Ordinal); - Assert.Contains("if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, -2, out quotient))", - body, - StringComparison.Ordinal); - Assert.Contains("if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, -1, out remainder))", - body, - StringComparison.Ordinal); - Assert.Contains( - "quotient = default;\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out remainder);", - body, StringComparison.Ordinal); - Assert.Contains( - "return true;\n }\n catch (global::CheatEngine.SDK.Lua.Calls.LuaException)", body, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_string_result_among_several_is_defaulted_with_the_null_forgiving_operator() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var body = Section(run.SingleGeneratedText, - "public static partial bool TryDescribe(double value, bool flag, out string text, out double doubled)", - "\n }\n"); - Assert.Contains("text = default!;", body, StringComparison.Ordinal); - Assert.Contains("doubled = default;", body, StringComparison.Ordinal); - } - - [Fact] - public void Generator_leading_state_parameter_acquires_an_atomic_operation_lease() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var text = run.SingleGeneratedText; - var throwing = Section(text, - "public static partial long AddOn(global::CheatEngine.SDK.Lua.State.LuaState state, long a, long b)", - "\n }\n"); - Assert.Contains( - "using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(state);", - throwing, - StringComparison.Ordinal); - Assert.Contains("global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State;", throwing, - StringComparison.Ordinal); - Assert.DoesNotContain("AcquireState", throwing, StringComparison.Ordinal); - var tryForm = Section(text, - "public static partial bool TryAddOn(global::CheatEngine.SDK.Lua.State.LuaState state, long a, long b, out long sum)", - "\n }\n"); - Assert.Contains( - "using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(state);", - tryForm, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_nullable_string_annotations_are_repeated_in_the_signature() - { - var run = roslyn.Run(BindingSources.GlobalSuite); - - var text = run.SingleGeneratedText; - Assert.Contains("public static partial string Upper(global::System.ReadOnlySpan text)", text, - StringComparison.Ordinal); - Assert.Contains("public static partial string? UpperOrNull(string? text)", text, StringComparison.Ordinal); - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Push(__L, text);", text, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_wide_signature_checks_the_stack_first_and_compiles_clean() - { - var run = roslyn.Run(BindingSources.ManyArguments); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains( - "if (!__L.TryEnsureStack(17))\n {\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out sum);", - text, StringComparison.Ordinal); - Assert.Contains( - "if (!__L.TryEnsureStack(17))\n {\n throw new global::CheatEngine.SDK.Lua.Calls.LuaException(\"The Lua stack could not grow by 17 slots to call 'sum16'.\");", - text, StringComparison.Ordinal); - Assert.Contains("__L.TryCall(16, 1)", text, StringComparison.Ordinal); - } - - [Fact] - public void Generator_narrow_signature_does_not_check_the_stack() - { - var run = roslyn.Run(BindingSources.Globals); - - Assert.DoesNotContain("TryEnsureStack", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_unannotated_out_string_result_gets_exactly_cs8601_the_documented_exception() - { - // Every supported shape compiles clean, with one known exception. An 'out string' Try-form result without - // '[MaybeNullWhen(false)]' or 'string?' is valid input (LuaGlobalShape accepts it; StringMarshaller.TryRead's - // own out parameter carries the annotation, the declaration does not), and the generated body assigns it on - // the failure path exactly like every other result - so nullable analysis reports CS8601 in the generated - // file. The fix is on the declaration, not in this generator. - const string Source = - "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out string value); }"; - - var run = roslyn.Run(Source); - - Assert.Single(run.GeneratedSources); - Assert.Empty(run.GeneratorDiagnostics); - Diagnostic[] problems = - [ - .. run.OutputCompilation - .GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning - && !(string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } && - !path.EndsWith(".g.cs", StringComparison.Ordinal))) - ]; - var problem = Assert.Single(problems); - Assert.Equal("CS8601", problem.Id); - Assert.Contains("LuaGlobals.g.cs", problem.Location.SourceTree?.FilePath, StringComparison.Ordinal); - } - - [Fact] - public void Generator_compiles_clean_for_a_consumer_with_nullable_disabled() - { - // The generated file always opens with its own '#nullable enable' (GeneratedCodeText.WriteFileHeader), so a - // non-nullable shape compiles clean regardless of the consumer project's own setting: nothing - // here depends on the project-wide nullable context. (A defining declaration that writes 'string?' in a file - // without its own '#nullable enable' gets CS8632 either way - that is plain C#, unrelated to this generator, - // and not exercised here.) - var options = - RoslynEnvironment.CompilationOptions.WithNullableContextOptions(NullableContextOptions.Disable); - var run = RoslynFixture.Run(roslyn.CreateCompilation(options, BindingSources.Globals)); - - Assert.Single(run.GeneratedSources); - run.AssertCompilesClean(); - } - - // The text from the first occurrence of 'start' to the first 'end' after it (the closing brace of the method). - private static string Section(string text, string start, string end) - { - var from = text.IndexOf(start, StringComparison.Ordinal); - Assert.True(from >= 0, "Not found in the generated text: " + start); - var to = text.IndexOf(end, from, StringComparison.Ordinal); - Assert.True(to >= 0, "No end marker after: " + start); - return text[from..to]; - } + [Fact] + public void Generator_try_and_throwing_forms_emit_exact_file_with_one_shared_cache() + { + GeneratorRun run = roslyn.Run(BindingSources.Globals); + + GeneratedSourceResult generated = Assert.Single(run.GeneratedSources); + Assert.Equal(ExpectedFiles.GlobalsHintName, generated.HintName); + Assert.Equal(ExpectedFiles.Globals(), generated.SourceText.ToString()); + } + + [Fact] + public void Generator_try_and_throwing_forms_compile_without_errors_or_warnings() + { + GeneratorRun run = roslyn.Run(BindingSources.Globals); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_global_suite_compiles_clean() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_outcome_form_preserves_resolution_call_and_result_categories() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, + "public static partial global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus TryReadInt32Detailed(nuint address, out int value)", + "\n }\n"); + Assert.Contains("LuaGlobalFunctions.TryPushWithOutcome", body, StringComparison.Ordinal); + Assert.Contains("__resolution.ToOperationStatus()", body, StringComparison.Ordinal); + Assert.Contains("LuaOperationStatus.LuaFailure(__status)", body, StringComparison.Ordinal); + Assert.Contains( + "LuaOperationStatus.NilResult : global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus.InvalidResult", + body, StringComparison.Ordinal); + Assert.Contains("return global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Success;", body, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_copy_out_result_copies_before_restoring_the_stack() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string text = run.SingleGeneratedText; + Assert.Contains( + "public static partial bool TryReadString(nuint address, int maxLength, global::System.Span destination, out int written)", + text, + StringComparison.Ordinal); + Assert.Contains("bool __ok = __L.TryCopyUtf8(-1, destination, out written);\n return __ok;", + text, + StringComparison.Ordinal); + Assert.Contains("finally\n {\n __L.SetTop(__top);", text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_string_result_reads_through_the_string_marshaller() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string text = run.SingleGeneratedText; + Assert.Contains("public static partial bool TryReadString(nuint address, int maxLength, out string value)", + text, StringComparison.Ordinal); + Assert.Contains( + "bool __ok = global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(__L, -1, out value);", text, + StringComparison.Ordinal); + Assert.Contains("public static partial string ReadString(nuint address, int maxLength)", text, + StringComparison.Ordinal); + Assert.Contains( + "global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(__L, __top, -1, \"readString\", \"a string\");", + text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_void_throwing_form_keeps_no_result_and_restores_the_stack_in_finally() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string text = run.SingleGeneratedText; + string body = Section(text, "public static partial void Beep()", "\n }\n"); + Assert.Contains("global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(0, 0);", body, + StringComparison.Ordinal); + Assert.Contains("finally\n {\n __L.SetTop(__top);", body, StringComparison.Ordinal); + Assert.DoesNotContain("return", body, StringComparison.Ordinal); + } + + [Fact] + public void Generator_bool_return_without_results_is_the_throwing_form() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, "public static partial bool IsKeyPressed(int key)", + "\n }\n"); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.TryRead(__L, -1, out bool __result)", + body, + StringComparison.Ordinal); + Assert.Contains("ThrowUnexpectedResult(__L, __top, -1, \"isKeyPressed\", \"a boolean\")", body, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_two_results_are_read_from_the_bottom_up_and_default_each_other_on_failure() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, + "public static partial bool TryDivide(long dividend, long divisor, out long quotient, out long remainder)", + "\n }\n"); + Assert.Contains("__L.TryCall(2, 2)", body, StringComparison.Ordinal); + Assert.Contains( + "remainder = default;\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out quotient);", + body, StringComparison.Ordinal); + Assert.Contains("if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, -2, out quotient))", + body, + StringComparison.Ordinal); + Assert.Contains("if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, -1, out remainder))", + body, + StringComparison.Ordinal); + Assert.Contains( + "quotient = default;\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out remainder);", + body, StringComparison.Ordinal); + Assert.Contains( + "return true;\n }\n catch (global::CheatEngine.SDK.Lua.Calls.LuaException)", body, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_string_result_among_several_is_defaulted_with_the_null_forgiving_operator() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, + "public static partial bool TryDescribe(double value, bool flag, out string text, out double doubled)", + "\n }\n"); + Assert.Contains("text = default!;", body, StringComparison.Ordinal); + Assert.Contains("doubled = default;", body, StringComparison.Ordinal); + } + + [Fact] + public void Generator_leading_state_parameter_acquires_an_atomic_operation_lease() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string text = run.SingleGeneratedText; + string throwing = Section(text, + "public static partial long AddOn(global::CheatEngine.SDK.Lua.State.LuaState state, long a, long b)", + "\n }\n"); + Assert.Contains( + "using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(state);", + throwing, + StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State;", throwing, + StringComparison.Ordinal); + Assert.DoesNotContain("AcquireState", throwing, StringComparison.Ordinal); + string tryForm = Section(text, + "public static partial bool TryAddOn(global::CheatEngine.SDK.Lua.State.LuaState state, long a, long b, out long sum)", + "\n }\n"); + Assert.Contains( + "using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(state);", + tryForm, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_nullable_string_annotations_are_repeated_in_the_signature() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + + string text = run.SingleGeneratedText; + Assert.Contains("public static partial string Upper(global::System.ReadOnlySpan text)", text, + StringComparison.Ordinal); + Assert.Contains("public static partial string? UpperOrNull(string? text)", text, StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Push(__L, text);", text, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_wide_signature_checks_the_stack_first_and_compiles_clean() + { + GeneratorRun run = roslyn.Run(BindingSources.ManyArguments); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains( + "if (!__L.TryEnsureStack(17))\n {\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out sum);", + text, StringComparison.Ordinal); + Assert.Contains( + "if (!__L.TryEnsureStack(17))\n {\n throw new global::CheatEngine.SDK.Lua.Calls.LuaException(\"The Lua stack could not grow by 17 slots to call 'sum16'.\");", + text, StringComparison.Ordinal); + Assert.Contains("__L.TryCall(16, 1)", text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_narrow_signature_does_not_check_the_stack() + { + GeneratorRun run = roslyn.Run(BindingSources.Globals); + + Assert.DoesNotContain("TryEnsureStack", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_unannotated_out_string_result_gets_exactly_cs8601_the_documented_exception() + { + // Every supported shape compiles clean, with one known exception. An 'out string' Try-form result without + // '[MaybeNullWhen(false)]' or 'string?' is valid input (LuaGlobalShape accepts it; StringMarshaller.TryRead's + // own out parameter carries the annotation, the declaration does not), and the generated body assigns it on + // the failure path exactly like every other result - so nullable analysis reports CS8601 in the generated + // file. The fix is on the declaration, not in this generator. + const string Source = + "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out string value); }"; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Single(run.GeneratedSources); + Assert.Empty(run.GeneratorDiagnostics); + Diagnostic[] problems = + [ + .. run.OutputCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning + && !(string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) + && diagnostic.Location.SourceTree is { FilePath: string path } && + !path.EndsWith(".g.cs", StringComparison.Ordinal))) + ]; + Diagnostic problem = Assert.Single(problems); + Assert.Equal("CS8601", problem.Id); + Assert.Contains("LuaGlobals.g.cs", problem.Location.SourceTree?.FilePath, StringComparison.Ordinal); + } + + [Fact] + public void Generator_compiles_clean_for_a_consumer_with_nullable_disabled() + { + // The generated file always opens with its own '#nullable enable' (GeneratedCodeText.WriteFileHeader), so a + // non-nullable shape compiles clean regardless of the consumer project's own setting: nothing + // here depends on the project-wide nullable context. (A defining declaration that writes 'string?' in a file + // without its own '#nullable enable' gets CS8632 either way - that is plain C#, unrelated to this generator, + // and not exercised here.) + CSharpCompilationOptions options = + RoslynEnvironment.CompilationOptions.WithNullableContextOptions(NullableContextOptions.Disable); + GeneratorRun run = RoslynFixture.Run(roslyn.CreateCompilation(options, BindingSources.Globals)); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + } + + // The text from the first occurrence of 'start' to the first 'end' after it (the closing brace of the method). + private static string Section(string text, string start, string end) + { + int from = text.IndexOf(start, StringComparison.Ordinal); + Assert.True(from >= 0, "Not found in the generated text: " + start); + int to = text.IndexOf(end, from, StringComparison.Ordinal); + Assert.True(to >= 0, "No end marker after: " + start); + return text[from..to]; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaMarshallerOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaMarshallerOutputTests.cs index 236dc1dd..1a3e0a92 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaMarshallerOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaMarshallerOutputTests.cs @@ -1,6 +1,9 @@ using System.Text; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using Microsoft.CodeAnalysis; + namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// @@ -9,189 +12,192 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class LuaMarshallerOutputTests(RoslynFixture roslyn) : IClassFixture { - [Fact] - public void Generator_valid_explicit_marshallers_emit_direct_static_calls_for_function_and_global_values() - { - var run = roslyn.Run(ValidSource); - - run.AssertCompilesClean(); - StringBuilder textBuilder = new(); - foreach (var source in run.GeneratedSources) textBuilder.Append(source.SourceText.ToString()); - - var text = textBuilder.ToString(); - Assert.Contains("global::Demo.TokenMarshaller.TryRead(__L, 1, out global::Demo.Token __arg0)", text, - StringComparison.Ordinal); - Assert.Contains("global::Demo.TokenMarshaller.Push(__L, __result);", text, StringComparison.Ordinal); - Assert.Contains("global::Demo.TokenMarshaller.Push(__L, value);", text, StringComparison.Ordinal); - Assert.Contains("global::Demo.TokenMarshaller.TryRead(__L, -1, out global::Demo.Token __result)", text, - StringComparison.Ordinal); - Assert.Contains("global::Demo.TokenMarshaller.TryRead(__L, -1, out value)", text, - StringComparison.Ordinal); - Assert.DoesNotContain("GetType", text, StringComparison.Ordinal); - Assert.DoesNotContain("Activator", text, StringComparison.Ordinal); - } - - [Fact] - public void Generator_marshaller_for_a_different_value_type_does_not_generate_a_binding() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Marshalling; - using CheatEngine.SDK.Lua.State; - namespace Demo; - public readonly struct Token { } - public readonly struct WrongMarshaller : ILuaMarshaller - { - public static void Push(LuaState state, int value) => state.PushInteger(value); - public static bool TryRead(LuaState state, int index, out int value) => state.TryReadInteger(index, out value); - } - public static partial class Bindings - { - [LuaFunction("wrong")] - public static Token Wrong([LuaMarshaller(typeof(WrongMarshaller))] Token value) => value; - } - """; - - var run = roslyn.Run(source); - - Assert.Empty(run.GeneratedSources); - } - - [Fact] - public void Generator_non_marshaller_type_does_not_generate_a_global_wrapper() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - namespace Demo; - public readonly struct Token { } - public sealed class NotAMarshaller { } - public static partial class Bindings - { - [LuaGlobal("token")] - [return: LuaMarshaller(typeof(NotAMarshaller))] - public static partial Token Read(); - } - """; - - var run = roslyn.Run(source); - - Assert.Empty(run.GeneratedSources); - } - - [Fact] - public void Generator_explicit_static_interface_marshaller_members_do_not_generate_a_binding() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Marshalling; - using CheatEngine.SDK.Lua.State; - - namespace Demo; - - public readonly struct Token { } - - public readonly struct ExplicitMarshaller : ILuaMarshaller - { - static void ILuaMarshaller.Push(LuaState state, Token value) { } - - static bool ILuaMarshaller.TryRead(LuaState state, int index, out Token value) - { - value = default; - return false; - } - } - - public static partial class Bindings - { - [LuaFunction("token")] - public static int RoundTrip([LuaMarshaller(typeof(ExplicitMarshaller))] Token value) => 0; - } - """; - - roslyn.Run(source).AssertNoOutput(); - } - - [Fact] - public void Generator_marshaller_with_direct_members_of_the_wrong_shape_does_not_generate_a_binding() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Marshalling; - using CheatEngine.SDK.Lua.State; - - namespace Demo; - - public readonly struct Token { } - - public readonly struct InvalidDirectMarshaller : ILuaMarshaller - { - static void ILuaMarshaller.Push(LuaState state, Token value) { } - - static bool ILuaMarshaller.TryRead(LuaState state, int index, out Token value) - { - value = default; - return false; - } - - public static void Push(LuaState state, int value) { } - - public static bool TryRead(LuaState state, int index, out int value) - { - value = default; - return false; - } - } - - public static partial class Bindings - { - [LuaGlobal("token")] - public static partial bool TryRead([LuaMarshaller(typeof(InvalidDirectMarshaller))] out Token value); - } - """; - - roslyn.Run(source).AssertNoOutput(); - } - - private const string ValidSource = """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Marshalling; - using CheatEngine.SDK.Lua.State; - - namespace Demo; - - public readonly record struct Token(long Value); - - public readonly struct TokenMarshaller : ILuaMarshaller - { - public static void Push(LuaState state, Token value) => state.PushInteger(value.Value); - public static bool TryRead(LuaState state, int index, out Token value) - { - if (state.TryReadInteger(index, out var raw)) - { - value = new Token(raw); - return true; - } - - value = default; - return false; - } - } - - public static partial class Bindings - { - [LuaFunction("twice")] - [return: LuaMarshaller(typeof(TokenMarshaller))] - public static Token Twice([LuaMarshaller(typeof(TokenMarshaller))] Token value) => new(value.Value * 2); - - [LuaGlobal("writeToken")] - public static partial void Write([LuaMarshaller(typeof(TokenMarshaller))] Token value); - - [LuaGlobal("readToken")] - [return: LuaMarshaller(typeof(TokenMarshaller))] - public static partial Token Read(); - - [LuaGlobal("readToken")] - public static partial bool TryRead([LuaMarshaller(typeof(TokenMarshaller))] out Token value); - } - """; + private const string ValidSource = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public readonly record struct Token(long Value); + + public readonly struct TokenMarshaller : ILuaMarshaller + { + public static void Push(LuaState state, Token value) => state.PushInteger(value.Value); + public static bool TryRead(LuaState state, int index, out Token value) + { + if (state.TryReadInteger(index, out var raw)) + { + value = new Token(raw); + return true; + } + + value = default; + return false; + } + } + + public static partial class Bindings + { + [LuaFunction("twice")] + [return: LuaMarshaller(typeof(TokenMarshaller))] + public static Token Twice([LuaMarshaller(typeof(TokenMarshaller))] Token value) => new(value.Value * 2); + + [LuaGlobal("writeToken")] + public static partial void Write([LuaMarshaller(typeof(TokenMarshaller))] Token value); + + [LuaGlobal("readToken")] + [return: LuaMarshaller(typeof(TokenMarshaller))] + public static partial Token Read(); + + [LuaGlobal("readToken")] + public static partial bool TryRead([LuaMarshaller(typeof(TokenMarshaller))] out Token value); + } + """; + + [Fact] + public void Generator_valid_explicit_marshallers_emit_direct_static_calls_for_function_and_global_values() + { + GeneratorRun run = roslyn.Run(ValidSource); + + run.AssertCompilesClean(); + StringBuilder textBuilder = new(); + foreach (GeneratedSourceResult source in run.GeneratedSources) + { + textBuilder.Append(source.SourceText); + } + + string text = textBuilder.ToString(); + Assert.Contains("global::Demo.TokenMarshaller.TryRead(__L, 1, out global::Demo.Token __arg0)", text, + StringComparison.Ordinal); + Assert.Contains("global::Demo.TokenMarshaller.Push(__L, __result);", text, StringComparison.Ordinal); + Assert.Contains("global::Demo.TokenMarshaller.Push(__L, value);", text, StringComparison.Ordinal); + Assert.Contains("global::Demo.TokenMarshaller.TryRead(__L, -1, out global::Demo.Token __result)", text, + StringComparison.Ordinal); + Assert.Contains("global::Demo.TokenMarshaller.TryRead(__L, -1, out value)", text, + StringComparison.Ordinal); + Assert.DoesNotContain("GetType", text, StringComparison.Ordinal); + Assert.DoesNotContain("Activator", text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_marshaller_for_a_different_value_type_does_not_generate_a_binding() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + namespace Demo; + public readonly struct Token { } + public readonly struct WrongMarshaller : ILuaMarshaller + { + public static void Push(LuaState state, int value) => state.PushInteger(value); + public static bool TryRead(LuaState state, int index, out int value) => state.TryReadInteger(index, out value); + } + public static partial class Bindings + { + [LuaFunction("wrong")] + public static Token Wrong([LuaMarshaller(typeof(WrongMarshaller))] Token value) => value; + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void Generator_non_marshaller_type_does_not_generate_a_global_wrapper() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + namespace Demo; + public readonly struct Token { } + public sealed class NotAMarshaller { } + public static partial class Bindings + { + [LuaGlobal("token")] + [return: LuaMarshaller(typeof(NotAMarshaller))] + public static partial Token Read(); + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void Generator_explicit_static_interface_marshaller_members_do_not_generate_a_binding() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public readonly struct Token { } + + public readonly struct ExplicitMarshaller : ILuaMarshaller + { + static void ILuaMarshaller.Push(LuaState state, Token value) { } + + static bool ILuaMarshaller.TryRead(LuaState state, int index, out Token value) + { + value = default; + return false; + } + } + + public static partial class Bindings + { + [LuaFunction("token")] + public static int RoundTrip([LuaMarshaller(typeof(ExplicitMarshaller))] Token value) => 0; + } + """; + + roslyn.Run(source).AssertNoOutput(); + } + + [Fact] + public void Generator_marshaller_with_direct_members_of_the_wrong_shape_does_not_generate_a_binding() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public readonly struct Token { } + + public readonly struct InvalidDirectMarshaller : ILuaMarshaller + { + static void ILuaMarshaller.Push(LuaState state, Token value) { } + + static bool ILuaMarshaller.TryRead(LuaState state, int index, out Token value) + { + value = default; + return false; + } + + public static void Push(LuaState state, int value) { } + + public static bool TryRead(LuaState state, int index, out int value) + { + value = default; + return false; + } + } + + public static partial class Bindings + { + [LuaGlobal("token")] + public static partial bool TryRead([LuaMarshaller(typeof(InvalidDirectMarshaller))] out Token value); + } + """; + + roslyn.Run(source).AssertNoOutput(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs index 1fc9cc7a..96a0e112 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs @@ -1,5 +1,7 @@ using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -11,670 +13,677 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class LuaObjectOutputTests(RoslynFixture roslyn) : IClassFixture { - private const string Source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("MemScan")] - public readonly partial struct Scan - { - [LuaMethod("firstScan")] - public partial void FirstScan(); - - [LuaMethod("getCount")] - public partial int GetCount(); - - [LuaMethod("tryGetAddress")] - public partial bool TryGetAddress(int index, out long address); - - [LuaProperty("Count")] - public partial int Count { get; set; } - } - """; - - [Fact] - public void Lua_annotation_usage_is_explicit_and_excludes_global_properties() - { - var luaClass = AttributeUsage(typeof(LuaClassAttribute)); - Assert.Equal(AttributeTargets.Struct, luaClass.ValidOn); - Assert.False(luaClass.Inherited); - Assert.False(luaClass.AllowMultiple); - - var luaGlobal = AttributeUsage(typeof(LuaGlobalAttribute)); - Assert.Equal(AttributeTargets.Method, luaGlobal.ValidOn); - Assert.False(luaGlobal.Inherited); - Assert.False(luaGlobal.AllowMultiple); - - Assert.False(AttributeUsage(typeof(LuaMethodAttribute)).AllowMultiple); - Assert.False(AttributeUsage(typeof(LuaPropertyAttribute)).AllowMultiple); - } - - [Fact] - public void Borrowed_handle_generates_identity_marshalling_and_protected_members() - { - var run = roslyn.Run(Source); - - var handle = run.GeneratedText("Demo.Scan.LuaClass.g.cs"); - Assert.Contains("private readonly global::CheatEngine.SDK.Engine.Objects.CEObject _handle;", handle, - StringComparison.Ordinal); - Assert.Contains("ICEObject", handle, StringComparison.Ordinal); - Assert.Contains("ILuaMarshaller", handle, StringComparison.Ordinal); - Assert.Contains("public static global::Demo.Scan FromHandle", handle, StringComparison.Ordinal); - Assert.Contains("public static bool TryRead", handle, StringComparison.Ordinal); - Assert.Contains("public static bool operator ==", handle, StringComparison.Ordinal); - - var members = run.GeneratedText("Demo.Scan.LuaObjectMembers.g.cs"); - Assert.Contains("this.Handle.TryPushMethodLeavingObject(__ceState, \"firstScan\"u8)", members, - StringComparison.Ordinal); - Assert.Contains("__ceState.SetTop(__ceTop);", members, StringComparison.Ordinal); - Assert.Contains("this.Handle.TryGetProperty", members, - StringComparison.Ordinal); - Assert.Contains("this.Handle.TrySetProperty", members, - StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Object_method_parameter_named_handle_does_not_shadow_the_generated_property() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Object")] - public readonly partial struct ObjectHandle - { - [LuaMethod("call")] - public partial void Call(int Handle); - } - """; - - var run = roslyn.Run(source); - - run.AssertCompilesClean(); - Assert.Contains("this.Handle.TryPushMethodLeavingObject", run.GeneratedText("Demo.ObjectHandle.LuaObjectMembers.g.cs"), - StringComparison.Ordinal); - } - - [Fact] - public void Invalid_handle_does_not_block_a_valid_sibling() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public partial struct Bad - { - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - Assert.Contains("partial struct Good", run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Invalid_handle_name_does_not_generate_members_without_its_handle() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("")] - public readonly partial struct Bad - { - [LuaMethod("go")] - partial void Go(); - } - - [LuaClass("Good")] - public readonly partial struct Good - { - [LuaMethod("go")] - public partial void Go(); - } - """; - - var run = roslyn.Run(source); - - Assert.Equal(2, run.GeneratedSources.Length); - Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.Contains("Demo.Good.LuaObjectMembers.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.DoesNotContain("Bad", run.GeneratedText("Demo.Good.LuaObjectMembers.g.cs"), StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Generated_identity_collision_skips_only_the_affected_handle() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public readonly partial struct Bad - { - public global::CheatEngine.SDK.Engine.Objects.CEObject Handle => default; - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - run.AssertCompilesClean(); - } - - [Fact] - public void Generated_handle_accessor_collision_skips_only_the_affected_handle() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public readonly partial struct Bad - { - private global::CheatEngine.SDK.Engine.Objects.CEObject get_Handle() => default; - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - run.AssertCompilesClean(); - } - - [Fact] - public void Generic_handle_accessor_collision_skips_only_the_affected_handle() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public readonly partial struct Bad - { - private global::CheatEngine.SDK.Engine.Objects.CEObject get_Handle() => default; - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.DoesNotContain("Demo.Bad.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Generated_handle_setter_collision_skips_only_the_matching_handle() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("BadSetter")] - public readonly partial struct BadSetter - { - private void set_Handle(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } - } - - [LuaClass("GenericSetter")] - public readonly partial struct GenericSetter - { - private void set_Handle(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } - } - - [LuaClass("DifferentSetter")] - public readonly partial struct DifferentSetter - { - private void set_Handle(int value) { } - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Equal(2, run.GeneratedSources.Length); - Assert.DoesNotContain("Demo.BadSetter.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.DoesNotContain("Demo.GenericSetter.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.Contains("Demo.DifferentSetter.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Generated_handle_accessor_named_non_methods_skip_only_the_affected_handles() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Field")] - public readonly partial struct Field - { - #pragma warning disable CS0169 - private readonly int get_Handle; - #pragma warning restore CS0169 - } - - [LuaClass("Property")] - public readonly partial struct Property - { - private int set_Handle => 0; - } - - [LuaClass("Nested")] - public readonly partial struct Nested - { - private struct get_Handle { } - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.DoesNotContain("Demo.Field.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.DoesNotContain("Demo.Property.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.DoesNotContain("Demo.Nested.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); - run.AssertCompilesClean(); - } - - [Theory] - [InlineData("_handle")] - [InlineData("Handle")] - [InlineData("FromHandle")] - [InlineData("Equals")] - [InlineData("GetHashCode")] - [InlineData("Push")] - [InlineData("TryRead")] - public void Every_generated_handle_member_name_skips_only_the_affected_handle(string memberName) - { - var newModifier = memberName is "Equals" or "GetHashCode" ? "new " : string.Empty; - var source = $$""" - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public readonly partial struct Bad - { - private {{newModifier}}int {{memberName}} => 0; - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - run.AssertCompilesClean(); - } - - [Theory] - [InlineData("_handle")] - [InlineData("Handle")] - [InlineData("FromHandle")] - [InlineData("Equals")] - [InlineData("GetHashCode")] - [InlineData("Push")] - [InlineData("TryRead")] - public void Every_generated_handle_type_name_skips_only_the_affected_handle(string typeName) - { - var source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public readonly partial struct TYPE - { - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """.Replace("TYPE", typeName, StringComparison.Ordinal); - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - run.AssertCompilesClean(); - } - - [Fact] - public void Generated_handle_constructor_collision_skips_only_the_affected_handle() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Bad")] - public readonly partial struct Bad - { - private Bad(global::CheatEngine.SDK.Engine.Objects.CEObject handle) - { - _ = handle; - } - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - run.AssertCompilesClean(); - } - - [Fact] - public void Record_and_ref_like_handles_do_not_block_a_valid_sibling() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Record")] - public readonly partial record struct RecordHandle; - - [LuaClass("Ref")] - public readonly ref partial struct RefHandle - { - } - - [LuaClass("Good")] - public readonly partial struct Good - { - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); - run.AssertCompilesClean(); - } - - [Fact] - public void Object_wide_arguments_and_results_preflight_the_stack_before_any_push() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Wide")] - public readonly partial struct Wide - { - [LuaMethod("sum15")] - public partial bool TrySum15( - int a01, int a02, int a03, int a04, int a05, - int a06, int a07, int a08, int a09, int a10, - int a11, int a12, int a13, int a14, int a15, - out long total); - - [LuaMethod("fanout")] - public partial bool TryFanout( - int input, - out int r01, out int r02, out int r03, out int r04, out int r05, - out int r06, out int r07, out int r08, out int r09, out int r10, - out int r11, out int r12, out int r13, out int r14, out int r15, - out int r16, out int r17); - } - """; - - var run = roslyn.Run(source); - var members = run.GeneratedText("Demo.Wide.LuaObjectMembers.g.cs"); - var stackCheck = members.IndexOf("if (!__ceState.TryEnsureStack(17))", StringComparison.Ordinal); - var receiverPush = members.IndexOf("this.Handle.TryPushMethodLeavingObject(__ceState, \"sum15\"u8)", - StringComparison.Ordinal); - - Assert.True(stackCheck >= 0 && stackCheck < receiverPush, - "The object receiver, function and all arguments must be preflighted before the first push."); - Assert.Contains( - "return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__ceState, __ceTop, out total);", - members, StringComparison.Ordinal); - Assert.Contains("if (!__ceState.TryEnsureStack(18))", members, StringComparison.Ordinal); - Assert.Contains("__ceState.TryCall(15, 1)", members, StringComparison.Ordinal); - Assert.Contains("__ceState.TryCall(1, 17)", members, StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Object_try_method_defaults_every_result_when_a_marshaller_push_throws() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Probe")] - public readonly partial struct Probe - { - [LuaMethod("describe")] - public partial bool TryDescribe(string input, out string? text, out int count); - } - """; - - var run = roslyn.Run(source); - var members = run.GeneratedText("Demo.Probe.LuaObjectMembers.g.cs"); - var root = RoslynFixture.Parse(members, "Demo.Probe.LuaObjectMembers.g.cs") - .GetCompilationUnitRoot(TestContext.Current.CancellationToken); - var method = FindGeneratedMethod(root, "TryDescribe"); - Assert.NotNull(method.Body); - var luaCall = FindTryStatement(method.Body!); - var exceptionCatch = Assert.Single(luaCall.Catches); - - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__ceState, input);", members, - StringComparison.Ordinal); - Assert.Contains(luaCall.Block.Statements, - static statement => statement.ToFullString().Contains("StringMarshaller.Push(__ceState, input)", - StringComparison.Ordinal)); - Assert.Equal("global::CheatEngine.SDK.Lua.Calls.LuaException", exceptionCatch.Declaration!.Type.ToString()); - Assert.Contains("text = default!;\n count = default;\n return false;", members, - StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Partial_property_modifiers_and_accessor_visibility_are_preserved() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Properties")] - public readonly partial struct Properties - { - [LuaProperty("Required")] - public required partial int Required { get; set; } - - [LuaProperty("Writable")] - public partial int Writable { get; private set; } - - [LuaProperty("Readable")] - public partial int Readable { private get; set; } - } - """; - - var run = roslyn.Run(source); - var members = run.GeneratedText("Demo.Properties.LuaObjectMembers.g.cs"); - - Assert.Contains("public required partial int Required", members, StringComparison.Ordinal); - Assert.Contains("[global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]", - run.GeneratedText("Demo.Properties.LuaClass.g.cs"), StringComparison.Ordinal); - Assert.Contains("public partial int Writable", members, StringComparison.Ordinal); - Assert.Contains("private set", members, StringComparison.Ordinal); - Assert.Contains("public partial int Readable", members, StringComparison.Ordinal); - Assert.Contains("private get", members, StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Unsupported_partial_property_forms_do_not_emit_object_members() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - public interface IContract - { - int Explicit { get; } - } - - [LuaClass("Unsupported")] - public readonly partial struct Unsupported : IContract - { - [LuaProperty("Init")] - public partial int Init { get; init; } - - [LuaProperty("Ref")] - public partial ref int Ref { get; } - - [LuaProperty("RefReadonly")] - public partial ref readonly int RefReadonly { get; } - - [LuaProperty("Explicit")] - partial int IContract.Explicit { get; } - } - """; - - var run = roslyn.Run(source); - - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Unsupported.LuaClass.g.cs", run.HintNames[0]); - } - - private static MethodDeclarationSyntax FindGeneratedMethod(CompilationUnitSyntax root, string methodName) - { - MethodDeclarationSyntax? result = null; - foreach (var node in root.DescendantNodes()) - if (node is MethodDeclarationSyntax candidate - && string.Equals(candidate.Identifier.ValueText, methodName, StringComparison.Ordinal)) - { - Assert.Null(result); - result = candidate; - } - - Assert.NotNull(result); - return result!; - } - - private static TryStatementSyntax FindTryStatement(BlockSyntax body) - { - TryStatementSyntax? result = null; - foreach (var statement in body.Statements) - if (statement is TryStatementSyntax candidate) - { - Assert.Null(result); - result = candidate; - } - - Assert.NotNull(result); - return result!; - } - - [Fact] - public void Case_only_type_name_difference_gets_stable_distinct_class_hints() - { - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace Demo; - - [LuaClass("Upper")] - public readonly partial struct Case - { - } - - [LuaClass("Lower")] - public readonly partial struct CASE - { - } - """; - - var run = roslyn.Run(source); - - Assert.Equal(2, run.HintNames.Length); - Assert.NotEqual(run.HintNames[0], run.HintNames[1], StringComparer.OrdinalIgnoreCase); - run.AssertCompilesClean(); - } - - [Fact] - public void Globals_generate_without_unsafe_but_functions_remain_gated() - { - var globalsOnly = RoslynFixture.Run(roslyn.CreateCompilation(RoslynEnvironment.SafeCompilationOptions, - BindingSources.Globals)); - Assert.Single(globalsOnly.GeneratedSources); - Assert.Equal("Demo.Memory.LuaGlobals.g.cs", globalsOnly.HintNames[0]); - globalsOnly.AssertCompilesClean(); - - var mixed = RoslynFixture.Run(roslyn.CreateCompilation(RoslynEnvironment.SafeCompilationOptions, - BindingSources.Functions, BindingSources.Globals)); - Assert.Single(mixed.GeneratedSources); - Assert.Equal("Demo.Memory.LuaGlobals.g.cs", mixed.HintNames[0]); - } - - private static AttributeUsageAttribute AttributeUsage(Type attributeType) - { - return (AttributeUsageAttribute)Attribute.GetCustomAttribute(attributeType, typeof(AttributeUsageAttribute))!; - } + private const string Source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("MemScan")] + public readonly partial struct Scan + { + [LuaMethod("firstScan")] + public partial void FirstScan(); + + [LuaMethod("getCount")] + public partial int GetCount(); + + [LuaMethod("tryGetAddress")] + public partial bool TryGetAddress(int index, out long address); + + [LuaProperty("Count")] + public partial int Count { get; set; } + } + """; + + [Fact] + public void Lua_annotation_usage_is_explicit_and_excludes_global_properties() + { + AttributeUsageAttribute luaClass = AttributeUsage(typeof(LuaClassAttribute)); + Assert.Equal(AttributeTargets.Struct, luaClass.ValidOn); + Assert.False(luaClass.Inherited); + Assert.False(luaClass.AllowMultiple); + + AttributeUsageAttribute luaGlobal = AttributeUsage(typeof(LuaGlobalAttribute)); + Assert.Equal(AttributeTargets.Method, luaGlobal.ValidOn); + Assert.False(luaGlobal.Inherited); + Assert.False(luaGlobal.AllowMultiple); + + Assert.False(AttributeUsage(typeof(LuaMethodAttribute)).AllowMultiple); + Assert.False(AttributeUsage(typeof(LuaPropertyAttribute)).AllowMultiple); + } + + [Fact] + public void Borrowed_handle_generates_identity_marshalling_and_protected_members() + { + GeneratorRun run = roslyn.Run(Source); + + string handle = run.GeneratedText("Demo.Scan.LuaClass.g.cs"); + Assert.Contains("private readonly global::CheatEngine.SDK.Engine.Objects.CEObject _handle;", handle, + StringComparison.Ordinal); + Assert.Contains("ICEObject", handle, StringComparison.Ordinal); + Assert.Contains("ILuaMarshaller", handle, StringComparison.Ordinal); + Assert.Contains("public static global::Demo.Scan FromHandle", handle, StringComparison.Ordinal); + Assert.Contains("public static bool TryRead", handle, StringComparison.Ordinal); + Assert.Contains("public static bool operator ==", handle, StringComparison.Ordinal); + + string members = run.GeneratedText("Demo.Scan.LuaObjectMembers.g.cs"); + Assert.Contains("this.Handle.TryPushMethodLeavingObject(__ceState, \"firstScan\"u8)", members, + StringComparison.Ordinal); + Assert.Contains("__ceState.SetTop(__ceTop);", members, StringComparison.Ordinal); + Assert.Contains("this.Handle.TryGetProperty", + members, + StringComparison.Ordinal); + Assert.Contains("this.Handle.TrySetProperty", + members, + StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Object_method_parameter_named_handle_does_not_shadow_the_generated_property() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct ObjectHandle + { + [LuaMethod("call")] + public partial void Call(int Handle); + } + """; + + GeneratorRun run = roslyn.Run(source); + + run.AssertCompilesClean(); + Assert.Contains("this.Handle.TryPushMethodLeavingObject", + run.GeneratedText("Demo.ObjectHandle.LuaObjectMembers.g.cs"), + StringComparison.Ordinal); + } + + [Fact] + public void Invalid_handle_does_not_block_a_valid_sibling() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public partial struct Bad + { + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + Assert.Contains("partial struct Good", run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Invalid_handle_name_does_not_generate_members_without_its_handle() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("")] + public readonly partial struct Bad + { + [LuaMethod("go")] + partial void Go(); + } + + [LuaClass("Good")] + public readonly partial struct Good + { + [LuaMethod("go")] + public partial void Go(); + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Equal(2, run.GeneratedSources.Length); + Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.Contains("Demo.Good.LuaObjectMembers.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.DoesNotContain("Bad", run.GeneratedText("Demo.Good.LuaObjectMembers.g.cs"), StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Generated_identity_collision_skips_only_the_affected_handle() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public readonly partial struct Bad + { + public global::CheatEngine.SDK.Engine.Objects.CEObject Handle => default; + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + run.AssertCompilesClean(); + } + + [Fact] + public void Generated_handle_accessor_collision_skips_only_the_affected_handle() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public readonly partial struct Bad + { + private global::CheatEngine.SDK.Engine.Objects.CEObject get_Handle() => default; + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + run.AssertCompilesClean(); + } + + [Fact] + public void Generic_handle_accessor_collision_skips_only_the_affected_handle() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public readonly partial struct Bad + { + private global::CheatEngine.SDK.Engine.Objects.CEObject get_Handle() => default; + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.DoesNotContain("Demo.Bad.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Generated_handle_setter_collision_skips_only_the_matching_handle() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("BadSetter")] + public readonly partial struct BadSetter + { + private void set_Handle(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } + } + + [LuaClass("GenericSetter")] + public readonly partial struct GenericSetter + { + private void set_Handle(global::CheatEngine.SDK.Engine.Objects.CEObject value) { } + } + + [LuaClass("DifferentSetter")] + public readonly partial struct DifferentSetter + { + private void set_Handle(int value) { } + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Equal(2, run.GeneratedSources.Length); + Assert.DoesNotContain("Demo.BadSetter.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.DoesNotContain("Demo.GenericSetter.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.Contains("Demo.DifferentSetter.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Generated_handle_accessor_named_non_methods_skip_only_the_affected_handles() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Field")] + public readonly partial struct Field + { + #pragma warning disable CS0169 + private readonly int get_Handle; + #pragma warning restore CS0169 + } + + [LuaClass("Property")] + public readonly partial struct Property + { + private int set_Handle => 0; + } + + [LuaClass("Nested")] + public readonly partial struct Nested + { + private struct get_Handle { } + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.DoesNotContain("Demo.Field.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.DoesNotContain("Demo.Property.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.DoesNotContain("Demo.Nested.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + Assert.Contains("Demo.Good.LuaClass.g.cs", run.HintNames, StringComparer.Ordinal); + run.AssertCompilesClean(); + } + + [Theory] + [InlineData("_handle")] + [InlineData("Handle")] + [InlineData("FromHandle")] + [InlineData("Equals")] + [InlineData("GetHashCode")] + [InlineData("Push")] + [InlineData("TryRead")] + public void Every_generated_handle_member_name_skips_only_the_affected_handle(string memberName) + { + string newModifier = memberName is "Equals" or "GetHashCode" ? "new " : string.Empty; + string source = $$""" + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public readonly partial struct Bad + { + private {{newModifier}}int {{memberName}} => 0; + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + run.AssertCompilesClean(); + } + + [Theory] + [InlineData("_handle")] + [InlineData("Handle")] + [InlineData("FromHandle")] + [InlineData("Equals")] + [InlineData("GetHashCode")] + [InlineData("Push")] + [InlineData("TryRead")] + public void Every_generated_handle_type_name_skips_only_the_affected_handle(string typeName) + { + string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public readonly partial struct TYPE + { + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """.Replace("TYPE", typeName, StringComparison.Ordinal); + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + run.AssertCompilesClean(); + } + + [Fact] + public void Generated_handle_constructor_collision_skips_only_the_affected_handle() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Bad")] + public readonly partial struct Bad + { + private Bad(global::CheatEngine.SDK.Engine.Objects.CEObject handle) + { + _ = handle; + } + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + run.AssertCompilesClean(); + } + + [Fact] + public void Record_and_ref_like_handles_do_not_block_a_valid_sibling() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Record")] + public readonly partial record struct RecordHandle; + + [LuaClass("Ref")] + public readonly ref partial struct RefHandle + { + } + + [LuaClass("Good")] + public readonly partial struct Good + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Good.LuaClass.g.cs", run.HintNames[0]); + run.AssertCompilesClean(); + } + + [Fact] + public void Object_wide_arguments_and_results_preflight_the_stack_before_any_push() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Wide")] + public readonly partial struct Wide + { + [LuaMethod("sum15")] + public partial bool TrySum15( + int a01, int a02, int a03, int a04, int a05, + int a06, int a07, int a08, int a09, int a10, + int a11, int a12, int a13, int a14, int a15, + out long total); + + [LuaMethod("fanout")] + public partial bool TryFanout( + int input, + out int r01, out int r02, out int r03, out int r04, out int r05, + out int r06, out int r07, out int r08, out int r09, out int r10, + out int r11, out int r12, out int r13, out int r14, out int r15, + out int r16, out int r17); + } + """; + + GeneratorRun run = roslyn.Run(source); + string members = run.GeneratedText("Demo.Wide.LuaObjectMembers.g.cs"); + int stackCheck = members.IndexOf("if (!__ceState.TryEnsureStack(17))", StringComparison.Ordinal); + int receiverPush = members.IndexOf("this.Handle.TryPushMethodLeavingObject(__ceState, \"sum15\"u8)", + StringComparison.Ordinal); + + Assert.True(stackCheck >= 0 && stackCheck < receiverPush, + "The object receiver, function and all arguments must be preflighted before the first push."); + Assert.Contains( + "return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__ceState, __ceTop, out total);", + members, StringComparison.Ordinal); + Assert.Contains("if (!__ceState.TryEnsureStack(18))", members, StringComparison.Ordinal); + Assert.Contains("__ceState.TryCall(15, 1)", members, StringComparison.Ordinal); + Assert.Contains("__ceState.TryCall(1, 17)", members, StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Object_try_method_defaults_every_result_when_a_marshaller_push_throws() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Probe")] + public readonly partial struct Probe + { + [LuaMethod("describe")] + public partial bool TryDescribe(string input, out string? text, out int count); + } + """; + + GeneratorRun run = roslyn.Run(source); + string members = run.GeneratedText("Demo.Probe.LuaObjectMembers.g.cs"); + CompilationUnitSyntax root = RoslynFixture.Parse(members, "Demo.Probe.LuaObjectMembers.g.cs") + .GetCompilationUnitRoot(TestContext.Current.CancellationToken); + MethodDeclarationSyntax method = FindGeneratedMethod(root, "TryDescribe"); + Assert.NotNull(method.Body); + TryStatementSyntax luaCall = FindTryStatement(method.Body!); + CatchClauseSyntax exceptionCatch = Assert.Single(luaCall.Catches); + + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__ceState, input);", members, + StringComparison.Ordinal); + Assert.Contains(luaCall.Block.Statements, + static statement => statement.ToFullString().Contains("StringMarshaller.Push(__ceState, input)", + StringComparison.Ordinal)); + Assert.Equal("global::CheatEngine.SDK.Lua.Calls.LuaException", exceptionCatch.Declaration!.Type.ToString()); + Assert.Contains("text = default!;\n count = default;\n return false;", members, + StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Partial_property_modifiers_and_accessor_visibility_are_preserved() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Properties")] + public readonly partial struct Properties + { + [LuaProperty("Required")] + public required partial int Required { get; set; } + + [LuaProperty("Writable")] + public partial int Writable { get; private set; } + + [LuaProperty("Readable")] + public partial int Readable { private get; set; } + } + """; + + GeneratorRun run = roslyn.Run(source); + string members = run.GeneratedText("Demo.Properties.LuaObjectMembers.g.cs"); + + Assert.Contains("public required partial int Required", members, StringComparison.Ordinal); + Assert.Contains("[global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]", + run.GeneratedText("Demo.Properties.LuaClass.g.cs"), StringComparison.Ordinal); + Assert.Contains("public partial int Writable", members, StringComparison.Ordinal); + Assert.Contains("private set", members, StringComparison.Ordinal); + Assert.Contains("public partial int Readable", members, StringComparison.Ordinal); + Assert.Contains("private get", members, StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Unsupported_partial_property_forms_do_not_emit_object_members() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public interface IContract + { + int Explicit { get; } + } + + [LuaClass("Unsupported")] + public readonly partial struct Unsupported : IContract + { + [LuaProperty("Init")] + public partial int Init { get; init; } + + [LuaProperty("Ref")] + public partial ref int Ref { get; } + + [LuaProperty("RefReadonly")] + public partial ref readonly int RefReadonly { get; } + + [LuaProperty("Explicit")] + partial int IContract.Explicit { get; } + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Unsupported.LuaClass.g.cs", run.HintNames[0]); + } + + private static MethodDeclarationSyntax FindGeneratedMethod(CompilationUnitSyntax root, string methodName) + { + MethodDeclarationSyntax? result = null; + foreach (SyntaxNode node in root.DescendantNodes()) + { + if (node is MethodDeclarationSyntax candidate + && string.Equals(candidate.Identifier.ValueText, methodName, StringComparison.Ordinal)) + { + Assert.Null(result); + result = candidate; + } + } + + Assert.NotNull(result); + return result!; + } + + private static TryStatementSyntax FindTryStatement(BlockSyntax body) + { + TryStatementSyntax? result = null; + foreach (StatementSyntax statement in body.Statements) + { + if (statement is TryStatementSyntax candidate) + { + Assert.Null(result); + result = candidate; + } + } + + Assert.NotNull(result); + return result!; + } + + [Fact] + public void Case_only_type_name_difference_gets_stable_distinct_class_hints() + { + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + [LuaClass("Upper")] + public readonly partial struct Case + { + } + + [LuaClass("Lower")] + public readonly partial struct CASE + { + } + """; + + GeneratorRun run = roslyn.Run(source); + + Assert.Equal(2, run.HintNames.Length); + Assert.NotEqual(run.HintNames[0], run.HintNames[1], StringComparer.OrdinalIgnoreCase); + run.AssertCompilesClean(); + } + + [Fact] + public void Globals_generate_without_unsafe_but_functions_remain_gated() + { + GeneratorRun globalsOnly = RoslynFixture.Run(roslyn.CreateCompilation(RoslynEnvironment.SafeCompilationOptions, + BindingSources.Globals)); + Assert.Single(globalsOnly.GeneratedSources); + Assert.Equal("Demo.Memory.LuaGlobals.g.cs", globalsOnly.HintNames[0]); + globalsOnly.AssertCompilesClean(); + + GeneratorRun mixed = RoslynFixture.Run(roslyn.CreateCompilation(RoslynEnvironment.SafeCompilationOptions, + BindingSources.Functions, BindingSources.Globals)); + Assert.Single(mixed.GeneratedSources); + Assert.Equal("Demo.Memory.LuaGlobals.g.cs", mixed.HintNames[0]); + } + + private static AttributeUsageAttribute AttributeUsage(Type attributeType) + { + return (AttributeUsageAttribute) Attribute.GetCustomAttribute(attributeType, typeof(AttributeUsageAttribute))!; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs index edd77dc9..98c41bb8 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -12,497 +14,558 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class NoOutputTests(RoslynFixture roslyn) : IClassFixture { - private const string Usings = - "using System; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State;\n"; - - public static TheoryData InvalidFunctions - { - get - { - var data = new TheoryData(); - foreach (var (shape, source) in FunctionMethodShapeRejections()) data.Add(shape, source); - foreach (var (shape, source) in FunctionContainingTypeRejections()) data.Add(shape, source); - foreach (var (shape, source) in FunctionNameRejections()) data.Add(shape, source); - foreach (var (shape, source) in FunctionParameterTypeRejections()) data.Add(shape, source); - foreach (var (shape, source) in FunctionParameterModifierRejections()) data.Add(shape, source); - foreach (var (shape, source) in FunctionReturnTypeRejections()) data.Add(shape, source); - foreach (var (shape, source) in FunctionDeclarationSiteRejections()) data.Add(shape, source); - return data; - } - } - - public static TheoryData InvalidGlobals - { - get - { - var data = new TheoryData(); - foreach (var (shape, source) in GlobalShapeRejections()) data.Add(shape, source); - foreach (var (shape, source) in GlobalNameRejections()) data.Add(shape, source); - foreach (var (shape, source) in GlobalArgumentRejections()) data.Add(shape, source); - foreach (var (shape, source) in GlobalResultTypeRejections()) data.Add(shape, source); - foreach (var (shape, source) in GlobalCopyOutRejections()) data.Add(shape, source); - foreach (var (shape, source) in GlobalReturnTypeRejections()) data.Add(shape, source); - foreach (var (shape, source) in GlobalTryFormAndMiscRejections()) data.Add(shape, source); - return data; - } - } - - private static IEnumerable<(string Shape, string Source)> FunctionMethodShapeRejections() - { - yield return ( - "instance method", "public static partial class T { [LuaFunction(\"f\")] public int F(int a) => a; }"); - yield return ( - "generic method", - "public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - yield return ( - "async void method", - "public static partial class T { [LuaFunction(\"f\")] public static async void F() { await System.Threading.Tasks.Task.Yield(); } }"); - yield return ( - "async Task method", - "public static partial class T { [LuaFunction(\"f\")] public static async System.Threading.Tasks.Task F() { await System.Threading.Tasks.Task.Yield(); } }"); - } - - private static IEnumerable<(string Shape, string Source)> FunctionContainingTypeRejections() - { - yield return ( - "generic containing type", - "public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - yield return ( - "nested in a generic type", - "public partial class O { public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; } }"); - yield return ( - "containing type not partial", - "public static class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - yield return ( - "outer type not partial", - "public class O { public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; } }"); - yield return ( - "containing type is an interface", - "public partial interface T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - yield return ( - "file-local containing type", - "file static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); - } - - private static IEnumerable<(string Shape, string Source)> FunctionNameRejections() - { - yield return ( - "empty name", "public static partial class T { [LuaFunction(\"\")] public static int F(int a) => a; }"); - yield return ( - "null name", "public static partial class T { [LuaFunction(null!)] public static int F(int a) => a; }"); - yield return ( - "name starting with a digit", - "public static partial class T { [LuaFunction(\"1f\")] public static int F(int a) => a; }"); - yield return ( - "name with a dash", - "public static partial class T { [LuaFunction(\"read-int\")] public static int F(int a) => a; }"); - yield return ( - "name with a dot", - "public static partial class T { [LuaFunction(\"ce.read\")] public static int F(int a) => a; }"); - yield return ( - "reserved word as name", - "public static partial class T { [LuaFunction(\"end\")] public static int F(int a) => a; }"); - yield return ( - "non-ASCII name", - "public static partial class T { [LuaFunction(\"caf\\u00E9\")] public static int F(int a) => a; }"); - } - - private static IEnumerable<(string Shape, string Source)> FunctionParameterTypeRejections() - { - yield return ( - "object parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(object a) => 0; }"); - yield return ( - "nullable int parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(int? a) => 0; }"); - yield return ( - "byte parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(byte a) => a; }"); - yield return ( - "decimal parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(decimal a) => 0; }"); - yield return ( - "Span parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(Span a) => a.Length; }"); - } - - private static IEnumerable<(string Shape, string Source)> FunctionParameterModifierRejections() - { - yield return ( - "state parameter not first", - "public static partial class T { [LuaFunction(\"f\")] public static int F(int a, LuaState L) => a; }"); - yield return ( - "ref parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(ref int a) => a; }"); - yield return ( - "in parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(in int a) => a; }"); - yield return ( - "out parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(out int a) { a = 0; return 0; } }"); - yield return ( - "params parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(params int[] a) => a.Length; }"); - yield return ( - "optional parameter", - "public static partial class T { [LuaFunction(\"f\")] public static int F(int a = 5) => a; }"); - } - - private static IEnumerable<(string Shape, string Source)> FunctionReturnTypeRejections() - { - yield return ( - "object return", - "public static partial class T { [LuaFunction(\"f\")] public static object F(int a) => a; }"); - yield return ( - "array return", - "public static partial class T { [LuaFunction(\"f\")] public static int[] F(int a) => new[] { a }; }"); - yield return ( - "Span return", - "public static partial class T { [LuaFunction(\"f\")] public static Span F(int a) => default; }"); - yield return ( - "nullable int return", - "public static partial class T { [LuaFunction(\"f\")] public static int? F(int a) => a; }"); - yield return ( - "ref return", - "public static partial class T { private static int s_field; [LuaFunction(\"f\")] public static ref int F(int a) => ref s_field; }"); - } - - private static IEnumerable<(string Shape, string Source)> FunctionDeclarationSiteRejections() - { - yield return ( - "duplicate names in one type", - "public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaFunction(\"f\")] public static int G(int a) => a; }"); - yield return ( - "local function", - "public static partial class T { public static int Outer() { [LuaFunction(\"f\")] static int F(int a) => a; return F(1); } }"); - yield return ( - "lambda", - "public static partial class T { public static Func Outer() => [LuaFunction(\"f\")] (int a) => a; }"); - yield return ( - "property accessor", - "public static partial class T { public static int P { [LuaFunction(\"f\")] get => 1; } }"); - yield return ( - "look-alike attribute", - "namespace Other { public sealed class LuaFunctionAttribute : Attribute { public LuaFunctionAttribute(string n) { } } } public static partial class T { [Other.LuaFunction(\"f\")] public static int F(int a) => a; }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalShapeRejections() - { - yield return ( - "not partial", - "public static partial class T { [LuaGlobal(\"g\")] public static bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "already implemented", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "attribute on the implementing part", - "public static partial class T { public static partial bool TryG(nuint a, out int v); [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "instance method", - "public partial class T { [LuaGlobal(\"g\")] public partial bool TryG(nuint a, out int v); public partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "generic method", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "generic containing type", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "containing type not partial", - "public static class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); - yield return ( - "async", - "public static partial class T { [LuaGlobal(\"g\")] public static async partial void G(int a); public static async partial void G(int a) { await System.Threading.Tasks.Task.Yield(); } }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalNameRejections() - { - yield return ("empty name", - "public static partial class T { [LuaGlobal(\"\")] public static partial int G(nuint a); }"); - yield return ( - "reserved word as name", - "public static partial class T { [LuaGlobal(\"function\")] public static partial int G(nuint a); }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalArgumentRejections() - { - yield return ( - "object argument", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(object a); }"); - yield return ( - "byte argument", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(byte a); }"); - yield return ( - "ref argument", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(ref int a); }"); - yield return ( - "in argument", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(in int a); }"); - yield return ( - "params argument", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(params int[] a); }"); - yield return ( - "optional argument", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(int a = 1); }"); - yield return ( - "state parameter not first", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(int a, LuaState L); }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalResultTypeRejections() - { - yield return ( - "object result", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out object v); }"); - yield return ( - "byte result", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out byte v); }"); - yield return ( - "span result", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out ReadOnlySpan v); }"); - yield return ( - "span return", - "public static partial class T { [LuaGlobal(\"g\")] public static partial ReadOnlySpan G(nuint a); }"); - yield return ( - "Span return", - "public static partial class T { [LuaGlobal(\"g\")] public static partial Span G(nuint a); }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalCopyOutRejections() - { - yield return ( - "copy-out destination without a count", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, Span d, out long w); }"); - yield return ( - "copy-out destination alone", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, Span d); }"); - yield return ( - "argument after a result", - "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(out int v, nuint a); }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalReturnTypeRejections() - { - yield return ( - "object return", - "public static partial class T { [LuaGlobal(\"g\")] public static partial object G(nuint a); }"); - yield return ( - "array return", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int[] G(nuint a); }"); - yield return ( - "nullable int return", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int? G(nuint a); }"); - } - - private static IEnumerable<(string Shape, string Source)> GlobalTryFormAndMiscRejections() - { - yield return ( - "Try form returning int", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(nuint a, out int v); }"); - yield return ( - "Try form returning void", - "public static partial class T { [LuaGlobal(\"g\")] public static partial void G(out int v); }"); - yield return ( - "partial property", - "public static partial class T { [LuaGlobal(\"g\")] public static partial int G { get; } public static partial int G => 1; }"); - yield return ( - "look-alike attribute", - "namespace Other { public sealed class LuaGlobalAttribute : Attribute { public LuaGlobalAttribute(string n) { } } } public static partial class T { [Other.LuaGlobal(\"g\")] public static partial int G(nuint a); public static partial int G(nuint a) => 0; }"); - } - - [Theory] - [MemberData(nameof(InvalidFunctions))] - public void Generator_invalid_function_shape_emits_nothing(string shape, string source) - { - Assert.NotEmpty(shape); - - var run = roslyn.Run(Usings + source); - - run.AssertNoOutput(); - } - - [Theory] - [MemberData(nameof(InvalidGlobals))] - public void Generator_invalid_global_shape_emits_nothing(string shape, string source) - { - Assert.NotEmpty(shape); - - var run = roslyn.Run(Usings + source); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_no_attribute_emits_nothing() - { - var run = roslyn.Run(Usings + - "public static partial class T { public static int F(int a) => a; public static partial int G(int a); public static partial int G(int a) => a; }"); - - run.AssertNoOutput(); - } - - [Fact] - public void Generator_same_name_source_LuaState_without_the_sdk_runtime_emits_nothing() - { - var references = ImmutableArray.CreateBuilder(); - references.AddRange(roslyn.Environment.FrameworkReferences); - foreach (var reference in roslyn.Environment.SdkReferences) - { - if (string.Equals(reference.Display, typeof(LuaState).Assembly.Location, - StringComparison.OrdinalIgnoreCase)) - continue; - - references.Add(reference); - } - - const string source = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace CheatEngine.SDK.Lua.State - { - public readonly struct LuaState - { - } - } - - namespace Demo; - - public static partial class Functions - { - [LuaFunction("callback")] - public static int Callback(global::CheatEngine.SDK.Lua.State.LuaState state) => 0; - } - - public static partial class Globals - { - [LuaGlobal("read")] - public static partial int Read(global::CheatEngine.SDK.Lua.State.LuaState state); - } - """; - - var compilation = CSharpCompilation.Create( - RoslynFixture.PluginAssemblyName, - [RoslynFixture.Parse(source, "Source0.cs")], - references.ToImmutable(), - RoslynEnvironment.CompilationOptions); - - RoslynFixture.Run(compilation).AssertNoOutput(); - } - - [Fact] - public void Generator_unsafe_not_allowed_skips_functions_but_emits_globals() - { - var run = RoslynFixture.Run(roslyn.CreateCompilation(RoslynEnvironment.SafeCompilationOptions, - BindingSources.Functions, BindingSources.Globals)); - - Assert.Null(run.Result.Exception); - Assert.Empty(run.GeneratorDiagnostics); - Assert.Single(run.GeneratedSources); - Assert.Equal("Demo.Memory.LuaGlobals.g.cs", run.HintNames[0]); - } - - [Fact] - public void Generator_invalid_member_next_to_a_valid_one_is_skipped_alone() - { - const string Source = Usings + """ - public static partial class T - { - [LuaFunction("good")] public static int Good(int a) => a; - [LuaFunction("bad")] public static int Bad(object a) => 0; - [LuaGlobal("readInteger")] public static partial bool TryRead(nuint a, out int v); - [LuaGlobal("broken")] public static partial bool TryBroken(nuint a, out object v); - } - """; - - var run = roslyn.Run(Source); - - Assert.Equal(2, run.GeneratedSources.Length); - var functions = run.GeneratedText("T.LuaFunctions.g.cs"); - Assert.Contains("__LuaThunk_good", functions, StringComparison.Ordinal); - Assert.DoesNotContain("__LuaThunk_bad", functions, StringComparison.Ordinal); - var globals = run.GeneratedText("T.LuaGlobals.g.cs"); - Assert.Contains("TryRead(nuint a, out int v)", globals, StringComparison.Ordinal); - Assert.DoesNotContain("TryBroken", globals, StringComparison.Ordinal); - Assert.DoesNotContain("s_luaGlobal_broken", globals, StringComparison.Ordinal); - } - - [Fact] - public void Generator_generated_function_identity_collision_skips_only_the_affected_function() - { - const string Source = Usings + """ - public static partial class T - { - [LuaFunction("good")] public static int Good(int a) => a; - [LuaFunction("bad")] public static int Bad(int a) => a; - private static int __LuaThunk_bad(nint handle) => 0; - } - """; - - var run = roslyn.Run(Source); - - var functions = run.SingleGeneratedText; - Assert.Contains("__LuaThunk_good", functions, StringComparison.Ordinal); - Assert.DoesNotContain("__LuaThunk_bad(nint", functions, StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_generated_global_cache_collision_skips_only_the_affected_global() - { - const string Source = Usings + """ - public static partial class T - { - private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_bad = new(); - [LuaGlobal("bad")] public static int Bad(nuint address) => address > 0 ? 1 : 0; - [LuaGlobal("good")] public static partial int Good(nuint address); - } - """; - - var run = roslyn.Run(Source); - - var globals = run.SingleGeneratedText; - Assert.DoesNotContain("Bad(nuint address)", globals, StringComparison.Ordinal); - Assert.Contains("Good(nuint address)", globals, StringComparison.Ordinal); - Assert.DoesNotContain("s_luaGlobal_bad = new", globals, StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_duplicate_name_drops_both_members_but_keeps_the_others() - { - const string Source = Usings + """ - public static partial class T - { - [LuaFunction("twin")] public static int A(int a) => a; - [LuaFunction("twin")] public static int B(int a) => a; - [LuaFunction("single")] public static int C(int a) => a; - } - """; - - var run = roslyn.Run(Source); - - var text = run.SingleGeneratedText; - Assert.DoesNotContain("twin", text, StringComparison.Ordinal); - Assert.Contains("__LuaThunk_single", text, StringComparison.Ordinal); - run.AssertCompilesClean(); - } - - [Fact] - public void Generator_same_global_bound_twice_shares_one_cache_and_is_not_a_duplicate() - { - var run = roslyn.Run(BindingSources.Globals); - - var text = run.SingleGeneratedText; - Assert.Equal(1, Count(text, "private static readonly global::CheatEngine.SDK.Lua.References.LuaRef ")); - Assert.Equal(2, Count(text, "TryPush(__L, global::Demo.Memory.s_luaGlobal_readInteger, \"readInteger\"u8)")); - } - - private static int Count(string text, string needle) - { - var count = 0; - for (var index = text.IndexOf(needle, StringComparison.Ordinal); - index >= 0; - index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) count++; - - return count; - } + private const string Usings = + "using System; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.State;\n"; + + public static TheoryData InvalidFunctions + { + get + { + TheoryData data = new(); + foreach ((string shape, string source) in FunctionMethodShapeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in FunctionContainingTypeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in FunctionNameRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in FunctionParameterTypeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in FunctionParameterModifierRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in FunctionReturnTypeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in FunctionDeclarationSiteRejections()) + { + data.Add(shape, source); + } + + return data; + } + } + + public static TheoryData InvalidGlobals + { + get + { + TheoryData data = new(); + foreach ((string shape, string source) in GlobalShapeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in GlobalNameRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in GlobalArgumentRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in GlobalResultTypeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in GlobalCopyOutRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in GlobalReturnTypeRejections()) + { + data.Add(shape, source); + } + + foreach ((string shape, string source) in GlobalTryFormAndMiscRejections()) + { + data.Add(shape, source); + } + + return data; + } + } + + private static IEnumerable<(string Shape, string Source)> FunctionMethodShapeRejections() + { + yield return ( + "instance method", "public static partial class T { [LuaFunction(\"f\")] public int F(int a) => a; }"); + yield return ( + "generic method", + "public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + yield return ( + "async void method", + "public static partial class T { [LuaFunction(\"f\")] public static async void F() { await System.Threading.Tasks.Task.Yield(); } }"); + yield return ( + "async Task method", + "public static partial class T { [LuaFunction(\"f\")] public static async System.Threading.Tasks.Task F() { await System.Threading.Tasks.Task.Yield(); } }"); + } + + private static IEnumerable<(string Shape, string Source)> FunctionContainingTypeRejections() + { + yield return ( + "generic containing type", + "public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + yield return ( + "nested in a generic type", + "public partial class O { public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; } }"); + yield return ( + "containing type not partial", + "public static class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + yield return ( + "outer type not partial", + "public class O { public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; } }"); + yield return ( + "containing type is an interface", + "public partial interface T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + yield return ( + "file-local containing type", + "file static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + } + + private static IEnumerable<(string Shape, string Source)> FunctionNameRejections() + { + yield return ( + "empty name", "public static partial class T { [LuaFunction(\"\")] public static int F(int a) => a; }"); + yield return ( + "null name", "public static partial class T { [LuaFunction(null!)] public static int F(int a) => a; }"); + yield return ( + "name starting with a digit", + "public static partial class T { [LuaFunction(\"1f\")] public static int F(int a) => a; }"); + yield return ( + "name with a dash", + "public static partial class T { [LuaFunction(\"read-int\")] public static int F(int a) => a; }"); + yield return ( + "name with a dot", + "public static partial class T { [LuaFunction(\"ce.read\")] public static int F(int a) => a; }"); + yield return ( + "reserved word as name", + "public static partial class T { [LuaFunction(\"end\")] public static int F(int a) => a; }"); + yield return ( + "non-ASCII name", + "public static partial class T { [LuaFunction(\"caf\\u00E9\")] public static int F(int a) => a; }"); + } + + private static IEnumerable<(string Shape, string Source)> FunctionParameterTypeRejections() + { + yield return ( + "object parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(object a) => 0; }"); + yield return ( + "nullable int parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(int? a) => 0; }"); + yield return ( + "byte parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(byte a) => a; }"); + yield return ( + "decimal parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(decimal a) => 0; }"); + yield return ( + "Span parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(Span a) => a.Length; }"); + } + + private static IEnumerable<(string Shape, string Source)> FunctionParameterModifierRejections() + { + yield return ( + "state parameter not first", + "public static partial class T { [LuaFunction(\"f\")] public static int F(int a, LuaState L) => a; }"); + yield return ( + "ref parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(ref int a) => a; }"); + yield return ( + "in parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(in int a) => a; }"); + yield return ( + "out parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(out int a) { a = 0; return 0; } }"); + yield return ( + "params parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(params int[] a) => a.Length; }"); + yield return ( + "optional parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(int a = 5) => a; }"); + } + + private static IEnumerable<(string Shape, string Source)> FunctionReturnTypeRejections() + { + yield return ( + "object return", + "public static partial class T { [LuaFunction(\"f\")] public static object F(int a) => a; }"); + yield return ( + "array return", + "public static partial class T { [LuaFunction(\"f\")] public static int[] F(int a) => new[] { a }; }"); + yield return ( + "Span return", + "public static partial class T { [LuaFunction(\"f\")] public static Span F(int a) => default; }"); + yield return ( + "nullable int return", + "public static partial class T { [LuaFunction(\"f\")] public static int? F(int a) => a; }"); + yield return ( + "ref return", + "public static partial class T { private static int s_field; [LuaFunction(\"f\")] public static ref int F(int a) => ref s_field; }"); + } + + private static IEnumerable<(string Shape, string Source)> FunctionDeclarationSiteRejections() + { + yield return ( + "duplicate names in one type", + "public static partial class T { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaFunction(\"f\")] public static int G(int a) => a; }"); + yield return ( + "local function", + "public static partial class T { public static int Outer() { [LuaFunction(\"f\")] static int F(int a) => a; return F(1); } }"); + yield return ( + "lambda", + "public static partial class T { public static Func Outer() => [LuaFunction(\"f\")] (int a) => a; }"); + yield return ( + "property accessor", + "public static partial class T { public static int P { [LuaFunction(\"f\")] get => 1; } }"); + yield return ( + "look-alike attribute", + "namespace Other { public sealed class LuaFunctionAttribute : Attribute { public LuaFunctionAttribute(string n) { } } } public static partial class T { [Other.LuaFunction(\"f\")] public static int F(int a) => a; }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalShapeRejections() + { + yield return ( + "not partial", + "public static partial class T { [LuaGlobal(\"g\")] public static bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "already implemented", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "attribute on the implementing part", + "public static partial class T { public static partial bool TryG(nuint a, out int v); [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "instance method", + "public partial class T { [LuaGlobal(\"g\")] public partial bool TryG(nuint a, out int v); public partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "generic method", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "generic containing type", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "containing type not partial", + "public static class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); public static partial bool TryG(nuint a, out int v) { v = 0; return false; } }"); + yield return ( + "async", + "public static partial class T { [LuaGlobal(\"g\")] public static async partial void G(int a); public static async partial void G(int a) { await System.Threading.Tasks.Task.Yield(); } }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalNameRejections() + { + yield return ("empty name", + "public static partial class T { [LuaGlobal(\"\")] public static partial int G(nuint a); }"); + yield return ( + "reserved word as name", + "public static partial class T { [LuaGlobal(\"function\")] public static partial int G(nuint a); }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalArgumentRejections() + { + yield return ( + "object argument", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(object a); }"); + yield return ( + "byte argument", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(byte a); }"); + yield return ( + "ref argument", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(ref int a); }"); + yield return ( + "in argument", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(in int a); }"); + yield return ( + "params argument", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(params int[] a); }"); + yield return ( + "optional argument", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(int a = 1); }"); + yield return ( + "state parameter not first", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(int a, LuaState L); }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalResultTypeRejections() + { + yield return ( + "object result", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out object v); }"); + yield return ( + "byte result", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out byte v); }"); + yield return ( + "span result", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out ReadOnlySpan v); }"); + yield return ( + "span return", + "public static partial class T { [LuaGlobal(\"g\")] public static partial ReadOnlySpan G(nuint a); }"); + yield return ( + "Span return", + "public static partial class T { [LuaGlobal(\"g\")] public static partial Span G(nuint a); }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalCopyOutRejections() + { + yield return ( + "copy-out destination without a count", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, Span d, out long w); }"); + yield return ( + "copy-out destination alone", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, Span d); }"); + yield return ( + "argument after a result", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(out int v, nuint a); }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalReturnTypeRejections() + { + yield return ( + "object return", + "public static partial class T { [LuaGlobal(\"g\")] public static partial object G(nuint a); }"); + yield return ( + "array return", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int[] G(nuint a); }"); + yield return ( + "nullable int return", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int? G(nuint a); }"); + } + + private static IEnumerable<(string Shape, string Source)> GlobalTryFormAndMiscRejections() + { + yield return ( + "Try form returning int", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(nuint a, out int v); }"); + yield return ( + "Try form returning void", + "public static partial class T { [LuaGlobal(\"g\")] public static partial void G(out int v); }"); + yield return ( + "partial property", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G { get; } public static partial int G => 1; }"); + yield return ( + "look-alike attribute", + "namespace Other { public sealed class LuaGlobalAttribute : Attribute { public LuaGlobalAttribute(string n) { } } } public static partial class T { [Other.LuaGlobal(\"g\")] public static partial int G(nuint a); public static partial int G(nuint a) => 0; }"); + } + + [Theory] + [MemberData(nameof(InvalidFunctions))] + public void Generator_invalid_function_shape_emits_nothing(string shape, string source) + { + Assert.NotEmpty(shape); + + GeneratorRun run = roslyn.Run(Usings + source); + + run.AssertNoOutput(); + } + + [Theory] + [MemberData(nameof(InvalidGlobals))] + public void Generator_invalid_global_shape_emits_nothing(string shape, string source) + { + Assert.NotEmpty(shape); + + GeneratorRun run = roslyn.Run(Usings + source); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_no_attribute_emits_nothing() + { + GeneratorRun run = roslyn.Run(Usings + + "public static partial class T { public static int F(int a) => a; public static partial int G(int a); public static partial int G(int a) => a; }"); + + run.AssertNoOutput(); + } + + [Fact] + public void Generator_same_name_source_LuaState_without_the_sdk_runtime_emits_nothing() + { + ImmutableArray.Builder references = ImmutableArray.CreateBuilder(); + references.AddRange(roslyn.Environment.FrameworkReferences); + foreach (MetadataReference reference in roslyn.Environment.SdkReferences) + { + if (string.Equals(reference.Display, typeof(LuaState).Assembly.Location, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + references.Add(reference); + } + + const string source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Lua.State + { + public readonly struct LuaState + { + } + } + + namespace Demo; + + public static partial class Functions + { + [LuaFunction("callback")] + public static int Callback(global::CheatEngine.SDK.Lua.State.LuaState state) => 0; + } + + public static partial class Globals + { + [LuaGlobal("read")] + public static partial int Read(global::CheatEngine.SDK.Lua.State.LuaState state); + } + """; + + CSharpCompilation compilation = CSharpCompilation.Create( + RoslynFixture.PluginAssemblyName, + [RoslynFixture.Parse(source, "Source0.cs")], + references.ToImmutable(), + RoslynEnvironment.CompilationOptions); + + RoslynFixture.Run(compilation).AssertNoOutput(); + } + + [Fact] + public void Generator_unsafe_not_allowed_skips_functions_but_emits_globals() + { + GeneratorRun run = RoslynFixture.Run(roslyn.CreateCompilation(RoslynEnvironment.SafeCompilationOptions, + BindingSources.Functions, BindingSources.Globals)); + + Assert.Null(run.Result.Exception); + Assert.Empty(run.GeneratorDiagnostics); + Assert.Single(run.GeneratedSources); + Assert.Equal("Demo.Memory.LuaGlobals.g.cs", run.HintNames[0]); + } + + [Fact] + public void Generator_invalid_member_next_to_a_valid_one_is_skipped_alone() + { + const string Source = Usings + """ + public static partial class T + { + [LuaFunction("good")] public static int Good(int a) => a; + [LuaFunction("bad")] public static int Bad(object a) => 0; + [LuaGlobal("readInteger")] public static partial bool TryRead(nuint a, out int v); + [LuaGlobal("broken")] public static partial bool TryBroken(nuint a, out object v); + } + """; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Equal(2, run.GeneratedSources.Length); + string functions = run.GeneratedText("T.LuaFunctions.g.cs"); + Assert.Contains("__LuaThunk_good", functions, StringComparison.Ordinal); + Assert.DoesNotContain("__LuaThunk_bad", functions, StringComparison.Ordinal); + string globals = run.GeneratedText("T.LuaGlobals.g.cs"); + Assert.Contains("TryRead(nuint a, out int v)", globals, StringComparison.Ordinal); + Assert.DoesNotContain("TryBroken", globals, StringComparison.Ordinal); + Assert.DoesNotContain("s_luaGlobal_broken", globals, StringComparison.Ordinal); + } + + [Fact] + public void Generator_generated_function_identity_collision_skips_only_the_affected_function() + { + const string Source = Usings + """ + public static partial class T + { + [LuaFunction("good")] public static int Good(int a) => a; + [LuaFunction("bad")] public static int Bad(int a) => a; + private static int __LuaThunk_bad(nint handle) => 0; + } + """; + + GeneratorRun run = roslyn.Run(Source); + + string functions = run.SingleGeneratedText; + Assert.Contains("__LuaThunk_good", functions, StringComparison.Ordinal); + Assert.DoesNotContain("__LuaThunk_bad(nint", functions, StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_generated_global_cache_collision_skips_only_the_affected_global() + { + const string Source = Usings + """ + public static partial class T + { + private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_bad = new(); + [LuaGlobal("bad")] public static int Bad(nuint address) => address > 0 ? 1 : 0; + [LuaGlobal("good")] public static partial int Good(nuint address); + } + """; + + GeneratorRun run = roslyn.Run(Source); + + string globals = run.SingleGeneratedText; + Assert.DoesNotContain("Bad(nuint address)", globals, StringComparison.Ordinal); + Assert.Contains("Good(nuint address)", globals, StringComparison.Ordinal); + Assert.DoesNotContain("s_luaGlobal_bad = new", globals, StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_duplicate_name_drops_both_members_but_keeps_the_others() + { + const string Source = Usings + """ + public static partial class T + { + [LuaFunction("twin")] public static int A(int a) => a; + [LuaFunction("twin")] public static int B(int a) => a; + [LuaFunction("single")] public static int C(int a) => a; + } + """; + + GeneratorRun run = roslyn.Run(Source); + + string text = run.SingleGeneratedText; + Assert.DoesNotContain("twin", text, StringComparison.Ordinal); + Assert.Contains("__LuaThunk_single", text, StringComparison.Ordinal); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_same_global_bound_twice_shares_one_cache_and_is_not_a_duplicate() + { + GeneratorRun run = roslyn.Run(BindingSources.Globals); + + string text = run.SingleGeneratedText; + Assert.Equal(1, Count(text, "private static readonly global::CheatEngine.SDK.Lua.References.LuaRef ")); + Assert.Equal(2, Count(text, "TryPush(__L, global::Demo.Memory.s_luaGlobal_readInteger, \"readInteger\"u8)")); + } + + private static int Count(string text, string needle) + { + int count = 0; + for (int index = text.IndexOf(needle, StringComparison.Ordinal); + index >= 0; + index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs index c2c9f9e1..6fa31a12 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs @@ -9,128 +9,128 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Generator; /// public sealed class PartialMethodSignatureTests(RoslynFixture roslyn) : IClassFixture { - private const string Usings = "using CheatEngine.SDK.Annotations.Lua;\n"; - - [Fact] - public void Generator_repeats_an_explicit_scoped_readonlyspan_argument() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(scoped System.ReadOnlySpan data, out int v); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("public static partial bool TryG(scoped global::System.ReadOnlySpan data, out int v)", - run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_omits_scoped_when_the_defining_declaration_did() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(System.ReadOnlySpan data, out int v); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains("public static partial bool TryG(global::System.ReadOnlySpan data, out int v)", text, - StringComparison.Ordinal); - Assert.DoesNotContain("scoped global::System.ReadOnlySpan", text, StringComparison.Ordinal); - } - - [Fact] - public void Generator_repeats_an_explicit_scoped_copyout_destination() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped System.Span destination, out int written); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("scoped global::System.Span destination, out int written", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_omits_scoped_on_the_copyout_destination_when_the_defining_declaration_did() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, System.Span destination, out int written); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - var text = run.SingleGeneratedText; - Assert.Contains("global::System.Span destination, out int written", text, StringComparison.Ordinal); - Assert.DoesNotContain("scoped global::System.Span", text, StringComparison.Ordinal); - } - - [Fact] - public void Generator_scoped_out_result_of_a_non_ref_struct_type_needs_no_special_handling() - { - // 'scoped' on an 'out' parameter of a non-ref-struct type does not affect partial-signature matching (the - // compiler accepts a mismatch there), so the emitter never needs to repeat it; this pins that down. - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped out int v); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("public static partial bool TryG(nuint a, out int v)", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_preserves_the_extension_receiver_in_the_implementing_declaration() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this nuint address, out int value); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("public static partial bool TryG(this nuint address, out int value)", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_preserves_the_extension_receiver_on_a_copyout_destination() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this System.Span destination, out int written); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("public static partial bool TryG(this global::System.Span destination, out int written)", - run.SingleGeneratedText, StringComparison.Ordinal); - } - - [Fact] - public void Generator_qualifies_the_cache_when_a_parameter_uses_its_name() - { - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial void G(int s_luaGlobal_g); }"; - - var run = roslyn.Run(Source); - - run.AssertCompilesClean(); - Assert.Contains("TryPush(__L, global::Demo.Holder.s_luaGlobal_g, \"g\"u8)", run.SingleGeneratedText, - StringComparison.Ordinal); - } - - [Fact] - public void Generator_parameter_named_like_a_generated_local_is_skipped_without_poisoning_compilation() - { - // A generated partial body shares its parameter scope with the defining declaration. Do not emit CS0136 and - // leave the analyzer to report the precise CESDK2007 collision at the author declaration. - const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint __L, out int v); }"; - - var run = roslyn.Run(Source); - - Assert.Empty(run.GeneratedSources); - Assert.Empty(run.GeneratorDiagnostics); - } + private const string Usings = "using CheatEngine.SDK.Annotations.Lua;\n"; + + [Fact] + public void Generator_repeats_an_explicit_scoped_readonlyspan_argument() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(scoped System.ReadOnlySpan data, out int v); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("public static partial bool TryG(scoped global::System.ReadOnlySpan data, out int v)", + run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_omits_scoped_when_the_defining_declaration_did() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(System.ReadOnlySpan data, out int v); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains("public static partial bool TryG(global::System.ReadOnlySpan data, out int v)", text, + StringComparison.Ordinal); + Assert.DoesNotContain("scoped global::System.ReadOnlySpan", text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_repeats_an_explicit_scoped_copyout_destination() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped System.Span destination, out int written); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("scoped global::System.Span destination, out int written", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_omits_scoped_on_the_copyout_destination_when_the_defining_declaration_did() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, System.Span destination, out int written); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains("global::System.Span destination, out int written", text, StringComparison.Ordinal); + Assert.DoesNotContain("scoped global::System.Span", text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_scoped_out_result_of_a_non_ref_struct_type_needs_no_special_handling() + { + // 'scoped' on an 'out' parameter of a non-ref-struct type does not affect partial-signature matching (the + // compiler accepts a mismatch there), so the emitter never needs to repeat it; this pins that down. + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped out int v); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("public static partial bool TryG(nuint a, out int v)", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_preserves_the_extension_receiver_in_the_implementing_declaration() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this nuint address, out int value); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("public static partial bool TryG(this nuint address, out int value)", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_preserves_the_extension_receiver_on_a_copyout_destination() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this System.Span destination, out int written); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("public static partial bool TryG(this global::System.Span destination, out int written)", + run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_qualifies_the_cache_when_a_parameter_uses_its_name() + { + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial void G(int s_luaGlobal_g); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains("TryPush(__L, global::Demo.Holder.s_luaGlobal_g, \"g\"u8)", run.SingleGeneratedText, + StringComparison.Ordinal); + } + + [Fact] + public void Generator_parameter_named_like_a_generated_local_is_skipped_without_poisoning_compilation() + { + // A generated partial body shares its parameter scope with the defining declaration. Do not emit CS0136 and + // leave the analyzer to report the precise CESDK2007 collision at the author declaration. + const string Source = Usings + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint __L, out int v); }"; + + GeneratorRun run = roslyn.Run(Source); + + Assert.Empty(run.GeneratedSources); + Assert.Empty(run.GeneratorDiagnostics); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/AllocationGate.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/AllocationGate.cs index 7007fca7..bf39cfdc 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/AllocationGate.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/AllocationGate.cs @@ -8,16 +8,22 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal static class AllocationGate { - public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) - { - for (var i = 0; i < warmUp; i++) body(); + public static void AssertZero(Action body, int iterations = 2_000, int warmUp = 64) + { + for (int i = 0; i < warmUp; i++) + { + body(); + } - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < iterations; i++) body(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + body(); + } - var allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(allocated == 0, - string.Create(CultureInfo.InvariantCulture, - $"{allocated} bytes were allocated over {iterations} iterations ({(double)allocated / iterations:F1} per call).")); - } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated == 0, + string.Create(CultureInfo.InvariantCulture, + $"{allocated} bytes were allocated over {iterations} iterations ({(double) allocated / iterations:F1} per call).")); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/BindingSources.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/BindingSources.cs index 571437ba..6aaa755c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/BindingSources.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/BindingSources.cs @@ -3,181 +3,181 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// Binding declarations that compile warning-free against the real SDK assemblies (CS1591 aside). internal static class BindingSources { - /// Two exported functions: the nominal case whose exact output pins. - public const string Functions = """ - using CheatEngine.SDK.Annotations.Lua; + /// Two exported functions: the nominal case whose exact output pins. + public const string Functions = """ + using CheatEngine.SDK.Annotations.Lua; - namespace Demo; + namespace Demo; - public static partial class Functions - { - [LuaFunction("add")] - public static long Add(long a, long b) => a + b; + public static partial class Functions + { + [LuaFunction("add")] + public static long Add(long a, long b) => a + b; - [LuaFunction("greet")] - public static string Greet(string name) => "hello, " + name; - } - """; + [LuaFunction("greet")] + public static string Greet(string name) => "hello, " + name; + } + """; - /// The worked example: a Try form and a throwing form of the same global, sharing one cache. - public const string Globals = """ - using CheatEngine.SDK.Annotations.Lua; + /// The worked example: a Try form and a throwing form of the same global, sharing one cache. + public const string Globals = """ + using CheatEngine.SDK.Annotations.Lua; - namespace Demo; + namespace Demo; - public static partial class Memory - { - [LuaGlobal("readInteger")] - public static partial bool TryReadInt32(nuint address, out int value); + public static partial class Memory + { + [LuaGlobal("readInteger")] + public static partial bool TryReadInt32(nuint address, out int value); - [LuaGlobal("readInteger")] - public static partial int ReadInt32(nuint address); - } - """; + [LuaGlobal("readInteger")] + public static partial int ReadInt32(nuint address); + } + """; - /// Every supported shape of an exported function, for execution against a real Lua. - public const string FunctionSuite = """ - using System; - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.State; + /// Every supported shape of an exported function, for execution against a real Lua. + public const string FunctionSuite = """ + using System; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.State; - namespace Demo; + namespace Demo; - public static partial class Suite - { - [LuaFunction("add")] - public static long Add(long a, long b) => a + b; + public static partial class Suite + { + [LuaFunction("add")] + public static long Add(long a, long b) => a + b; - [LuaFunction("greet")] - public static string Greet(string name) => "hello, " + name; + [LuaFunction("greet")] + public static string Greet(string name) => "hello, " + name; - [LuaFunction("ping")] - public static void Ping() => Pings++; + [LuaFunction("ping")] + public static void Ping() => Pings++; - public static int Pings; + public static int Pings; - [LuaFunction("isint")] - public static bool IsInteger(LuaState L, double value) => L.IsInteger(1); + [LuaFunction("isint")] + public static bool IsInteger(LuaState L, double value) => L.IsInteger(1); - [LuaFunction("boom")] - public static int Boom() => throw new InvalidOperationException("managed boom"); + [LuaFunction("boom")] + public static int Boom() => throw new InvalidOperationException("managed boom"); - [LuaFunction("echo")] - public static ReadOnlySpan Echo(ReadOnlySpan text) => text; + [LuaFunction("echo")] + public static ReadOnlySpan Echo(ReadOnlySpan text) => text; - [LuaFunction("half")] - public static double Half(double value) => value / 2; + [LuaFunction("half")] + public static double Half(double value) => value / 2; - [LuaFunction("negate")] - public static bool Negate(bool value) => !value; + [LuaFunction("negate")] + public static bool Negate(bool value) => !value; - [LuaFunction("step")] - public static nuint Step(nuint address) => address + 4; + [LuaFunction("step")] + public static nuint Step(nuint address) => address + 4; - [LuaFunction("small")] - public static int Small(int value) => value; + [LuaFunction("small")] + public static int Small(int value) => value; - [LuaFunction("scale")] - public static float Scale(float value) => value * 2; + [LuaFunction("scale")] + public static float Scale(float value) => value * 2; - [LuaFunction("maybe")] - public static string? Maybe(bool give) => give ? "yes" : null; - } - """; + [LuaFunction("maybe")] + public static string? Maybe(bool give) => give ? "yes" : null; + } + """; - /// Every supported shape of a bound global, for execution against stand-in Lua globals. - public const string GlobalSuite = """ - using System; - using System.Diagnostics.CodeAnalysis; - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Calls; - using CheatEngine.SDK.Lua.State; + /// Every supported shape of a bound global, for execution against stand-in Lua globals. + public const string GlobalSuite = """ + using System; + using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.State; - namespace Demo; + namespace Demo; - public static partial class Bindings - { - [LuaGlobal("readInteger")] - public static partial bool TryReadInt32(nuint address, out int value); + public static partial class Bindings + { + [LuaGlobal("readInteger")] + public static partial bool TryReadInt32(nuint address, out int value); - [LuaGlobal("readInteger")] - public static partial LuaOperationStatus TryReadInt32Detailed(nuint address, out int value); + [LuaGlobal("readInteger")] + public static partial LuaOperationStatus TryReadInt32Detailed(nuint address, out int value); - [LuaGlobal("readInteger")] - public static partial int ReadInt32(nuint address); + [LuaGlobal("readInteger")] + public static partial int ReadInt32(nuint address); - [LuaGlobal("beep")] - public static partial LuaOperationStatus BeepDetailed(); + [LuaGlobal("beep")] + public static partial LuaOperationStatus BeepDetailed(); - [LuaGlobal("readString")] - public static partial bool TryReadString(nuint address, int maxLength, Span destination, out int written); + [LuaGlobal("readString")] + public static partial bool TryReadString(nuint address, int maxLength, Span destination, out int written); - [LuaGlobal("readString")] - public static partial bool TryReadString(nuint address, int maxLength, [MaybeNullWhen(false)] out string value); + [LuaGlobal("readString")] + public static partial bool TryReadString(nuint address, int maxLength, [MaybeNullWhen(false)] out string value); - [LuaGlobal("readString")] - public static partial string ReadString(nuint address, int maxLength); + [LuaGlobal("readString")] + public static partial string ReadString(nuint address, int maxLength); - [LuaGlobal("beep")] - public static partial void Beep(); + [LuaGlobal("beep")] + public static partial void Beep(); - [LuaGlobal("isKeyPressed")] - public static partial bool IsKeyPressed(int key); + [LuaGlobal("isKeyPressed")] + public static partial bool IsKeyPressed(int key); - [LuaGlobal("divide")] - public static partial bool TryDivide(long dividend, long divisor, out long quotient, out long remainder); + [LuaGlobal("divide")] + public static partial bool TryDivide(long dividend, long divisor, out long quotient, out long remainder); - [LuaGlobal("describe")] - public static partial bool TryDescribe(double value, bool flag, [MaybeNullWhen(false)] out string text, out double doubled); + [LuaGlobal("describe")] + public static partial bool TryDescribe(double value, bool flag, [MaybeNullWhen(false)] out string text, out double doubled); - [LuaGlobal("add")] - public static partial long AddOn(LuaState state, long a, long b); + [LuaGlobal("add")] + public static partial long AddOn(LuaState state, long a, long b); - [LuaGlobal("add")] - public static partial bool TryAddOn(LuaState state, long a, long b, out long sum); + [LuaGlobal("add")] + public static partial bool TryAddOn(LuaState state, long a, long b, out long sum); - [LuaGlobal("upper")] - public static partial string Upper(ReadOnlySpan text); + [LuaGlobal("upper")] + public static partial string Upper(ReadOnlySpan text); - [LuaGlobal("upper")] - public static partial string? UpperOrNull(string? text); - } - """; + [LuaGlobal("upper")] + public static partial string? UpperOrNull(string? text); + } + """; - /// A function with more arguments than the guaranteed free slots: the body checks the stack first. - public const string ManyArguments = """ - using CheatEngine.SDK.Annotations.Lua; + /// A function with more arguments than the guaranteed free slots: the body checks the stack first. + public const string ManyArguments = """ + using CheatEngine.SDK.Annotations.Lua; - namespace Demo; + namespace Demo; - public static partial class Wide - { - [LuaGlobal("sum16")] - public static partial bool TrySum16( - int a0, int a1, int a2, int a3, int a4, int a5, int a6, int a7, - int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15, - out long sum); + public static partial class Wide + { + [LuaGlobal("sum16")] + public static partial bool TrySum16( + int a0, int a1, int a2, int a3, int a4, int a5, int a6, int a7, + int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15, + out long sum); - [LuaGlobal("sum16")] - public static partial long Sum16( - int a0, int a1, int a2, int a3, int a4, int a5, int a6, int a7, - int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15); - } - """; + [LuaGlobal("sum16")] + public static partial long Sum16( + int a0, int a1, int a2, int a3, int a4, int a5, int a6, int a7, + int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15); + } + """; - /// Both binding kinds in one type: two files. - public const string Mixed = """ - using CheatEngine.SDK.Annotations.Lua; + /// Both binding kinds in one type: two files. + public const string Mixed = """ + using CheatEngine.SDK.Annotations.Lua; - namespace Demo; + namespace Demo; - public static partial class Mixed - { - [LuaFunction("twice")] - public static int Twice(int value) => value * 2; + public static partial class Mixed + { + [LuaFunction("twice")] + public static int Twice(int value) => value * 2; - [LuaGlobal("readInteger")] - public static partial bool TryReadInt32(nuint address, out int value); - } - """; + [LuaGlobal("readInteger")] + public static partial bool TryReadInt32(nuint address, out int value); + } + """; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ExpectedFiles.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ExpectedFiles.cs index a91dabc5..c31bbf49 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ExpectedFiles.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ExpectedFiles.cs @@ -1,3 +1,5 @@ +using System.Reflection; + namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// @@ -7,274 +9,274 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal static class ExpectedFiles { - /// Hint name of the file for . - public const string FunctionsHintName = "Demo.Functions.LuaFunctions.g.cs"; - - /// Hint name of the file for . - public const string GlobalsHintName = "Demo.Memory.LuaGlobals.g.cs"; - - private static readonly string GeneratedCode = CreateGeneratedCodeLine(); - - private static readonly string FunctionsText = Normalise($$""" - // - #nullable enable - - #pragma warning disable CS0612, CS0618 // a [LuaFunction] target may be marked [Obsolete] - - namespace Demo - { - partial class Functions - { - /// - /// Registers every [LuaFunction] of this type as an ownership-aware global lease: add, greet. - /// - /// The calling thread's state of the attached Lua runtime. - /// The explicit policy for effective globals that already exist. - /// A factual registration result and a lease that releases only values this call still owns. - {{GeneratedCode}} - public static unsafe global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state, global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting) - { - return global::CheatEngine.SDK.Lua.Registration.LuaRegistrationSet.Register(state, - [ - new global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry("add", new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_add)), - new global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry("greet", new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_greet)), - ], - collisionPolicy); - } - - /// - /// Registers every [LuaFunction] of this type as a global of : add, greet. - /// Stack: +0 on success; +1 (the error value) on failure, as for every protected operation. - /// - /// The state to register on; the calling thread's. Requires the plugin to be enabled. - /// The status of the first failing operation, or LuaStatus.Ok. - {{GeneratedCode}} - public static unsafe global::CheatEngine.SDK.Lua.Calls.LuaStatus RegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state) - { - global::CheatEngine.SDK.Lua.Calls.LuaStatus __status; - __status = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(state, new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_add)); - if (!__status.IsOk) - { - return __status; - } - - __status = state.TrySetGlobal("add"u8); - if (!__status.IsOk) - { - return __status; - } - - __status = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(state, new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_greet)); - if (!__status.IsOk) - { - return __status; - } - - __status = state.TrySetGlobal("greet"u8); - if (!__status.IsOk) - { - return __status; - } - - return global::CheatEngine.SDK.Lua.Calls.LuaStatus.Ok; - } - - /// - /// Assigns nil to every global that registers: add, greet. - /// Stack: +0 on success; +1 (the error value) on failure, as for every protected operation. - /// - /// The state to unregister from; the calling thread's. - /// The status of the first failing assignment, or LuaStatus.Ok. - {{GeneratedCode}} - public static global::CheatEngine.SDK.Lua.Calls.LuaStatus UnregisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state) - { - global::CheatEngine.SDK.Lua.Calls.LuaStatus __status; - state.PushNil(); - __status = state.TrySetGlobal("add"u8); - if (!__status.IsOk) - { - return __status; - } - - state.PushNil(); - __status = state.TrySetGlobal("greet"u8); - if (!__status.IsOk) - { - return __status; - } - - return global::CheatEngine.SDK.Lua.Calls.LuaStatus.Ok; - } - - {{GeneratedCode}} - [global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] - private static int __LuaThunk_add(nint __handle) - { - global::CheatEngine.SDK.Lua.State.LuaState __L = new(__handle); - try - { - if (__L.Top != 2) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, "wrong number of arguments to 'add' (2 expected)"u8); - } - - if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, 1, out long __arg0)) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 1, "integer"u8); - } - - if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, 2, out long __arg1)) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 2, "integer"u8); - } - - long __result = global::Demo.Functions.Add(__arg0, __arg1); - global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Push(__L, __result); - return 1; - } - catch (global::System.Exception __exception) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception); - } - } - - {{GeneratedCode}} - [global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] - private static int __LuaThunk_greet(nint __handle) - { - global::CheatEngine.SDK.Lua.State.LuaState __L = new(__handle); - try - { - if (__L.Top != 1) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, "wrong number of arguments to 'greet' (1 expected)"u8); - } - - if (!global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(__L, 1, out string? __arg0)) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 1, "string"u8); - } - - string? __result = global::Demo.Functions.Greet(__arg0); - global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__L, __result); - return 1; - } - catch (global::System.Exception __exception) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception); - } - } - } - } - - """); - - private static readonly string GlobalsText = Normalise($$""" - // - #nullable enable - - namespace Demo - { - partial class Memory - { - {{GeneratedCode}} - private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_readInteger = new(); - - {{GeneratedCode}} - public static partial int ReadInt32(nuint address) - { - using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); - global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; - int __top = __L.Top; - try - { - if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, global::Demo.Memory.s_luaGlobal_readInteger, "readInteger"u8)) - { - global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "readInteger"); - } - - global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); - global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(1, 1); - if (!__status.IsOk) - { - global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); - } - - if (!global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out int __result)) - { - global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(__L, __top, -1, "readInteger", "an integer"); - } - - return __result; - } - finally - { - __L.SetTop(__top); - } - } - - {{GeneratedCode}} - public static partial bool TryReadInt32(nuint address, out int value) - { - using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); - global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; - int __top = __L.Top; - try - { - if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, global::Demo.Memory.s_luaGlobal_readInteger, "readInteger"u8)) - { - return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); - } - - global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); - if (!__L.TryCall(1, 1).IsOk) - { - return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); - } - - bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); - return __ok; - } - catch (global::CheatEngine.SDK.Lua.Calls.LuaException) - { - value = default; - return false; - } - finally - { - __L.SetTop(__top); - } - } - } - } - - """); - - /// - /// The file for : registration pair, then the thunks of add and - /// greet. - /// - public static string Functions() - { - return FunctionsText; - } - - /// - /// The file for : one cache, the Try body, the throwing body. - /// - public static string Globals() - { - return GlobalsText; - } - - private static string CreateGeneratedCodeLine() - { - var generator = typeof(LuaBindingsGenerator).Assembly.GetName(); - return $"[global::System.CodeDom.Compiler.GeneratedCode(\"{generator.Name}\", \"{generator.Version}\")]"; - } - - private static string Normalise(string text) - { - return text.Replace("\r\n", "\n", StringComparison.Ordinal); - } + /// Hint name of the file for . + public const string FunctionsHintName = "Demo.Functions.LuaFunctions.g.cs"; + + /// Hint name of the file for . + public const string GlobalsHintName = "Demo.Memory.LuaGlobals.g.cs"; + + private static readonly string GeneratedCode = CreateGeneratedCodeLine(); + + private static readonly string FunctionsText = Normalise($$""" + // + #nullable enable + + #pragma warning disable CS0612, CS0618 // a [LuaFunction] target may be marked [Obsolete] + + namespace Demo + { + partial class Functions + { + /// + /// Registers every [LuaFunction] of this type as an ownership-aware global lease: add, greet. + /// + /// The calling thread's state of the attached Lua runtime. + /// The explicit policy for effective globals that already exist. + /// A factual registration result and a lease that releases only values this call still owns. + {{GeneratedCode}} + public static unsafe global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state, global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting) + { + return global::CheatEngine.SDK.Lua.Registration.LuaRegistrationSet.Register(state, + [ + new global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry("add", new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_add)), + new global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry("greet", new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_greet)), + ], + collisionPolicy); + } + + /// + /// Registers every [LuaFunction] of this type as a global of : add, greet. + /// Stack: +0 on success; +1 (the error value) on failure, as for every protected operation. + /// + /// The state to register on; the calling thread's. Requires the plugin to be enabled. + /// The status of the first failing operation, or LuaStatus.Ok. + {{GeneratedCode}} + public static unsafe global::CheatEngine.SDK.Lua.Calls.LuaStatus RegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state) + { + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status; + __status = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(state, new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_add)); + if (!__status.IsOk) + { + return __status; + } + + __status = state.TrySetGlobal("add"u8); + if (!__status.IsOk) + { + return __status; + } + + __status = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(state, new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_greet)); + if (!__status.IsOk) + { + return __status; + } + + __status = state.TrySetGlobal("greet"u8); + if (!__status.IsOk) + { + return __status; + } + + return global::CheatEngine.SDK.Lua.Calls.LuaStatus.Ok; + } + + /// + /// Assigns nil to every global that registers: add, greet. + /// Stack: +0 on success; +1 (the error value) on failure, as for every protected operation. + /// + /// The state to unregister from; the calling thread's. + /// The status of the first failing assignment, or LuaStatus.Ok. + {{GeneratedCode}} + public static global::CheatEngine.SDK.Lua.Calls.LuaStatus UnregisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state) + { + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status; + state.PushNil(); + __status = state.TrySetGlobal("add"u8); + if (!__status.IsOk) + { + return __status; + } + + state.PushNil(); + __status = state.TrySetGlobal("greet"u8); + if (!__status.IsOk) + { + return __status; + } + + return global::CheatEngine.SDK.Lua.Calls.LuaStatus.Ok; + } + + {{GeneratedCode}} + [global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] + private static int __LuaThunk_add(nint __handle) + { + global::CheatEngine.SDK.Lua.State.LuaState __L = new(__handle); + try + { + if (__L.Top != 2) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, "wrong number of arguments to 'add' (2 expected)"u8); + } + + if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, 1, out long __arg0)) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 1, "integer"u8); + } + + if (!global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, 2, out long __arg1)) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 2, "integer"u8); + } + + long __result = global::Demo.Functions.Add(__arg0, __arg1); + global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Push(__L, __result); + return 1; + } + catch (global::System.Exception __exception) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception); + } + } + + {{GeneratedCode}} + [global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] + private static int __LuaThunk_greet(nint __handle) + { + global::CheatEngine.SDK.Lua.State.LuaState __L = new(__handle); + try + { + if (__L.Top != 1) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, "wrong number of arguments to 'greet' (1 expected)"u8); + } + + if (!global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(__L, 1, out string? __arg0)) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 1, "string"u8); + } + + string? __result = global::Demo.Functions.Greet(__arg0); + global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__L, __result); + return 1; + } + catch (global::System.Exception __exception) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception); + } + } + } + } + + """); + + private static readonly string GlobalsText = Normalise($$""" + // + #nullable enable + + namespace Demo + { + partial class Memory + { + {{GeneratedCode}} + private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_readInteger = new(); + + {{GeneratedCode}} + public static partial int ReadInt32(nuint address) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, global::Demo.Memory.s_luaGlobal_readInteger, "readInteger"u8)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "readInteger"); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(1, 1); + if (!__status.IsOk) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); + } + + if (!global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out int __result)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(__L, __top, -1, "readInteger", "an integer"); + } + + return __result; + } + finally + { + __L.SetTop(__top); + } + } + + {{GeneratedCode}} + public static partial bool TryReadInt32(nuint address, out int value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, global::Demo.Memory.s_luaGlobal_readInteger, "readInteger"u8)) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + if (!__L.TryCall(1, 1).IsOk) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); + return __ok; + } + catch (global::CheatEngine.SDK.Lua.Calls.LuaException) + { + value = default; + return false; + } + finally + { + __L.SetTop(__top); + } + } + } + } + + """); + + /// + /// The file for : registration pair, then the thunks of add and + /// greet. + /// + public static string Functions() + { + return FunctionsText; + } + + /// + /// The file for : one cache, the Try body, the throwing body. + /// + public static string Globals() + { + return GlobalsText; + } + + private static string CreateGeneratedCodeLine() + { + AssemblyName generator = typeof(LuaBindingsGenerator).Assembly.GetName(); + return $"[global::System.CodeDom.Compiler.GeneratedCode(\"{generator.Name}\", \"{generator.Version}\")]"; + } + + private static string Normalise(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratedAssembly.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratedAssembly.cs index 2298a862..77195318 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratedAssembly.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratedAssembly.cs @@ -2,6 +2,8 @@ using System.Reflection; using System.Runtime.Loader; +using Microsoft.CodeAnalysis.Emit; + namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// @@ -17,67 +19,70 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal sealed class GeneratedAssembly { - private const BindingFlags StaticMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + private const BindingFlags StaticMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; - private static int s_counter; + private static int s_counter; - private GeneratedAssembly(Assembly assembly) - { - Assembly = assembly; - } + private GeneratedAssembly(Assembly assembly) + { + Assembly = assembly; + } - /// The loaded assembly. - public Assembly Assembly { get; } + /// The loaded assembly. + public Assembly Assembly + { + get; + } - /// - /// Compiles, emits and loads 's output; fails the test when it does not compile clean or - /// emit. - /// - public static GeneratedAssembly Load(GeneratorRun run) - { - run.AssertCompilesClean(); + /// + /// Compiles, emits and loads 's output; fails the test when it does not compile clean or + /// emit. + /// + public static GeneratedAssembly Load(GeneratorRun run) + { + run.AssertCompilesClean(); - using MemoryStream image = new(); - var result = run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(result.Success, "The output compilation does not emit:\n" + string.Join('\n', result.Diagnostics)); - image.Position = 0; + using MemoryStream image = new(); + EmitResult result = run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, "The output compilation does not emit:\n" + string.Join('\n', result.Diagnostics)); + image.Position = 0; - var name = string.Create( - CultureInfo.InvariantCulture, - $"CheatEngine.SDK.LuaBindings.Tests.{Interlocked.Increment(ref s_counter)}"); - AssemblyLoadContext context = new(name); - return new GeneratedAssembly(context.LoadFromStream(image)); - } + string name = string.Create( + CultureInfo.InvariantCulture, + $"CheatEngine.SDK.LuaBindings.Tests.{Interlocked.Increment(ref s_counter)}"); + AssemblyLoadContext context = new(name); + return new GeneratedAssembly(context.LoadFromStream(image)); + } - /// - /// The static method of ; - /// picks an overload. - /// - public MethodInfo Method(string typeName, string methodName, Type[]? parameterTypes = null) - { - var type = Assembly.GetType(typeName, true)!; - var method = parameterTypes is null - ? type.GetMethod(methodName, StaticMembers) - : type.GetMethod(methodName, StaticMembers, parameterTypes); - return method ?? throw new MissingMethodException(typeName, methodName); - } + /// + /// The static method of ; + /// picks an overload. + /// + public MethodInfo Method(string typeName, string methodName, Type[]? parameterTypes = null) + { + Type type = Assembly.GetType(typeName, true)!; + MethodInfo? method = parameterTypes is null + ? type.GetMethod(methodName, StaticMembers) + : type.GetMethod(methodName, StaticMembers, parameterTypes); + return method ?? throw new MissingMethodException(typeName, methodName); + } - /// A delegate over a static method, for calls that must not allocate (reflection invocation does). - public TDelegate Delegate(string typeName, string methodName) - where TDelegate : Delegate - { - var parameters = typeof(TDelegate).GetMethod("Invoke")!.GetParameters(); - Type[] parameterTypes = [.. parameters.Select(static parameter => parameter.ParameterType)]; - return Method(typeName, methodName, parameterTypes).CreateDelegate(); - } + /// A delegate over a static method, for calls that must not allocate (reflection invocation does). + public TDelegate Delegate(string typeName, string methodName) + where TDelegate : Delegate + { + ParameterInfo[] parameters = typeof(TDelegate).GetMethod("Invoke")!.GetParameters(); + Type[] parameterTypes = [.. parameters.Select(static parameter => parameter.ParameterType)]; + return Method(typeName, methodName, parameterTypes).CreateDelegate(); + } - /// - /// Whether declares a static method named (any - /// accessibility). - /// - public bool HasMethod(string typeName, string methodName) - { - return Assembly.GetType(typeName, true)!.GetMethods(StaticMembers) - .Any(method => string.Equals(method.Name, methodName, StringComparison.Ordinal)); - } + /// + /// Whether declares a static method named (any + /// accessibility). + /// + public bool HasMethod(string typeName, string methodName) + { + return Assembly.GetType(typeName, true)!.GetMethods(StaticMembers) + .Any(method => string.Equals(method.Name, methodName, StringComparison.Ordinal)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs index be92429e..72cd2bc4 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; @@ -6,90 +7,106 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// One execution of the generator: the driver (to run again), the result and the updated compilation. internal sealed class GeneratorRun { - private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics) - { - Driver = driver; - OutputCompilation = outputCompilation; - GeneratorDiagnostics = diagnostics; - Result = driver.GetRunResult().Results.Single(); - } - - /// The driver after the run; feed it to again to test incrementality. - public GeneratorDriver Driver { get; } - - /// Input compilation plus the generated trees. - public Compilation OutputCompilation { get; } - - /// Diagnostics reported by the generator itself (this generator must never report any). - public ImmutableArray GeneratorDiagnostics { get; } - - public GeneratorRunResult Result { get; } - - public ImmutableArray GeneratedSources => Result.GeneratedSources; - - /// Text of the only generated file; fails when there is none or more than one. - public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); - - /// Hint names of the generated files, in generation order. - public string[] HintNames => [.. GeneratedSources.Select(static source => source.HintName)]; - - public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) - { - var updated = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var diagnostics, - TestContext.Current.CancellationToken); - - return new GeneratorRun(updated, outputCompilation, diagnostics); - } - - /// Text of the generated file named ; fails when there is none. - public string GeneratedText(string hintName) - { - foreach (var source in GeneratedSources) - if (string.Equals(source.HintName, hintName, StringComparison.Ordinal)) - return source.SourceText.ToString(); - - Assert.Fail($"No generated file is named '{hintName}'. Generated: {string.Join(", ", HintNames)}."); - return string.Empty; - } - - /// Asserts "silent": no file, no generator diagnostic, no exception swallowed by the driver. - public void AssertNoOutput() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - Assert.Empty(GeneratedSources); - } - - /// - /// Asserts that the updated compilation (user code + generated code, against the real SDK assemblies) has no - /// error and no warning, and that the generator reported nothing. - /// - public void AssertCompilesClean() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - - Diagnostic[] problems = - [ - .. OutputCompilation - .GetDiagnostics(TestContext.Current.CancellationToken) - .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) - ]; - - Assert.True(problems.Length == 0, - "Unexpected compiler diagnostics:\n" + string.Join('\n', problems.AsEnumerable())); - } - - // The test classes are public and undocumented on purpose (short sources). CS1591 is ignored for them, and only - // for them: in a generated file it still fails the assertion. - private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) - { - return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); - } + private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics) + { + Driver = driver; + OutputCompilation = outputCompilation; + GeneratorDiagnostics = diagnostics; + Result = driver.GetRunResult().Results.Single(); + } + + /// The driver after the run; feed it to again to test incrementality. + public GeneratorDriver Driver + { + get; + } + + /// Input compilation plus the generated trees. + public Compilation OutputCompilation + { + get; + } + + /// Diagnostics reported by the generator itself (this generator must never report any). + public ImmutableArray GeneratorDiagnostics + { + get; + } + + public GeneratorRunResult Result + { + get; + } + + public ImmutableArray GeneratedSources => Result.GeneratedSources; + + /// Text of the only generated file; fails when there is none or more than one. + public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); + + /// Hint names of the generated files, in generation order. + public string[] HintNames => [.. GeneratedSources.Select(static source => source.HintName)]; + + public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) + { + GeneratorDriver updated = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + + return new GeneratorRun(updated, outputCompilation, diagnostics); + } + + /// Text of the generated file named ; fails when there is none. + public string GeneratedText(string hintName) + { + foreach (GeneratedSourceResult source in GeneratedSources) + { + if (string.Equals(source.HintName, hintName, StringComparison.Ordinal)) + { + return source.SourceText.ToString(); + } + } + + Assert.Fail($"No generated file is named '{hintName}'. Generated: {string.Join(", ", HintNames)}."); + return string.Empty; + } + + /// Asserts "silent": no file, no generator diagnostic, no exception swallowed by the driver. + public void AssertNoOutput() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + Assert.Empty(GeneratedSources); + } + + /// + /// Asserts that the updated compilation (user code + generated code, against the real SDK assemblies) has no + /// error and no warning, and that the generator reported nothing. + /// + public void AssertCompilesClean() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + + Diagnostic[] problems = + [ + .. OutputCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && + !IsMissingDocumentationInTestInput(diagnostic)) + ]; + + Assert.True(problems.Length == 0, + "Unexpected compiler diagnostics:\n" + string.Join('\n', problems.AsEnumerable())); + } + + // The test classes are public and undocumented on purpose (short sources). CS1591 is ignored for them, and only + // for them: in a generated file it still fails the assertion. + private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) + { + return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/HostDouble.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/HostDouble.cs index 1ee7e5ca..7f409984 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/HostDouble.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/HostDouble.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using CheatEngine.SDK.Lua.Interop.Types; using CheatEngine.SDK.Lua.Runtime; @@ -12,22 +13,22 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal static unsafe class HostDouble { - private static lua_State* s_state; + private static lua_State* s_state; - /// - /// Points the provider at and builds a binding with the calling thread as main thread - /// and no object pusher. - /// - public static LuaHostBinding CreateBinding(lua_State* state) - { - s_state = state; - delegate* unmanaged[Stdcall] provider = &Provide; - return new LuaHostBinding((nint)provider, 0, Environment.CurrentManagedThreadId); - } + /// + /// Points the provider at and builds a binding with the calling thread as main thread + /// and no object pusher. + /// + public static LuaHostBinding CreateBinding(lua_State* state) + { + s_state = state; + delegate* unmanaged[Stdcall] provider = &Provide; + return new LuaHostBinding((nint) provider, 0, Environment.CurrentManagedThreadId); + } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* Provide() - { - return s_state; - } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* Provide() + { + return s_state; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs index 8f826817..8b157554 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; @@ -17,81 +18,96 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal static class LocalFrameworkReferences { - private const string TargetFrameworkFolder = "net10.0"; + private const string TargetFrameworkFolder = "net10.0"; - private static string RuntimeDirectory => - Path.GetDirectoryName(typeof(object).Assembly.Location) - ?? throw new InvalidOperationException( - "System.Private.CoreLib has no location: single-file test hosts are not supported."); + private static string RuntimeDirectory => + Path.GetDirectoryName(typeof(object).Assembly.Location) + ?? throw new InvalidOperationException( + "System.Private.CoreLib has no location: single-file test hosts are not supported."); - /// Targeting pack when there is one, the running runtime otherwise. - /// Neither source yields a single assembly. - public static ImmutableArray Load() - { - var references = FromTargetingPack(); - if (references.IsEmpty) references = FromRunningRuntime(); + /// Targeting pack when there is one, the running runtime otherwise. + /// Neither source yields a single assembly. + public static ImmutableArray Load() + { + ImmutableArray references = FromTargetingPack(); + if (references.IsEmpty) + { + references = FromRunningRuntime(); + } - return references.IsEmpty - ? throw new InvalidOperationException( - $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") - : references; - } + return references.IsEmpty + ? throw new InvalidOperationException( + $"No Microsoft.NETCore.App references found: no targeting pack next to '{RuntimeDirectory}' and no trusted platform assembly in it.") + : references; + } - /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. - public static ImmutableArray FromTargetingPack() - { - // /shared/Microsoft.NETCore.App// -> /packs/Microsoft.NETCore.App.Ref//ref/net10.0/ - var dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); - if (dotnetRoot is null) return []; + /// Reference assemblies of the highest installed 10.0.x targeting pack; empty when none is installed. + public static ImmutableArray FromTargetingPack() + { + // /shared/Microsoft.NETCore.App// -> /packs/Microsoft.NETCore.App.Ref//ref/net10.0/ + string? dotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(RuntimeDirectory))); + if (dotnetRoot is null) + { + return []; + } - var packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); - if (!Directory.Exists(packs)) return []; + string packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref"); + if (!Directory.Exists(packs)) + { + return []; + } - string? best = null; - Version? bestVersion = null; - foreach (var pack in Directory.EnumerateDirectories(packs)) - { - var candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); - if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out var version) - && (bestVersion is null || version > bestVersion)) - { - best = candidate; - bestVersion = version; - } - } + string? best = null; + Version? bestVersion = null; + foreach (string pack in Directory.EnumerateDirectories(packs)) + { + string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); + if (Directory.Exists(candidate) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) + { + best = candidate; + bestVersion = version; + } + } - return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); - } + return best is null ? [] : CreateReferences(Directory.GetFiles(best, "*.dll")); + } - /// Implementation assemblies of the runtime this process runs on (managed ones only). - public static ImmutableArray FromRunningRuntime() - { - // The list also holds the test application's own dependencies (xUnit, Roslyn, the generator): only what - // sits in the shared framework directory is Microsoft.NETCore.App. Native DLLs are not on the list. - var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; - List paths = []; - foreach (var path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) - paths.Add(path); + /// Implementation assemblies of the runtime this process runs on (managed ones only). + public static ImmutableArray FromRunningRuntime() + { + // The list also holds the test application's own dependencies (xUnit, Roslyn, the generator): only what + // sits in the shared framework directory is Microsoft.NETCore.App. Native DLLs are not on the list. + string trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; + List paths = []; + foreach (string path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + if (string.Equals(Path.GetDirectoryName(path), RuntimeDirectory, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(path); + } + } - return CreateReferences(paths); - } + return CreateReferences(paths); + } - // "10.0.1", "10.0.0-rc.2.25502.107": the pre-release label does not matter for picking a pack. - private static bool TryParsePackVersion(string directoryName, out Version? version) - { - var label = directoryName.IndexOf('-', StringComparison.Ordinal); - return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); - } + // "10.0.1", "10.0.0-rc.2.25502.107": the pre-release label does not matter for picking a pack. + private static bool TryParsePackVersion(string directoryName, out Version? version) + { + int label = directoryName.IndexOf('-', StringComparison.Ordinal); + return Version.TryParse(label < 0 ? directoryName : directoryName[..label], out version); + } - // Sorted: the order of references is part of a compilation, and directory enumeration order is not specified. - private static ImmutableArray CreateReferences(IEnumerable paths) - { - var references = ImmutableArray.CreateBuilder(); - foreach (var path in paths.Order(StringComparer.OrdinalIgnoreCase)) - references.Add(MetadataReference.CreateFromFile(path)); + // Sorted: the order of references is part of a compilation, and directory enumeration order is not specified. + private static ImmutableArray CreateReferences(IEnumerable paths) + { + ImmutableArray.Builder references = ImmutableArray.CreateBuilder(); + foreach (string path in paths.Order(StringComparer.OrdinalIgnoreCase)) + { + references.Add(MetadataReference.CreateFromFile(path)); + } - return references.ToImmutable(); - } + return references.ToImmutable(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaRuntimeSuite.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaRuntimeSuite.cs index ac921845..80c88cd0 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaRuntimeSuite.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaRuntimeSuite.cs @@ -8,6 +8,6 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; [CollectionDefinition(Name, DisableParallelization = true)] public sealed class LuaRuntimeSuite { - /// The collection name. - public const string Name = "Lua runtime"; + /// The collection name. + public const string Name = "Lua runtime"; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaTest.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaTest.cs index 379c9b65..50995542 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaTest.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LuaTest.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -8,68 +9,68 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// What the NativeLua tests share: the skip guard, chunk execution through the public API and result reading. internal static class LuaTest { - /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. - public static void RequireNativeLua() - { - Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); - } + /// Skips the calling test, with the fixture's reason, when no Lua 5.3 library is available. + public static void RequireNativeLua() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + } - /// Wraps the fixture's state in the SDK view. - public static LuaState View(NativeLuaState state) - { - return new LuaState(state.Pointer); - } + /// Wraps the fixture's state in the SDK view. + public static LuaState View(NativeLuaState state) + { + return new LuaState(state.Pointer); + } - /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. - public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) - { - var status = L.TryExecute(source, resultCount, "=test"u8); - if (!status.IsOk) - { - var error = LuaError.FromStack(L, status); - Assert.Fail("The chunk failed: " + error); - } - } + /// Compiles and runs a chunk, failing the test with the Lua message on any error. Results stay on the stack. + public static void Run(LuaState L, ReadOnlySpan source, int resultCount = 0) + { + LuaStatus status = L.TryExecute(source, resultCount, "=test"u8); + if (!status.IsOk) + { + LuaError error = LuaError.FromStack(L, status); + Assert.Fail("The chunk failed: " + error); + } + } - /// - /// Runs a chunk that returns one string and pops it, failing the test when the chunk raises or returns something - /// else. - /// - public static string RunForString(LuaState L, ReadOnlySpan source) - { - using LuaFrame frame = new(L); - Run(L, source, 1); - return ReadString(L, -1); - } + /// + /// Runs a chunk that returns one string and pops it, failing the test when the chunk raises or returns something + /// else. + /// + public static string RunForString(LuaState L, ReadOnlySpan source) + { + using LuaFrame frame = new(L); + Run(L, source, 1); + return ReadString(L, -1); + } - /// Runs a chunk that returns one integer and pops it. - public static long RunForInteger(LuaState L, ReadOnlySpan source) - { - using LuaFrame frame = new(L); - Run(L, source, 1); - Assert.True(L.TryReadInteger(-1, out var value), "The chunk returned a " + L.TypeOf(-1) + ", not an integer."); - return value; - } + /// Runs a chunk that returns one integer and pops it. + public static long RunForInteger(LuaState L, ReadOnlySpan source) + { + using LuaFrame frame = new(L); + Run(L, source, 1); + Assert.True(L.TryReadInteger(-1, out long value), "The chunk returned a " + L.TypeOf(-1) + ", not an integer."); + return value; + } - /// - /// Runs a chunk of the form return pcall(...), asserts that the protected call failed and returns its - /// error message. - /// - public static string RunForError(LuaState L, ReadOnlySpan source) - { - using LuaFrame frame = new(L); - Run(L, source, 2); - Assert.Equal(LuaType.Boolean, L.TypeOf(-2)); - Assert.False(L.ToBoolean(-2), "The protected call succeeded, an error was expected."); - return ReadString(L, -1); - } + /// + /// Runs a chunk of the form return pcall(...), asserts that the protected call failed and returns its + /// error message. + /// + public static string RunForError(LuaState L, ReadOnlySpan source) + { + using LuaFrame frame = new(L); + Run(L, source, 2); + Assert.Equal(LuaType.Boolean, L.TypeOf(-2)); + Assert.False(L.ToBoolean(-2), "The protected call succeeded, an error was expected."); + return ReadString(L, -1); + } - /// Reads the string at , or fails the test when the value is not a string. - public static string ReadString(LuaState L, int index) - { - Assert.True(L.TryReadString(index, out var value), - string.Create(CultureInfo.InvariantCulture, - $"The value at {index} is a {L.TypeOf(index)}, not a string.")); - return value; - } + /// Reads the string at , or fails the test when the value is not a string. + public static string ReadString(LuaState L, int index) + { + Assert.True(L.TryReadString(index, out string? value), + string.Create(CultureInfo.InvariantCulture, + $"The value at {index} is a {L.TypeOf(index)}, not a string.")); + return value; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs index 3ebf109d..27df8352 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Globalization; using System.Reflection; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; @@ -12,54 +13,66 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal static class ModelGraph { - private static readonly Type[] ForbiddenTypes = - [ - typeof(ISymbol), - typeof(SyntaxNode), - typeof(SyntaxTree), - typeof(SyntaxToken), - typeof(SyntaxReference), - typeof(Location), - typeof(SemanticModel), - typeof(Compilation), - typeof(AttributeData) - ]; + private static readonly Type[] ForbiddenTypes = + [ + typeof(ISymbol), + typeof(SyntaxNode), + typeof(SyntaxTree), + typeof(SyntaxToken), + typeof(SyntaxReference), + typeof(Location), + typeof(SemanticModel), + typeof(Compilation), + typeof(AttributeData) + ]; - /// Asserts that reaches no Roslyn object; returns the number of objects visited. - public static int AssertFreeOfRoslynObjects(object? value, string stepName) - { - HashSet visited = new(ReferenceEqualityComparer.Instance); - Visit(value, stepName, visited, 0); - return visited.Count; - } + /// Asserts that reaches no Roslyn object; returns the number of objects visited. + public static int AssertFreeOfRoslynObjects(object? value, string stepName) + { + HashSet visited = new(ReferenceEqualityComparer.Instance); + Visit(value, stepName, visited, 0); + return visited.Count; + } - private static void Visit(object? value, string path, HashSet visited, int depth) - { - if (value is null) return; + private static void Visit(object? value, string path, HashSet visited, int depth) + { + if (value is null) + { + return; + } - var type = value.GetType(); - Assert.False( - Array.Exists(ForbiddenTypes, forbidden => forbidden.IsAssignableFrom(type)), - $"{path}: a {type.FullName} is held by the pipeline model."); + Type type = value.GetType(); + Assert.False( + Array.Exists(ForbiddenTypes, forbidden => forbidden.IsAssignableFrom(type)), + $"{path}: a {type.FullName} is held by the pipeline model."); - if (type.IsPrimitive || type.IsEnum || value is string) return; + if (type.IsPrimitive || type.IsEnum || value is string) + { + return; + } - Assert.True(depth < 32, $"{path}: object graph too deep."); - if (!type.IsValueType && !visited.Add(value)) return; + Assert.True(depth < 32, $"{path}: object graph too deep."); + if (!type.IsValueType && !visited.Add(value)) + { + return; + } - if (value is IEnumerable sequence) - { - var index = 0; - foreach (var item in sequence) - { - var itemPath = string.Create(CultureInfo.InvariantCulture, $"{path}[{index++}]"); - Visit(item, itemPath, visited, depth + 1); - } + if (value is IEnumerable sequence) + { + int index = 0; + foreach (object item in sequence) + { + string itemPath = string.Create(CultureInfo.InvariantCulture, $"{path}[{index++}]"); + Visit(item, itemPath, visited, depth + 1); + } - return; - } + return; + } - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); - } + foreach (FieldInfo field in + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynEnvironment.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynEnvironment.cs index 4f58b2cf..6605abba 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynEnvironment.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynEnvironment.cs @@ -1,8 +1,10 @@ using System.Collections.Immutable; + using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.State; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -17,46 +19,52 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal sealed class RoslynEnvironment { - /// Documentation comments are parsed and diagnosed, like in a project with GenerateDocumentationFile. - public static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); - - /// Strict, with unsafe ON: the registration table takes thunk addresses. Nullable on, every warning wave. - public static readonly CSharpCompilationOptions CompilationOptions = new( - OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable, - allowUnsafe: true, - warningLevel: 9999); - - /// The same options with unsafe OFF: the generator must then emit nothing. - public static readonly CSharpCompilationOptions SafeCompilationOptions = CompilationOptions.WithAllowUnsafe(false); - - private static readonly Lazy LazyShared = - new(static () => new RoslynEnvironment(LocalFrameworkReferences.Load())); - - private RoslynEnvironment(ImmutableArray frameworkReferences) - { - FrameworkReferences = frameworkReferences; - SdkReferences = - [ - MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(LuaApi).Assembly.Location), - MetadataReference.CreateFromFile(typeof(LuaState).Assembly.Location), - MetadataReference.CreateFromFile(typeof(CEObject).Assembly.Location), - ]; - } - - /// The process-wide environment. - public static RoslynEnvironment Shared => LazyShared.Value; - - /// Microsoft.NETCore.App 10.0: reference assemblies, or the running runtime as a fallback. - public ImmutableArray FrameworkReferences { get; } - - /// - /// The real CheatEngine.SDK.Annotations, CheatEngine.SDK.Lua.Interop and CheatEngine.SDK.Lua - /// , as loaded in this process. - /// - public ImmutableArray SdkReferences { get; } - - /// Framework + SDK: the references of a plugin compilation. - public ImmutableArray PluginReferences => FrameworkReferences.AddRange(SdkReferences); + /// Documentation comments are parsed and diagnosed, like in a project with GenerateDocumentationFile. + public static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); + + /// Strict, with unsafe ON: the registration table takes thunk addresses. Nullable on, every warning wave. + public static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + allowUnsafe: true, + warningLevel: 9999); + + /// The same options with unsafe OFF: the generator must then emit nothing. + public static readonly CSharpCompilationOptions SafeCompilationOptions = CompilationOptions.WithAllowUnsafe(false); + + private static readonly Lazy LazyShared = + new(static () => new RoslynEnvironment(LocalFrameworkReferences.Load())); + + private RoslynEnvironment(ImmutableArray frameworkReferences) + { + FrameworkReferences = frameworkReferences; + SdkReferences = + [ + MetadataReference.CreateFromFile(typeof(LuaFunctionAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(LuaApi).Assembly.Location), + MetadataReference.CreateFromFile(typeof(LuaState).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CEObject).Assembly.Location) + ]; + } + + /// The process-wide environment. + public static RoslynEnvironment Shared => LazyShared.Value; + + /// Microsoft.NETCore.App 10.0: reference assemblies, or the running runtime as a fallback. + public ImmutableArray FrameworkReferences + { + get; + } + + /// + /// The real CheatEngine.SDK.Annotations, CheatEngine.SDK.Lua.Interop and CheatEngine.SDK.Lua + /// , as loaded in this process. + /// + public ImmutableArray SdkReferences + { + get; + } + + /// Framework + SDK: the references of a plugin compilation. + public ImmutableArray PluginReferences => FrameworkReferences.AddRange(SdkReferences); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynFixture.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynFixture.cs index fc2e2f27..3a014e89 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynFixture.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RoslynFixture.cs @@ -1,4 +1,5 @@ using System.Globalization; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -11,70 +12,73 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// public sealed class RoslynFixture { - /// Assembly name of the plugin compilations created here. - internal const string PluginAssemblyName = "TestBindings"; + /// Assembly name of the plugin compilations created here. + internal const string PluginAssemblyName = "TestBindings"; - /// - /// Takes the process-wide environment; a failure to find the framework references fails the class with the - /// resolver's message. - /// - public RoslynFixture() - { - Environment = RoslynEnvironment.Shared; - } + /// + /// Takes the process-wide environment; a failure to find the framework references fails the class with the + /// resolver's message. + /// + public RoslynFixture() + { + Environment = RoslynEnvironment.Shared; + } - internal RoslynEnvironment Environment { get; } + internal RoslynEnvironment Environment + { + get; + } - /// - /// A plugin compilation (unsafe allowed) with one syntax tree per source, named Source0.cs, - /// Source1.cs... - /// - internal CSharpCompilation CreateCompilation(params string[] sources) - { - return CreateCompilation(RoslynEnvironment.CompilationOptions, sources); - } + /// + /// A plugin compilation (unsafe allowed) with one syntax tree per source, named Source0.cs, + /// Source1.cs... + /// + internal CSharpCompilation CreateCompilation(params string[] sources) + { + return CreateCompilation(RoslynEnvironment.CompilationOptions, sources); + } - /// Same, with other compilation options (for example unsafe off). - internal CSharpCompilation CreateCompilation(CSharpCompilationOptions options, params string[] sources) - { - var trees = new SyntaxTree[sources.Length]; - for (var i = 0; i < sources.Length; i++) - { - var path = string.Create(CultureInfo.InvariantCulture, $"Source{i}.cs"); - trees[i] = Parse(sources[i], path); - } + /// Same, with other compilation options (for example unsafe off). + internal CSharpCompilation CreateCompilation(CSharpCompilationOptions options, params string[] sources) + { + SyntaxTree[] trees = new SyntaxTree[sources.Length]; + for (int i = 0; i < sources.Length; i++) + { + string path = string.Create(CultureInfo.InvariantCulture, $"Source{i}.cs"); + trees[i] = Parse(sources[i], path); + } - return CSharpCompilation.Create(PluginAssemblyName, trees, Environment.PluginReferences, options); - } + return CSharpCompilation.Create(PluginAssemblyName, trees, Environment.PluginReferences, options); + } - /// Creates a driver for the generator with step tracking on. - internal static GeneratorDriver CreateDriver() - { - return CSharpGeneratorDriver.Create( - [new LuaBindingsGenerator().AsSourceGenerator()], - [], - RoslynEnvironment.ParseOptions, - null, - new GeneratorDriverOptions( - IncrementalGeneratorOutputKind.None, - true)); - } + /// Creates a driver for the generator with step tracking on. + internal static GeneratorDriver CreateDriver() + { + return CSharpGeneratorDriver.Create( + [new LuaBindingsGenerator().AsSourceGenerator()], + [], + RoslynEnvironment.ParseOptions, + null, + new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + true)); + } - /// Runs the generator once over . - internal GeneratorRun Run(params string[] sources) - { - return Run(CreateCompilation(sources)); - } + /// Runs the generator once over . + internal GeneratorRun Run(params string[] sources) + { + return Run(CreateCompilation(sources)); + } - /// Runs the generator once over . - internal static GeneratorRun Run(Compilation compilation) - { - return GeneratorRun.Execute(CreateDriver(), compilation); - } + /// Runs the generator once over . + internal static GeneratorRun Run(Compilation compilation) + { + return GeneratorRun.Execute(CreateDriver(), compilation); + } - internal static SyntaxTree Parse(string source, string path) - { - return CSharpSyntaxTree.ParseText(source, RoslynEnvironment.ParseOptions, path, - cancellationToken: TestContext.Current.CancellationToken); - } + internal static SyntaxTree Parse(string source, string path) + { + return CSharpSyntaxTree.ParseText(source, RoslynEnvironment.ParseOptions, path, + cancellationToken: TestContext.Current.CancellationToken); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RuntimeScope.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RuntimeScope.cs index 20bbb12d..66cd0017 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RuntimeScope.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/RuntimeScope.cs @@ -9,13 +9,13 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// internal sealed unsafe class RuntimeScope : IDisposable { - public RuntimeScope(NativeLuaState state) - { - LuaRuntime.Attach(HostDouble.CreateBinding(state.L)); - } + public RuntimeScope(NativeLuaState state) + { + LuaRuntime.Attach(HostDouble.CreateBinding(state.L)); + } - public void Dispose() - { - LuaRuntime.Detach(); - } + public void Dispose() + { + LuaRuntime.Detach(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/StepAssert.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/StepAssert.cs index e7c96727..f4a13bbe 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/StepAssert.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/StepAssert.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.Shared; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; @@ -7,59 +9,61 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; /// Assertions over (the incrementality gate). internal static class StepAssert { - /// Name Roslyn gives to the step of a RegisterSourceOutput callback. - public const string SourceOutputStep = "SourceOutput"; + /// Name Roslyn gives to the step of a RegisterSourceOutput callback. + public const string SourceOutputStep = "SourceOutput"; - /// - /// Every CheatEngine.SDK-named step ran, produced something, and produced only Cached/Unchanged values; - /// no - /// source output was re-executed. Also fails when a step exists that the generator's name list does not know, - /// so a new step cannot slip past the gate. The compilation must therefore exercise both pipelines. - /// - public static void NothingWasRecomputed(GeneratorRunResult result) - { - string[] trackedCheatEngineSdkSteps = - [.. result.TrackedSteps.Keys.Where(TrackingNames.IsCheatEngineSdkStep).Order(StringComparer.Ordinal)]; - Assert.All(trackedCheatEngineSdkSteps, - stepName => Assert.Contains(stepName, LuaBindingsTrackingNames.All, StringComparer.Ordinal)); + /// + /// Every CheatEngine.SDK-named step ran, produced something, and produced only Cached/Unchanged values; + /// no + /// source output was re-executed. Also fails when a step exists that the generator's name list does not know, + /// so a new step cannot slip past the gate. The compilation must therefore exercise both pipelines. + /// + public static void NothingWasRecomputed(GeneratorRunResult result) + { + string[] trackedCheatEngineSdkSteps = + [.. result.TrackedSteps.Keys.Where(TrackingNames.IsCheatEngineSdkStep).Order(StringComparer.Ordinal)]; + Assert.All(trackedCheatEngineSdkSteps, + stepName => Assert.Contains(stepName, LuaBindingsTrackingNames.All, StringComparer.Ordinal)); - foreach (var stepName in trackedCheatEngineSdkSteps) - Assert.All( - Reasons(result, stepName), - reason => Assert.True( - reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, - $"Step '{stepName}' was recomputed: {reason}.")); + foreach (string stepName in trackedCheatEngineSdkSteps) + { + Assert.All( + Reasons(result, stepName), + reason => Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Step '{stepName}' was recomputed: {reason}.")); + } - Assert.All(OutputReasons(result), static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); - } + Assert.All(OutputReasons(result), static reason => Assert.Equal(IncrementalStepRunReason.Cached, reason)); + } - /// Reasons of every output of the step named ; fails when there is none. - public static ImmutableArray Reasons(GeneratorRunResult result, string stepName) - { - Assert.True(result.TrackedSteps.ContainsKey(stepName), $"Step '{stepName}' was not tracked."); + /// Reasons of every output of the step named ; fails when there is none. + public static ImmutableArray Reasons(GeneratorRunResult result, string stepName) + { + Assert.True(result.TrackedSteps.ContainsKey(stepName), $"Step '{stepName}' was not tracked."); - ImmutableArray reasons = - [ - .. result.TrackedSteps[stepName].SelectMany(static step => step.Outputs) - .Select(static output => output.Reason) - ]; + ImmutableArray reasons = + [ + .. result.TrackedSteps[stepName].SelectMany(static step => step.Outputs) + .Select(static output => output.Reason) + ]; - Assert.False(reasons.IsEmpty, $"Step '{stepName}' produced no output: the assertion would be vacuous."); - return reasons; - } + Assert.False(reasons.IsEmpty, $"Step '{stepName}' produced no output: the assertion would be vacuous."); + return reasons; + } - /// Reasons of every source output (both pipelines register one); fails when there is none. - public static ImmutableArray OutputReasons(GeneratorRunResult result) - { - Assert.True(result.TrackedOutputSteps.ContainsKey(SourceOutputStep), "The source output step was not tracked."); + /// Reasons of every source output (both pipelines register one); fails when there is none. + public static ImmutableArray OutputReasons(GeneratorRunResult result) + { + Assert.True(result.TrackedOutputSteps.ContainsKey(SourceOutputStep), "The source output step was not tracked."); - ImmutableArray reasons = - [ - .. result.TrackedOutputSteps[SourceOutputStep].SelectMany(static step => step.Outputs) - .Select(static output => output.Reason) - ]; + ImmutableArray reasons = + [ + .. result.TrackedOutputSteps[SourceOutputStep].SelectMany(static step => step.Outputs) + .Select(static output => output.Reason) + ]; - Assert.False(reasons.IsEmpty, "The source output step produced no output: the assertion would be vacuous."); - return reasons; - } + Assert.False(reasons.IsEmpty, "The source output step produced no output: the assertion would be vacuous."); + return reasons; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs index e4faf13a..2b56bfe2 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using CheatEngine.SDK.SourceGenerators.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; @@ -12,108 +13,115 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Model; /// public sealed class LuaFunctionTablesTests { - private static readonly ContainingTypeModel Alpha = Type("global::Demo.Alpha", "Alpha"); - private static readonly ContainingTypeModel Zeta = Type("global::Demo.Zeta", "Zeta"); - - [Fact] - public void Group_sorts_tables_by_type_and_thunks_by_lua_name() - { - var tables = LuaFunctionTables.Group( - [ - Function(Zeta, "z"), - Function(Alpha, "b"), - Function(Alpha, "a") - ]); - - Assert.Equal(2, tables.Length); - Assert.Same(Alpha, tables[0].ContainingType); - Assert.Equal(["a", "b"], tables[0].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), - StringComparer.Ordinal); - Assert.Same(Zeta, tables[1].ContainingType); - Assert.Equal(["z"], tables[1].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), - StringComparer.Ordinal); - } - - [Fact] - public void Group_drops_every_member_of_a_duplicated_name_within_a_type_only() - { - var tables = LuaFunctionTables.Group( - [ - Function(Alpha, "twin"), - Function(Alpha, "twin"), - Function(Alpha, "single"), - Function(Zeta, "twin") - ]); - - Assert.Equal(2, tables.Length); - Assert.Equal(["single"], tables[0].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), - StringComparer.Ordinal); - Assert.Equal(["twin"], tables[1].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), - StringComparer.Ordinal); - } - - [Fact] - public void Group_skips_invalid_members_and_types_left_without_a_valid_one() - { - var invalid = Function(Alpha, "bad") with { Issues = LuaFunctionShapeIssues.NotStatic, Thunk = null }; - var invalidType = Function(Zeta, "ok") with { ContainingTypeIssues = ContainingTypeIssues.NotPartial }; - - var tables = LuaFunctionTables.Group([invalid, invalidType, Function(Alpha, "good")]); - - var table = Assert.Single(tables); - Assert.Equal("good", Assert.Single(table.Thunks).LuaName); - Assert.True(LuaFunctionTables.Group([invalid]).IsEmpty); - Assert.True(LuaFunctionTables.Group([]).IsEmpty); - Assert.True(LuaFunctionTables.Group(default).IsEmpty); - } - - [Fact] - public void Group_is_a_pure_function_of_its_input() - { - ImmutableArray models = [Function(Alpha, "a"), Function(Zeta, "z")]; - - Assert.Equal(LuaFunctionTables.Group(models), LuaFunctionTables.Group(models)); - Assert.Equal(LuaFunctionTables.Group(models).GetHashCode(), LuaFunctionTables.Group(models).GetHashCode()); - } - - [Fact] - public void Group_assigns_case_insensitive_collision_names_independent_of_input_order() - { - var upper = Type("global::Demo.Type", "Type"); - var lower = Type("global::Demo.type", "type"); - var forward = LuaFunctionTables.Group([Function(lower, "lower"), Function(upper, "upper")]); - var reverse = LuaFunctionTables.Group([Function(upper, "upper"), Function(lower, "lower")]); - - AssertHintNames(forward, - HintNames.ForType("Demo.Type", LuaFunctionTableModel.HintSuffix), - HintNames.Disambiguated("Demo.type", LuaFunctionTableModel.HintSuffix)); - AssertHintNames(reverse, - HintNames.ForType("Demo.Type", LuaFunctionTableModel.HintSuffix), - HintNames.Disambiguated("Demo.type", LuaFunctionTableModel.HintSuffix)); - } - - private static ContainingTypeModel Type(string fullyQualifiedName, string name) - { - return new ContainingTypeModel("Demo", - new EquatableArray([new TypeDeclarationModel("class", name)]), - fullyQualifiedName, "Demo." + name); - } - - private static LuaFunctionModel Function(ContainingTypeModel type, string luaName) - { - return new LuaFunctionModel( - type, - ContainingTypeIssues.None, - luaName, - LuaFunctionShapeIssues.None, - new LuaThunkModel(luaName, LuaThunkModel.ThunkNameFor(luaName), type.FullyQualifiedName + ".M", - PassesState: false, EquatableArray.Empty, ReturnKind: null)); - } - - private static void AssertHintNames(EquatableArray tables, string first, string second) - { - Assert.Equal(2, tables.Length); - Assert.Equal(first, tables[0].HintName, StringComparer.Ordinal); - Assert.Equal(second, tables[1].HintName, StringComparer.Ordinal); - } + private static readonly ContainingTypeModel Alpha = Type("global::Demo.Alpha", "Alpha"); + private static readonly ContainingTypeModel Zeta = Type("global::Demo.Zeta", "Zeta"); + + [Fact] + public void Group_sorts_tables_by_type_and_thunks_by_lua_name() + { + EquatableArray tables = LuaFunctionTables.Group( + [ + Function(Zeta, "z"), + Function(Alpha, "b"), + Function(Alpha, "a") + ]); + + Assert.Equal(2, tables.Length); + Assert.Same(Alpha, tables[0].ContainingType); + Assert.Equal(["a", "b"], tables[0].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), + StringComparer.Ordinal); + Assert.Same(Zeta, tables[1].ContainingType); + Assert.Equal(["z"], tables[1].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), + StringComparer.Ordinal); + } + + [Fact] + public void Group_drops_every_member_of_a_duplicated_name_within_a_type_only() + { + EquatableArray tables = LuaFunctionTables.Group( + [ + Function(Alpha, "twin"), + Function(Alpha, "twin"), + Function(Alpha, "single"), + Function(Zeta, "twin") + ]); + + Assert.Equal(2, tables.Length); + Assert.Equal(["single"], tables[0].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), + StringComparer.Ordinal); + Assert.Equal(["twin"], tables[1].Thunks.AsImmutableArray().Select(static thunk => thunk.LuaName), + StringComparer.Ordinal); + } + + [Fact] + public void Group_skips_invalid_members_and_types_left_without_a_valid_one() + { + LuaFunctionModel invalid = Function(Alpha, "bad") with + { + Issues = LuaFunctionShapeIssues.NotStatic, Thunk = null + }; + LuaFunctionModel invalidType = + Function(Zeta, "ok") with { ContainingTypeIssues = ContainingTypeIssues.NotPartial }; + + EquatableArray tables = + LuaFunctionTables.Group([invalid, invalidType, Function(Alpha, "good")]); + + LuaFunctionTableModel table = Assert.Single(tables); + Assert.Equal("good", Assert.Single(table.Thunks).LuaName); + Assert.True(LuaFunctionTables.Group([invalid]).IsEmpty); + Assert.True(LuaFunctionTables.Group([]).IsEmpty); + Assert.True(LuaFunctionTables.Group(default).IsEmpty); + } + + [Fact] + public void Group_is_a_pure_function_of_its_input() + { + ImmutableArray models = [Function(Alpha, "a"), Function(Zeta, "z")]; + + Assert.Equal(LuaFunctionTables.Group(models), LuaFunctionTables.Group(models)); + Assert.Equal(LuaFunctionTables.Group(models).GetHashCode(), LuaFunctionTables.Group(models).GetHashCode()); + } + + [Fact] + public void Group_assigns_case_insensitive_collision_names_independent_of_input_order() + { + ContainingTypeModel upper = Type("global::Demo.Type", "Type"); + ContainingTypeModel lower = Type("global::Demo.type", "type"); + EquatableArray forward = + LuaFunctionTables.Group([Function(lower, "lower"), Function(upper, "upper")]); + EquatableArray reverse = + LuaFunctionTables.Group([Function(upper, "upper"), Function(lower, "lower")]); + + AssertHintNames(forward, + HintNames.ForType("Demo.Type", LuaFunctionTableModel.HintSuffix), + HintNames.Disambiguated("Demo.type", LuaFunctionTableModel.HintSuffix)); + AssertHintNames(reverse, + HintNames.ForType("Demo.Type", LuaFunctionTableModel.HintSuffix), + HintNames.Disambiguated("Demo.type", LuaFunctionTableModel.HintSuffix)); + } + + private static ContainingTypeModel Type(string fullyQualifiedName, string name) + { + return new ContainingTypeModel("Demo", + new EquatableArray([new TypeDeclarationModel("class", name)]), + fullyQualifiedName, "Demo." + name); + } + + private static LuaFunctionModel Function(ContainingTypeModel type, string luaName) + { + return new LuaFunctionModel( + type, + ContainingTypeIssues.None, + luaName, + LuaFunctionShapeIssues.None, + new LuaThunkModel(luaName, LuaThunkModel.ThunkNameFor(luaName), type.FullyQualifiedName + ".M", + false, EquatableArray.Empty, null)); + } + + private static void AssertHintNames(EquatableArray tables, string first, string second) + { + Assert.Equal(2, tables.Length); + Assert.Equal(first, tables[0].HintName, StringComparer.Ordinal); + Assert.Equal(second, tables[1].HintName, StringComparer.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs index 4b5ee406..87a5b8bc 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs @@ -2,6 +2,7 @@ using CheatEngine.SDK.SourceGenerators.Shared; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -10,97 +11,102 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Model; /// The grouping step of the [LuaGlobal] pipeline and the compilation facts. public sealed class LuaGlobalTablesTests { - private static readonly ContainingTypeModel Memory = - new("Demo", new EquatableArray([new TypeDeclarationModel("class", "Memory")]), - "global::Demo.Memory", "Demo.Memory"); + private static readonly ContainingTypeModel Memory = + new("Demo", new EquatableArray([new TypeDeclarationModel("class", "Memory")]), + "global::Demo.Memory", "Demo.Memory"); - [Fact] - public void Group_lists_each_global_once_and_sorts_bodies_by_sort_key() - { - var tables = LuaGlobalTables.Group( - [ - Global("readInteger", "TryReadInt32(nuint, out int)"), - Global("readString", "TryReadString(nuint, int, out string)"), - Global("readInteger", "ReadInt32(nuint)") - ]); + [Fact] + public void Group_lists_each_global_once_and_sorts_bodies_by_sort_key() + { + EquatableArray tables = LuaGlobalTables.Group( + [ + Global("readInteger", "TryReadInt32(nuint, out int)"), + Global("readString", "TryReadString(nuint, int, out string)"), + Global("readInteger", "ReadInt32(nuint)") + ]); - var table = Assert.Single(tables); - Assert.Equal(["readInteger", "readString"], table.CachedGlobals.AsImmutableArray(), StringComparer.Ordinal); - Assert.Equal( - ["ReadInt32(nuint)", "TryReadInt32(nuint, out int)", "TryReadString(nuint, int, out string)"], - table.Calls.AsImmutableArray().Select(static call => call.MethodName), - StringComparer.Ordinal); - } + LuaGlobalTableModel table = Assert.Single(tables); + Assert.Equal(["readInteger", "readString"], table.CachedGlobals.AsImmutableArray(), StringComparer.Ordinal); + Assert.Equal( + ["ReadInt32(nuint)", "TryReadInt32(nuint, out int)", "TryReadString(nuint, int, out string)"], + table.Calls.AsImmutableArray().Select(static call => call.MethodName), + StringComparer.Ordinal); + } - [Fact] - public void Group_skips_invalid_members_and_empty_inputs() - { - var invalid = Global("g", "G()") with { Issues = LuaGlobalShapeIssues.NotPartialDefinition, Call = null }; + [Fact] + public void Group_skips_invalid_members_and_empty_inputs() + { + LuaGlobalModel invalid = Global("g", "G()") with + { + Issues = LuaGlobalShapeIssues.NotPartialDefinition, Call = null + }; - Assert.True(LuaGlobalTables.Group([invalid]).IsEmpty); - Assert.True(LuaGlobalTables.Group([]).IsEmpty); - Assert.True(LuaGlobalTables.Group(default).IsEmpty); - Assert.Single(LuaGlobalTables.Group([invalid, Global("g", "G()")])); - } + Assert.True(LuaGlobalTables.Group([invalid]).IsEmpty); + Assert.True(LuaGlobalTables.Group([]).IsEmpty); + Assert.True(LuaGlobalTables.Group(default).IsEmpty); + Assert.Single(LuaGlobalTables.Group([invalid, Global("g", "G()")])); + } - [Fact] - public void CompilationFacts_reads_allow_unsafe_from_csharp_options_only() - { - var unsafeOn = CSharpCompilation.Create("a", - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); - var unsafeOff = CSharpCompilation.Create("b", - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: false)); + [Fact] + public void CompilationFacts_reads_allow_unsafe_from_csharp_options_only() + { + CSharpCompilation unsafeOn = CSharpCompilation.Create("a", + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + CSharpCompilation unsafeOff = CSharpCompilation.Create("b", + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: false)); - Assert.True(CompilationFacts.From(unsafeOn).AllowUnsafeBlocks); - Assert.False(CompilationFacts.From(unsafeOff).AllowUnsafeBlocks); - Assert.False(CompilationFacts.From(null!).AllowUnsafeBlocks); - Assert.Equal(CompilationFacts.From(unsafeOn), CompilationFacts.From(unsafeOn)); - } + Assert.True(CompilationFacts.From(unsafeOn).AllowUnsafeBlocks); + Assert.False(CompilationFacts.From(unsafeOff).AllowUnsafeBlocks); + Assert.False(CompilationFacts.From(null!).AllowUnsafeBlocks); + Assert.Equal(CompilationFacts.From(unsafeOn), CompilationFacts.From(unsafeOn)); + } - [Fact] - public void Group_assigns_case_insensitive_collision_names_independent_of_input_order() - { - var upper = Type("global::Demo.Type", "Type"); - var lower = Type("global::Demo.type", "type"); - var forward = LuaGlobalTables.Group([Global(lower, "lower", "Lower()"), Global(upper, "upper", "Upper()")]); - var reverse = LuaGlobalTables.Group([Global(upper, "upper", "Upper()"), Global(lower, "lower", "Lower()")]); + [Fact] + public void Group_assigns_case_insensitive_collision_names_independent_of_input_order() + { + ContainingTypeModel upper = Type("global::Demo.Type", "Type"); + ContainingTypeModel lower = Type("global::Demo.type", "type"); + EquatableArray forward = + LuaGlobalTables.Group([Global(lower, "lower", "Lower()"), Global(upper, "upper", "Upper()")]); + EquatableArray reverse = + LuaGlobalTables.Group([Global(upper, "upper", "Upper()"), Global(lower, "lower", "Lower()")]); - AssertHintNames(forward, - HintNames.ForType("Demo.Type", LuaGlobalTableModel.HintSuffix), - HintNames.Disambiguated("Demo.type", LuaGlobalTableModel.HintSuffix)); - AssertHintNames(reverse, - HintNames.ForType("Demo.Type", LuaGlobalTableModel.HintSuffix), - HintNames.Disambiguated("Demo.type", LuaGlobalTableModel.HintSuffix)); - } + AssertHintNames(forward, + HintNames.ForType("Demo.Type", LuaGlobalTableModel.HintSuffix), + HintNames.Disambiguated("Demo.type", LuaGlobalTableModel.HintSuffix)); + AssertHintNames(reverse, + HintNames.ForType("Demo.Type", LuaGlobalTableModel.HintSuffix), + HintNames.Disambiguated("Demo.type", LuaGlobalTableModel.HintSuffix)); + } - private static LuaGlobalModel Global(string name, string sortKey) - { - return Global(Memory, name, sortKey); - } + private static LuaGlobalModel Global(string name, string sortKey) + { + return Global(Memory, name, sortKey); + } - private static LuaGlobalModel Global(ContainingTypeModel type, string name, string sortKey) - { - return new LuaGlobalModel( - type, - ContainingTypeIssues.None, - LuaGlobalShapeIssues.None, - new LuaGlobalCallModel(name, LuaGlobalCallModel.CacheFieldFor(name), "public static partial", sortKey, - string.Empty, EquatableArray.Empty, LuaCallForm.Throwing, - EquatableArray.Empty, null, false), - sortKey); - } + private static LuaGlobalModel Global(ContainingTypeModel type, string name, string sortKey) + { + return new LuaGlobalModel( + type, + ContainingTypeIssues.None, + LuaGlobalShapeIssues.None, + new LuaGlobalCallModel(name, LuaGlobalCallModel.CacheFieldFor(name), "public static partial", sortKey, + string.Empty, EquatableArray.Empty, LuaCallForm.Throwing, + EquatableArray.Empty, null, false), + sortKey); + } - private static ContainingTypeModel Type(string fullyQualifiedName, string name) - { - return new ContainingTypeModel("Demo", - new EquatableArray([new TypeDeclarationModel("class", name)]), - fullyQualifiedName, "Demo." + name); - } + private static ContainingTypeModel Type(string fullyQualifiedName, string name) + { + return new ContainingTypeModel("Demo", + new EquatableArray([new TypeDeclarationModel("class", name)]), + fullyQualifiedName, "Demo." + name); + } - private static void AssertHintNames(EquatableArray tables, string first, string second) - { - Assert.Equal(2, tables.Length); - Assert.Equal(first, tables[0].HintName, StringComparer.Ordinal); - Assert.Equal(second, tables[1].HintName, StringComparer.Ordinal); - } + private static void AssertHintNames(EquatableArray tables, string first, string second) + { + Assert.Equal(2, tables.Length); + Assert.Equal(first, tables[0].HintName, StringComparer.Ordinal); + Assert.Equal(second, tables[1].HintName, StringComparer.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs index 05a7112b..27ecdc26 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.SourceGenerators.Shared; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; @@ -6,90 +7,90 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; /// The per-type hint-name sanitiser of Shared/HintNames.cs. public sealed class HintNamesTests { - [Theory] - [InlineData("Demo.Functions", "Demo.Functions.LuaFunctions.g.cs")] - [InlineData("Functions", "Functions.LuaFunctions.g.cs")] - [InlineData("Demo.Outer.Inner_2", "Demo.Outer.Inner_2.LuaFunctions.g.cs")] - public void ForType_plain_name_passes_through(string typeName, string expected) - { - Assert.Equal(expected, HintNames.ForType(typeName, ".LuaFunctions.g.cs")); - } - - [Fact] - public void ForType_replaced_characters_get_an_underscore_and_a_hash() - { - var hint = HintNames.ForType("Demo.Caf\u00E9", ".g.cs"); - - Assert.StartsWith("Demo.Caf__", hint, StringComparison.Ordinal); - Assert.EndsWith(".g.cs", hint, StringComparison.Ordinal); - Assert.Equal("Demo.Caf__".Length + 8 + ".g.cs".Length, hint.Length); - Assert.All(hint, c => Assert.True(c < 128, "non-ASCII character in a hint name")); - } - - [Fact] - public void ForType_names_differing_only_in_replaced_characters_stay_distinct() - { - var a = HintNames.ForType("Demo.Caf\u00E9", ".g.cs"); - var b = HintNames.ForType("Demo.Caf\u00E8", ".g.cs"); - var c = HintNames.ForType("Demo.Caf/", ".g.cs"); - - Assert.NotEqual(a, b, StringComparer.Ordinal); - Assert.NotEqual(a, c, StringComparer.Ordinal); - Assert.Equal(a, HintNames.ForType("Demo.Caf\u00E9", ".g.cs"), StringComparer.Ordinal); - } - - [Fact] - public void ForType_rejects_null() - { - Assert.Throws(() => HintNames.ForType(null!, ".g.cs")); - Assert.Throws(() => HintNames.ForType("A", null!)); - } - - [Fact] - public void AllocateUnique_prepopulated_readable_name_uses_the_deterministic_hash_candidate() - { - const string TypeName = "Demo.Caf\u00E9"; - const string Suffix = ".g.cs"; - var readable = HintNames.ForType(TypeName, Suffix); - var used = HintNames.CreateUsedNames(); - Assert.True(used.Add(readable)); - - var hint = HintNames.AllocateUnique(TypeName, Suffix, used); - - Assert.NotEqual(readable, hint, StringComparer.Ordinal); - Assert.Equal(HintNames.Disambiguated(TypeName, Suffix), hint, StringComparer.Ordinal); - Assert.Contains(hint, used); - } - - [Fact] - public void AllocateUnique_third_collision_uses_the_first_available_ordinal_suffix() - { - const string TypeName = "Demo.Type"; - const string Suffix = ".g.cs"; - var readable = HintNames.ForType(TypeName, Suffix); - var hashed = HintNames.Disambiguated(TypeName, Suffix); - var second = WithOrdinal(hashed, Suffix, 2); - var used = HintNames.CreateUsedNames(); - Assert.True(used.Add(readable)); - Assert.True(used.Add(hashed)); - Assert.True(used.Add(second)); - - var hint = HintNames.AllocateUnique(TypeName, Suffix, used); - - Assert.Equal(WithOrdinal(hashed, Suffix, 3), hint, StringComparer.Ordinal); - Assert.Contains(hint, used); - } - - [Fact] - public void AllocateUnique_rejects_a_case_sensitive_reservation_set() - { - Assert.Throws(() => - HintNames.AllocateUnique("Demo.Type", ".g.cs", new HashSet(StringComparer.Ordinal))); - } - - private static string WithOrdinal(string hintName, string suffix, int ordinal) - { - return hintName[..^suffix.Length] + "_" + ordinal.ToString(CultureInfo.InvariantCulture) + - suffix; - } + [Theory] + [InlineData("Demo.Functions", "Demo.Functions.LuaFunctions.g.cs")] + [InlineData("Functions", "Functions.LuaFunctions.g.cs")] + [InlineData("Demo.Outer.Inner_2", "Demo.Outer.Inner_2.LuaFunctions.g.cs")] + public void ForType_plain_name_passes_through(string typeName, string expected) + { + Assert.Equal(expected, HintNames.ForType(typeName, ".LuaFunctions.g.cs")); + } + + [Fact] + public void ForType_replaced_characters_get_an_underscore_and_a_hash() + { + string hint = HintNames.ForType("Demo.Caf\u00E9", ".g.cs"); + + Assert.StartsWith("Demo.Caf__", hint, StringComparison.Ordinal); + Assert.EndsWith(".g.cs", hint, StringComparison.Ordinal); + Assert.Equal("Demo.Caf__".Length + 8 + ".g.cs".Length, hint.Length); + Assert.All(hint, c => Assert.True(c < 128, "non-ASCII character in a hint name")); + } + + [Fact] + public void ForType_names_differing_only_in_replaced_characters_stay_distinct() + { + string a = HintNames.ForType("Demo.Caf\u00E9", ".g.cs"); + string b = HintNames.ForType("Demo.Caf\u00E8", ".g.cs"); + string c = HintNames.ForType("Demo.Caf/", ".g.cs"); + + Assert.NotEqual(a, b, StringComparer.Ordinal); + Assert.NotEqual(a, c, StringComparer.Ordinal); + Assert.Equal(a, HintNames.ForType("Demo.Caf\u00E9", ".g.cs"), StringComparer.Ordinal); + } + + [Fact] + public void ForType_rejects_null() + { + Assert.Throws(() => HintNames.ForType(null!, ".g.cs")); + Assert.Throws(() => HintNames.ForType("A", null!)); + } + + [Fact] + public void AllocateUnique_prepopulated_readable_name_uses_the_deterministic_hash_candidate() + { + const string TypeName = "Demo.Caf\u00E9"; + const string Suffix = ".g.cs"; + string readable = HintNames.ForType(TypeName, Suffix); + HashSet used = HintNames.CreateUsedNames(); + Assert.True(used.Add(readable)); + + string hint = HintNames.AllocateUnique(TypeName, Suffix, used); + + Assert.NotEqual(readable, hint, StringComparer.Ordinal); + Assert.Equal(HintNames.Disambiguated(TypeName, Suffix), hint, StringComparer.Ordinal); + Assert.Contains(hint, used); + } + + [Fact] + public void AllocateUnique_third_collision_uses_the_first_available_ordinal_suffix() + { + const string TypeName = "Demo.Type"; + const string Suffix = ".g.cs"; + string readable = HintNames.ForType(TypeName, Suffix); + string hashed = HintNames.Disambiguated(TypeName, Suffix); + string second = WithOrdinal(hashed, Suffix, 2); + HashSet used = HintNames.CreateUsedNames(); + Assert.True(used.Add(readable)); + Assert.True(used.Add(hashed)); + Assert.True(used.Add(second)); + + string hint = HintNames.AllocateUnique(TypeName, Suffix, used); + + Assert.Equal(WithOrdinal(hashed, Suffix, 3), hint, StringComparer.Ordinal); + Assert.Contains(hint, used); + } + + [Fact] + public void AllocateUnique_rejects_a_case_sensitive_reservation_set() + { + Assert.Throws(() => + HintNames.AllocateUnique("Demo.Type", ".g.cs", new HashSet(StringComparer.Ordinal))); + } + + private static string WithOrdinal(string hintName, string suffix, int ordinal) + { + return hintName[..^suffix.Length] + "_" + ordinal.ToString(CultureInfo.InvariantCulture) + + suffix; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs index 6b47bcb8..732aa358 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs @@ -1,10 +1,12 @@ using System.Collections.Immutable; using System.Reflection; + using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -19,90 +21,90 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; /// public sealed class LuaApiNamesTests { - private static readonly Assembly LuaAssembly = typeof(LuaState).Assembly; - private static readonly Assembly EngineAssembly = typeof(CEObject).Assembly; + private static readonly Assembly LuaAssembly = typeof(LuaState).Assembly; + private static readonly Assembly EngineAssembly = typeof(CEObject).Assembly; - [Fact] - public void Every_CheatEngine_SDK_name_written_into_generated_code_denotes_a_real_member_of_the_lua_assembly() - { - var names = typeof(LuaApiNames) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(field => field is { IsLiteral: true } && field.FieldType == typeof(string)) - .Select(field => (string)field.GetRawConstantValue()!) - .Where(value => value.StartsWith("global::CheatEngine.SDK.", StringComparison.Ordinal)) - .ToArray(); + [Fact] + public void Every_CheatEngine_SDK_name_written_into_generated_code_denotes_a_real_member_of_the_lua_assembly() + { + string[] names = typeof(LuaApiNames) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field is { IsLiteral: true } && field.FieldType == typeof(string)) + .Select(field => (string) field.GetRawConstantValue()!) + .Where(value => value.StartsWith("global::CheatEngine.SDK.", StringComparison.Ordinal)) + .ToArray(); - Assert.NotEmpty(names); - foreach (var name in names) - { - var text = name["global::".Length..]; - if (text.EndsWith("()", StringComparison.Ordinal)) - { - var dot = text.LastIndexOf('.'); - var owner = FindType(text[..dot]) - ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); - Assert.NotEmpty(owner.GetMember( - text[(dot + 1)..^2], - MemberTypes.Method, - BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)); - } - else - { - _ = FindType(text) - ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); - } - } - } + Assert.NotEmpty(names); + foreach (string name in names) + { + string text = name["global::".Length..]; + if (text.EndsWith("()", StringComparison.Ordinal)) + { + int dot = text.LastIndexOf('.'); + Type owner = FindType(text[..dot]) + ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); + Assert.NotEmpty(owner.GetMember( + text[(dot + 1)..^2], + MemberTypes.Method, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)); + } + else + { + _ = FindType(text) + ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); + } + } + } - private static Type? FindType(string fullName) - { - return LuaAssembly.GetType(fullName) ?? LuaAssembly.GetType(fullName + "`1") - ?? EngineAssembly.GetType(fullName) ?? EngineAssembly.GetType(fullName + "`1"); - } + private static Type? FindType(string fullName) + { + return LuaAssembly.GetType(fullName) ?? LuaAssembly.GetType(fullName + "`1") + ?? EngineAssembly.GetType(fullName) ?? EngineAssembly.GetType(fullName + "`1"); + } - [Fact] - public void The_lua_state_the_parsers_recognise_is_the_real_LuaState() - { - ImmutableArray references = - [ - .. LocalFrameworkReferences.Load(), - MetadataReference.CreateFromFile(LuaAssembly.Location) - ]; - var compilation = CSharpCompilation.Create("RealLuaState", references: references); + [Fact] + public void The_lua_state_the_parsers_recognise_is_the_real_LuaState() + { + ImmutableArray references = + [ + .. LocalFrameworkReferences.Load(), + MetadataReference.CreateFromFile(LuaAssembly.Location) + ]; + CSharpCompilation compilation = CSharpCompilation.Create("RealLuaState", references: references); - var luaState = compilation.GetTypeByMetadataName(typeof(LuaState).FullName!); + INamedTypeSymbol? luaState = compilation.GetTypeByMetadataName(typeof(LuaState).FullName!); - Assert.NotNull(luaState); - Assert.True(LuaValueKindMapper.IsLuaState(luaState, luaState)); - Assert.False(LuaValueKindMapper.IsLuaState(compilation.GetSpecialType(SpecialType.System_Int32), luaState)); - } + Assert.NotNull(luaState); + Assert.True(LuaValueKindMapper.IsLuaState(luaState, luaState)); + Assert.False(LuaValueKindMapper.IsLuaState(compilation.GetSpecialType(SpecialType.System_Int32), luaState)); + } - // The SDK namespaces are two segments deep (CheatEngine.SDK): a walk that stops early, starts late or forgets to end - // at the global namespace would take one of these look-alikes for the real LuaState. - [Theory] - [InlineData("CheatEngine.Lua.State")] - [InlineData("SDK.Lua.State")] - [InlineData("Lua.State")] - [InlineData("Other.CheatEngine.SDK.Lua.State")] - [InlineData("CheatEngine.SDK.State")] - public void A_look_alike_LuaState_outside_the_sdk_namespace_is_not_recognised(string @namespace) - { - var compilation = CSharpCompilation.Create( - "LookAlikeLuaState", - [ - CSharpSyntaxTree.ParseText($"namespace {@namespace} {{ public struct LuaState {{ }} }}", - cancellationToken: TestContext.Current.CancellationToken) - ], - [ - .. LocalFrameworkReferences.Load(), - MetadataReference.CreateFromFile(LuaAssembly.Location), - ]); + // The SDK namespaces are two segments deep (CheatEngine.SDK): a walk that stops early, starts late or forgets to end + // at the global namespace would take one of these look-alikes for the real LuaState. + [Theory] + [InlineData("CheatEngine.Lua.State")] + [InlineData("SDK.Lua.State")] + [InlineData("Lua.State")] + [InlineData("Other.CheatEngine.SDK.Lua.State")] + [InlineData("CheatEngine.SDK.State")] + public void A_look_alike_LuaState_outside_the_sdk_namespace_is_not_recognised(string @namespace) + { + CSharpCompilation compilation = CSharpCompilation.Create( + "LookAlikeLuaState", + [ + CSharpSyntaxTree.ParseText($"namespace {@namespace} {{ public struct LuaState {{ }} }}", + cancellationToken: TestContext.Current.CancellationToken) + ], + [ + .. LocalFrameworkReferences.Load(), + MetadataReference.CreateFromFile(LuaAssembly.Location) + ]); - var lookAlike = compilation.GetTypeByMetadataName(@namespace + ".LuaState"); - var luaState = compilation.GetTypeByMetadataName(typeof(LuaState).FullName!); + INamedTypeSymbol? lookAlike = compilation.GetTypeByMetadataName(@namespace + ".LuaState"); + INamedTypeSymbol? luaState = compilation.GetTypeByMetadataName(typeof(LuaState).FullName!); - Assert.NotNull(lookAlike); - Assert.NotNull(luaState); - Assert.False(LuaValueKindMapper.IsLuaState(lookAlike, luaState)); - } + Assert.NotNull(lookAlike); + Assert.NotNull(luaState); + Assert.False(LuaValueKindMapper.IsLuaState(lookAlike, luaState)); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs index 3b64f60f..1b193a99 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs @@ -9,207 +9,207 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; /// public sealed class LuaGlobalCallEmitterTests { - [Fact] - public void Emit_try_form_with_one_result_writes_the_exact_call_shape() - { - LuaGlobalCallModel model = new( - "readInteger", - "s_luaGlobal_readInteger", - "public static", - "TryReadInt32", - string.Empty, - new EquatableArray( - [new LuaArgumentModel("address", LuaValueKind.Address, IsNullable: false)]), - LuaCallForm.Try, - new EquatableArray([LuaResultModel.Value(LuaValueKind.Int32, "value")]), - null, - false); - - Assert.Equal( - """ - public static bool TryReadInt32(nuint address, out int value) - { - using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); - global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; - int __top = __L.Top; - try - { - if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_readInteger, "readInteger"u8)) - { - return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); - } - - global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); - if (!__L.TryCall(1, 1).IsOk) - { - return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); - } - - bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); - return __ok; - } - catch (global::CheatEngine.SDK.Lua.Calls.LuaException) - { - value = default; - return false; - } - finally - { - __L.SetTop(__top); - } - } - - """.ReplaceLineEndings("\n"), - Emit(model)); - } - - [Fact] - public void Emit_throwing_void_form_keeps_no_result() - { - LuaGlobalCallModel model = new( - "beep", - "s_luaGlobal_beep", - "internal static", - "Beep", - string.Empty, - EquatableArray.Empty, - LuaCallForm.Throwing, - EquatableArray.Empty, - null, - false); - - Assert.Equal( - """ - internal static void Beep() - { - using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); - global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; - int __top = __L.Top; - try - { - if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_beep, "beep"u8)) - { - global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "beep"); - } - - global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(0, 0); - if (!__status.IsOk) - { - global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); - } - } - finally - { - __L.SetTop(__top); - } - } - - """.ReplaceLineEndings("\n"), - Emit(model)); - } - - [Fact] - public void Emit_throwing_string_form_reads_a_nullable_local_and_names_the_expected_kind() - { - LuaGlobalCallModel model = new( - "readString", - "s_luaGlobal_readString", - "public static", - "ReadString", - "L", - new EquatableArray([ - new LuaArgumentModel("address", LuaValueKind.Address, false), - new LuaArgumentModel("text", LuaValueKind.String, true) - ]), - LuaCallForm.Throwing, - EquatableArray.Empty, - LuaValueKind.String, - true); - - var text = Emit(model); - Assert.StartsWith( - "public static string? ReadString(global::CheatEngine.SDK.Lua.State.LuaState L, nuint address, string? text)\n", - text, - StringComparison.Ordinal); - Assert.Contains( - "using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(L);\n", - text, - StringComparison.Ordinal); - Assert.Contains("global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State;\n", text, - StringComparison.Ordinal); - Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__L, text);\n", text, - StringComparison.Ordinal); - Assert.Contains( - "if (!global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(__L, -1, out string? __result))\n", - text, - StringComparison.Ordinal); - Assert.Contains("ThrowUnexpectedResult(__L, __top, -1, \"readString\", \"a string\");", text, - StringComparison.Ordinal); - Assert.Contains("return __result;\n }\n finally\n {\n __L.SetTop(__top);", text, - StringComparison.Ordinal); - } - - [Fact] - public void WriteParameterList_writes_state_arguments_and_copy_out_results() - { - LuaGlobalCallModel model = new( - "readString", - "s_luaGlobal_readString", - "public static partial", - "TryReadString", - "state", - new EquatableArray([ - new LuaArgumentModel("address", LuaValueKind.Address, false), - new LuaArgumentModel("maxLength", LuaValueKind.Int32, false) - ]), - LuaCallForm.Try, - new EquatableArray([ - LuaResultModel.CopyOut("destination", "written"), - LuaResultModel.Value(LuaValueKind.String, "text", true) - ]), - null, - false); - - SourceWriter writer = new(); - LuaGlobalCallEmitter.WriteParameterList(writer, model); - - Assert.Equal( - "(global::CheatEngine.SDK.Lua.State.LuaState state, nuint address, int maxLength, global::System.Span destination, out int written, out string? text)", - writer.ToString()); - Assert.Equal("bool", LuaGlobalCallEmitter.ReturnTypeName(model)); - Assert.Equal(2, model.ResultCount); - Assert.True(model.TakesState); - } - - [Fact] - public void ResultCount_follows_the_form() - { - LuaGlobalCallModel throwingVoid = new("g", "s", "static", "G", string.Empty, - EquatableArray.Empty, LuaCallForm.Throwing, EquatableArray.Empty, - ReturnKind: null, ReturnIsNullable: false); - var throwingValue = throwingVoid with { ReturnKind = LuaValueKind.Double }; - - Assert.Equal(0, throwingVoid.ResultCount); - Assert.Equal(1, throwingValue.ResultCount); - Assert.Equal("void", LuaGlobalCallEmitter.ReturnTypeName(throwingVoid)); - Assert.Equal("double", LuaGlobalCallEmitter.ReturnTypeName(throwingValue)); - Assert.Equal("s_luaGlobal_readInteger", LuaGlobalCallModel.CacheFieldFor("readInteger")); - } - - [Fact] - public void Emit_rejects_null_arguments() - { - LuaGlobalCallModel model = new("g", "s", "static", "G", string.Empty, EquatableArray.Empty, - LuaCallForm.Throwing, EquatableArray.Empty, null, false); - - Assert.Throws(() => LuaGlobalCallEmitter.Emit(null!, model)); - Assert.Throws(() => LuaGlobalCallEmitter.Emit(new SourceWriter(), null!)); - } - - private static string Emit(LuaGlobalCallModel model) - { - SourceWriter writer = new(); - LuaGlobalCallEmitter.Emit(writer, model); - return writer.ToString(); - } + [Fact] + public void Emit_try_form_with_one_result_writes_the_exact_call_shape() + { + LuaGlobalCallModel model = new( + "readInteger", + "s_luaGlobal_readInteger", + "public static", + "TryReadInt32", + string.Empty, + new EquatableArray( + [new LuaArgumentModel("address", LuaValueKind.Address, false)]), + LuaCallForm.Try, + new EquatableArray([LuaResultModel.Value(LuaValueKind.Int32, "value")]), + null, + false); + + Assert.Equal( + """ + public static bool TryReadInt32(nuint address, out int value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_readInteger, "readInteger"u8)) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + if (!__L.TryCall(1, 1).IsOk) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); + return __ok; + } + catch (global::CheatEngine.SDK.Lua.Calls.LuaException) + { + value = default; + return false; + } + finally + { + __L.SetTop(__top); + } + } + + """.ReplaceLineEndings("\n"), + Emit(model)); + } + + [Fact] + public void Emit_throwing_void_form_keeps_no_result() + { + LuaGlobalCallModel model = new( + "beep", + "s_luaGlobal_beep", + "internal static", + "Beep", + string.Empty, + EquatableArray.Empty, + LuaCallForm.Throwing, + EquatableArray.Empty, + null, + false); + + Assert.Equal( + """ + internal static void Beep() + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_beep, "beep"u8)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "beep"); + } + + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(0, 0); + if (!__status.IsOk) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); + } + } + finally + { + __L.SetTop(__top); + } + } + + """.ReplaceLineEndings("\n"), + Emit(model)); + } + + [Fact] + public void Emit_throwing_string_form_reads_a_nullable_local_and_names_the_expected_kind() + { + LuaGlobalCallModel model = new( + "readString", + "s_luaGlobal_readString", + "public static", + "ReadString", + "L", + new EquatableArray([ + new LuaArgumentModel("address", LuaValueKind.Address, false), + new LuaArgumentModel("text", LuaValueKind.String, true) + ]), + LuaCallForm.Throwing, + EquatableArray.Empty, + LuaValueKind.String, + true); + + string text = Emit(model); + Assert.StartsWith( + "public static string? ReadString(global::CheatEngine.SDK.Lua.State.LuaState L, nuint address, string? text)\n", + text, + StringComparison.Ordinal); + Assert.Contains( + "using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(L);\n", + text, + StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State;\n", text, + StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__L, text);\n", text, + StringComparison.Ordinal); + Assert.Contains( + "if (!global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(__L, -1, out string? __result))\n", + text, + StringComparison.Ordinal); + Assert.Contains("ThrowUnexpectedResult(__L, __top, -1, \"readString\", \"a string\");", text, + StringComparison.Ordinal); + Assert.Contains("return __result;\n }\n finally\n {\n __L.SetTop(__top);", text, + StringComparison.Ordinal); + } + + [Fact] + public void WriteParameterList_writes_state_arguments_and_copy_out_results() + { + LuaGlobalCallModel model = new( + "readString", + "s_luaGlobal_readString", + "public static partial", + "TryReadString", + "state", + new EquatableArray([ + new LuaArgumentModel("address", LuaValueKind.Address, false), + new LuaArgumentModel("maxLength", LuaValueKind.Int32, false) + ]), + LuaCallForm.Try, + new EquatableArray([ + LuaResultModel.CopyOut("destination", "written"), + LuaResultModel.Value(LuaValueKind.String, "text", true) + ]), + null, + false); + + SourceWriter writer = new(); + LuaGlobalCallEmitter.WriteParameterList(writer, model); + + Assert.Equal( + "(global::CheatEngine.SDK.Lua.State.LuaState state, nuint address, int maxLength, global::System.Span destination, out int written, out string? text)", + writer.ToString()); + Assert.Equal("bool", LuaGlobalCallEmitter.ReturnTypeName(model)); + Assert.Equal(2, model.ResultCount); + Assert.True(model.TakesState); + } + + [Fact] + public void ResultCount_follows_the_form() + { + LuaGlobalCallModel throwingVoid = new("g", "s", "static", "G", string.Empty, + EquatableArray.Empty, LuaCallForm.Throwing, EquatableArray.Empty, + null, false); + LuaGlobalCallModel throwingValue = throwingVoid with { ReturnKind = LuaValueKind.Double }; + + Assert.Equal(0, throwingVoid.ResultCount); + Assert.Equal(1, throwingValue.ResultCount); + Assert.Equal("void", LuaGlobalCallEmitter.ReturnTypeName(throwingVoid)); + Assert.Equal("double", LuaGlobalCallEmitter.ReturnTypeName(throwingValue)); + Assert.Equal("s_luaGlobal_readInteger", LuaGlobalCallModel.CacheFieldFor("readInteger")); + } + + [Fact] + public void Emit_rejects_null_arguments() + { + LuaGlobalCallModel model = new("g", "s", "static", "G", string.Empty, EquatableArray.Empty, + LuaCallForm.Throwing, EquatableArray.Empty, null, false); + + Assert.Throws(() => LuaGlobalCallEmitter.Emit(null!, model)); + Assert.Throws(() => LuaGlobalCallEmitter.Emit(new SourceWriter(), null!)); + } + + private static string Emit(LuaGlobalCallModel model) + { + SourceWriter writer = new(); + LuaGlobalCallEmitter.Emit(writer, model); + return writer.ToString(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaNamesTests.cs index 674ed701..1dad56bd 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaNamesTests.cs @@ -8,51 +8,51 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; /// public sealed class LuaNamesTests { - [Theory] - [InlineData("readInteger")] - [InlineData("_G")] - [InlineData("a")] - [InlineData("x1")] - [InlineData("__thunk")] - [InlineData("END")] - [InlineData("nilValue")] - public void IsValidName_lua_identifier_true(string name) - { - Assert.True(LuaNames.IsValidName(name)); - } + [Theory] + [InlineData("readInteger")] + [InlineData("_G")] + [InlineData("a")] + [InlineData("x1")] + [InlineData("__thunk")] + [InlineData("END")] + [InlineData("nilValue")] + public void IsValidName_lua_identifier_true(string name) + { + Assert.True(LuaNames.IsValidName(name)); + } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("1abc")] - [InlineData("read-int")] - [InlineData("ce.read")] - [InlineData("with space")] - [InlineData("caf\u00E9")] - [InlineData("and")] - [InlineData("end")] - [InlineData("function")] - [InlineData("goto")] - [InlineData("nil")] - [InlineData("true")] - public void IsValidName_not_an_identifier_or_reserved_false(string? name) - { - Assert.False(LuaNames.IsValidName(name)); - } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("1abc")] + [InlineData("read-int")] + [InlineData("ce.read")] + [InlineData("with space")] + [InlineData("caf\u00E9")] + [InlineData("and")] + [InlineData("end")] + [InlineData("function")] + [InlineData("goto")] + [InlineData("nil")] + [InlineData("true")] + public void IsValidName_not_an_identifier_or_reserved_false(string? name) + { + Assert.False(LuaNames.IsValidName(name)); + } - [Fact] - public void IsReservedWord_covers_the_22_words_of_lua_5_3() - { - string[] reserved = - [ - "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in", - "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while" - ]; + [Fact] + public void IsReservedWord_covers_the_22_words_of_lua_5_3() + { + string[] reserved = + [ + "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in", + "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while" + ]; - Assert.Equal(22, reserved.Length); - Assert.All(reserved, word => Assert.True(LuaNames.IsReservedWord(word), word)); - Assert.False(LuaNames.IsReservedWord("End")); - Assert.False(LuaNames.IsReservedWord("self")); - } + Assert.Equal(22, reserved.Length); + Assert.All(reserved, word => Assert.True(LuaNames.IsReservedWord(word), word)); + Assert.False(LuaNames.IsReservedWord("End")); + Assert.False(LuaNames.IsReservedWord("self")); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs index 6d8334d2..41ec2b49 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs @@ -6,139 +6,139 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; /// The thunk and registration emitters over hand-built models (no Roslyn). public sealed class LuaThunkEmitterTests { - private static readonly LuaThunkModel Ping = new("ping", "__LuaThunk_ping", "global::Demo.Suite.Ping", - PassesState: false, EquatableArray.Empty, ReturnKind: null); + private static readonly LuaThunkModel Ping = new("ping", "__LuaThunk_ping", "global::Demo.Suite.Ping", + false, EquatableArray.Empty, null); - private static readonly LuaThunkModel IsInteger = new( - "isint", - "__LuaThunk_isint", - "global::Demo.Suite.IsInteger", - PassesState: true, - new EquatableArray( - [new LuaArgumentModel("value", LuaValueKind.Double, IsNullable: false)]), - LuaValueKind.Boolean); + private static readonly LuaThunkModel IsInteger = new( + "isint", + "__LuaThunk_isint", + "global::Demo.Suite.IsInteger", + true, + new EquatableArray( + [new LuaArgumentModel("value", LuaValueKind.Double, false)]), + LuaValueKind.Boolean); - [Fact] - public void Emit_void_target_without_arguments_checks_the_count_and_returns_zero() - { - Assert.Equal( - """ - [global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] - private static int __LuaThunk_ping(nint __handle) - { - global::CheatEngine.SDK.Lua.State.LuaState __L = new(__handle); - try - { - if (__L.Top != 0) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, "wrong number of arguments to 'ping' (0 expected)"u8); - } + [Fact] + public void Emit_void_target_without_arguments_checks_the_count_and_returns_zero() + { + Assert.Equal( + """ + [global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })] + private static int __LuaThunk_ping(nint __handle) + { + global::CheatEngine.SDK.Lua.State.LuaState __L = new(__handle); + try + { + if (__L.Top != 0) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, "wrong number of arguments to 'ping' (0 expected)"u8); + } - global::Demo.Suite.Ping(); - return 0; - } - catch (global::System.Exception __exception) - { - return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception); - } - } + global::Demo.Suite.Ping(); + return 0; + } + catch (global::System.Exception __exception) + { + return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception); + } + } - """.ReplaceLineEndings("\n"), - Emit(Ping)); - } + """.ReplaceLineEndings("\n"), + Emit(Ping)); + } - [Fact] - public void Emit_state_passing_target_passes_the_state_first_and_pushes_the_result() - { - var text = Emit(IsInteger); + [Fact] + public void Emit_state_passing_target_passes_the_state_first_and_pushes_the_result() + { + string text = Emit(IsInteger); - Assert.Contains("if (__L.Top != 1)\n", text, StringComparison.Ordinal); - Assert.Contains( - "if (!global::CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.TryRead(__L, 1, out double __arg0))\n", - text, - StringComparison.Ordinal); - Assert.Contains( - "return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 1, \"number\"u8);\n", text, - StringComparison.Ordinal); - Assert.Contains("bool __result = global::Demo.Suite.IsInteger(__L, __arg0);\n", text, StringComparison.Ordinal); - Assert.Contains( - "global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(__L, __result);\n return 1;\n", - text, - StringComparison.Ordinal); - } + Assert.Contains("if (__L.Top != 1)\n", text, StringComparison.Ordinal); + Assert.Contains( + "if (!global::CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.TryRead(__L, 1, out double __arg0))\n", + text, + StringComparison.Ordinal); + Assert.Contains( + "return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 1, \"number\"u8);\n", text, + StringComparison.Ordinal); + Assert.Contains("bool __result = global::Demo.Suite.IsInteger(__L, __arg0);\n", text, StringComparison.Ordinal); + Assert.Contains( + "global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(__L, __result);\n return 1;\n", + text, + StringComparison.Ordinal); + } - [Fact] - public void WrongArgumentCountMessage_names_the_function_and_the_count() - { - Assert.Equal("wrong number of arguments to 'add' (2 expected)", - LuaThunkEmitter.WrongArgumentCountMessage("add", 2)); - Assert.Equal("__LuaThunk_add", LuaThunkModel.ThunkNameFor("add")); - } + [Fact] + public void WrongArgumentCountMessage_names_the_function_and_the_count() + { + Assert.Equal("wrong number of arguments to 'add' (2 expected)", + LuaThunkEmitter.WrongArgumentCountMessage("add", 2)); + Assert.Equal("__LuaThunk_add", LuaThunkModel.ThunkNameFor("add")); + } - [Fact] - public void Registration_emits_a_lease_and_legacy_registration_pair_in_the_given_order() - { - SourceWriter writer = new(); - LuaRegistrationEmitter.Emit(writer, new EquatableArray([IsInteger, Ping]), string.Empty); - var text = writer.ToString(); + [Fact] + public void Registration_emits_a_lease_and_legacy_registration_pair_in_the_given_order() + { + SourceWriter writer = new(); + LuaRegistrationEmitter.Emit(writer, new EquatableArray([IsInteger, Ping]), string.Empty); + string text = writer.ToString(); - Assert.Contains( - "public static unsafe global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state, global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting)\n", - text, StringComparison.Ordinal); - Assert.Contains( - "new global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry(\"isint\", new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_isint)),", - text, StringComparison.Ordinal); - Assert.Contains( - "public static unsafe global::CheatEngine.SDK.Lua.Calls.LuaStatus RegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state)\n", - text, StringComparison.Ordinal); - Assert.Contains( - "public static global::CheatEngine.SDK.Lua.Calls.LuaStatus UnregisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state)\n", - text, StringComparison.Ordinal); - Assert.Contains( - "global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(state, new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_isint));", - text, - StringComparison.Ordinal); - Assert.True( - text.IndexOf("TrySetGlobal(\"isint\"u8)", StringComparison.Ordinal) < - text.IndexOf("TrySetGlobal(\"ping\"u8)", StringComparison.Ordinal), - "The order of the model was not kept."); - Assert.Contains("isint, ping.", text, StringComparison.Ordinal); - Assert.DoesNotContain("GeneratedCode", text, StringComparison.Ordinal); - } + Assert.Contains( + "public static unsafe global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state, global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting)\n", + text, StringComparison.Ordinal); + Assert.Contains( + "new global::CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry(\"isint\", new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_isint)),", + text, StringComparison.Ordinal); + Assert.Contains( + "public static unsafe global::CheatEngine.SDK.Lua.Calls.LuaStatus RegisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state)\n", + text, StringComparison.Ordinal); + Assert.Contains( + "public static global::CheatEngine.SDK.Lua.Calls.LuaStatus UnregisterLuaFunctions(global::CheatEngine.SDK.Lua.State.LuaState state)\n", + text, StringComparison.Ordinal); + Assert.Contains( + "global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(state, new global::CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction(&__LuaThunk_isint));", + text, + StringComparison.Ordinal); + Assert.True( + text.IndexOf("TrySetGlobal(\"isint\"u8)", StringComparison.Ordinal) < + text.IndexOf("TrySetGlobal(\"ping\"u8)", StringComparison.Ordinal), + "The order of the model was not kept."); + Assert.Contains("isint, ping.", text, StringComparison.Ordinal); + Assert.DoesNotContain("GeneratedCode", text, StringComparison.Ordinal); + } - [Fact] - public void Registration_puts_the_member_attributes_on_every_generated_registration_method() - { - SourceWriter writer = new(); - LuaRegistrationEmitter.Emit(writer, new EquatableArray([Ping]), "[Marker]"); - var text = writer.ToString(); + [Fact] + public void Registration_puts_the_member_attributes_on_every_generated_registration_method() + { + SourceWriter writer = new(); + LuaRegistrationEmitter.Emit(writer, new EquatableArray([Ping]), "[Marker]"); + string text = writer.ToString(); - Assert.Contains( - "[Marker]\npublic static unsafe global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions", - text, StringComparison.Ordinal); - Assert.Contains( - "[Marker]\npublic static unsafe global::CheatEngine.SDK.Lua.Calls.LuaStatus RegisterLuaFunctions", text, - StringComparison.Ordinal); - Assert.Contains("[Marker]\npublic static global::CheatEngine.SDK.Lua.Calls.LuaStatus UnregisterLuaFunctions", - text, - StringComparison.Ordinal); - } + Assert.Contains( + "[Marker]\npublic static unsafe global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions", + text, StringComparison.Ordinal); + Assert.Contains( + "[Marker]\npublic static unsafe global::CheatEngine.SDK.Lua.Calls.LuaStatus RegisterLuaFunctions", text, + StringComparison.Ordinal); + Assert.Contains("[Marker]\npublic static global::CheatEngine.SDK.Lua.Calls.LuaStatus UnregisterLuaFunctions", + text, + StringComparison.Ordinal); + } - [Fact] - public void Registration_rejects_an_empty_table_and_null_writer() - { - Assert.Throws(() => - LuaRegistrationEmitter.Emit(new SourceWriter(), EquatableArray.Empty, string.Empty)); - Assert.Throws(() => - LuaRegistrationEmitter.Emit(null!, new EquatableArray([Ping]), string.Empty)); - Assert.Throws(() => LuaThunkEmitter.Emit(null!, Ping)); - Assert.Throws(() => LuaThunkEmitter.Emit(new SourceWriter(), null!)); - } + [Fact] + public void Registration_rejects_an_empty_table_and_null_writer() + { + Assert.Throws(() => + LuaRegistrationEmitter.Emit(new SourceWriter(), EquatableArray.Empty, string.Empty)); + Assert.Throws(() => + LuaRegistrationEmitter.Emit(null!, new EquatableArray([Ping]), string.Empty)); + Assert.Throws(() => LuaThunkEmitter.Emit(null!, Ping)); + Assert.Throws(() => LuaThunkEmitter.Emit(new SourceWriter(), null!)); + } - private static string Emit(LuaThunkModel model) - { - SourceWriter writer = new(); - LuaThunkEmitter.Emit(writer, model); - return writer.ToString(); - } + private static string Emit(LuaThunkModel model) + { + SourceWriter writer = new(); + LuaThunkEmitter.Emit(writer, model); + return writer.ToString(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs index 82cad2b6..cb65ce68 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs @@ -10,57 +10,59 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.SharedCode; /// public sealed class LuaValueKindsTests { - [Theory] - [InlineData("Int32", "global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller", "int", "integer", "an integer")] - [InlineData("Int64", "global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller", "long", "integer", "an integer")] - [InlineData("Single", "global::CheatEngine.SDK.Lua.Marshalling.SingleMarshaller", "float", "number", "a number")] - [InlineData("Double", "global::CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller", "double", "number", "a number")] - [InlineData("Boolean", "global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller", "bool", "boolean", "a boolean")] - [InlineData("Address", "global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller", "nuint", "integer", - "an integer")] - [InlineData("Utf8", "global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller", "global::System.ReadOnlySpan", - "string", - "a string")] - [InlineData("String", "global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller", "string", "string", "a string")] - public void Kind_maps_to_marshaller_type_and_words(string kindName, string marshaller, string typeName, - string expectedArgument, string expectedResult) - { - // The enum is internal to the generator: rows name it, the test resolves it. - var kind = Enum.Parse(kindName); + [Theory] + [InlineData("Int32", "global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller", "int", "integer", "an integer")] + [InlineData("Int64", "global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller", "long", "integer", "an integer")] + [InlineData("Single", "global::CheatEngine.SDK.Lua.Marshalling.SingleMarshaller", "float", "number", "a number")] + [InlineData("Double", "global::CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller", "double", "number", "a number")] + [InlineData("Boolean", "global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller", "bool", "boolean", "a boolean")] + [InlineData("Address", "global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller", "nuint", "integer", + "an integer")] + [InlineData("Utf8", "global::CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller", "global::System.ReadOnlySpan", + "string", + "a string")] + [InlineData("String", "global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller", "string", "string", "a string")] + public void Kind_maps_to_marshaller_type_and_words(string kindName, string marshaller, string typeName, + string expectedArgument, string expectedResult) + { + // The enum is internal to the generator: rows name it, the test resolves it. + LuaValueKind kind = Enum.Parse(kindName); - Assert.Equal(marshaller, LuaValueKinds.MarshallerTypeName(kind)); - Assert.Equal(typeName, LuaValueKinds.TypeName(kind)); - Assert.Equal(expectedArgument, LuaValueKinds.ExpectedArgument(kind)); - Assert.Equal(expectedResult, LuaValueKinds.ExpectedResult(kind)); - } + Assert.Equal(marshaller, LuaValueKinds.MarshallerTypeName(kind)); + Assert.Equal(typeName, LuaValueKinds.TypeName(kind)); + Assert.Equal(expectedArgument, LuaValueKinds.ExpectedArgument(kind)); + Assert.Equal(expectedResult, LuaValueKinds.ExpectedResult(kind)); + } - [Fact] - public void Marshaller_names_denote_real_types_of_the_lua_assembly() - { - foreach (var kind in Enum.GetValues()) - { - var name = LuaValueKinds.MarshallerTypeName(kind) - .Replace("global::", string.Empty, StringComparison.Ordinal); - var type = typeof(LuaState).Assembly.GetType(name); - Assert.NotNull(type); - Assert.NotNull(type.GetMethod("Push")); - Assert.NotNull(type.GetMethod("TryRead")); - } - } + [Fact] + public void Marshaller_names_denote_real_types_of_the_lua_assembly() + { + foreach (LuaValueKind kind in Enum.GetValues()) + { + string name = LuaValueKinds.MarshallerTypeName(kind) + .Replace("global::", string.Empty, StringComparison.Ordinal); + Type? type = typeof(LuaState).Assembly.GetType(name); + Assert.NotNull(type); + Assert.NotNull(type.GetMethod("Push")); + Assert.NotNull(type.GetMethod("TryRead")); + } + } - [Fact] - public void String_is_the_only_nullable_and_reference_kind() - { - Assert.Equal("string?", LuaValueKinds.TypeName(LuaValueKind.String, true)); - Assert.Equal("int", LuaValueKinds.TypeName(LuaValueKind.Int32, true)); - Assert.True(LuaValueKinds.IsReferenceType(LuaValueKind.String)); - Assert.False(LuaValueKinds.IsReferenceType(LuaValueKind.Utf8)); - } + [Fact] + public void String_is_the_only_nullable_and_reference_kind() + { + Assert.Equal("string?", LuaValueKinds.TypeName(LuaValueKind.String, true)); + Assert.Equal("int", LuaValueKinds.TypeName(LuaValueKind.Int32, true)); + Assert.True(LuaValueKinds.IsReferenceType(LuaValueKind.String)); + Assert.False(LuaValueKinds.IsReferenceType(LuaValueKind.Utf8)); + } - [Fact] - public void Utf8_is_the_only_kind_that_cannot_be_a_result() - { - foreach (var kind in Enum.GetValues()) - Assert.Equal(kind != LuaValueKind.Utf8, LuaValueKinds.CanBeResult(kind)); - } + [Fact] + public void Utf8_is_the_only_kind_that_cannot_be_a_result() + { + foreach (LuaValueKind kind in Enum.GetValues()) + { + Assert.Equal(kind != LuaValueKind.Utf8, LuaValueKinds.CanBeResult(kind)); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs index e29d9c2e..4447ca01 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs @@ -1,5 +1,7 @@ using System.Globalization; + using CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructure; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Generator; @@ -7,110 +9,112 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Generator; /// Invalid additional-file content fails locally and never falls back to a stale generated contract. public sealed class CatalogDiagnosticsTests { - [Fact] - public void An_incorrect_bitmap_reports_the_external_file_value_and_emits_nothing() - { - var text = CatalogSources.ReverseOpcodeOrder.Replace("0x0000000000000401", "0x0000000000000001", - StringComparison.Ordinal); - const string Path = "eng/lua-bridge/protected-operations.json"; - var run = RoslynFixture.Run(Path, text); + [Fact] + public void An_incorrect_bitmap_reports_the_external_file_value_and_emits_nothing() + { + string text = CatalogSources.ReverseOpcodeOrder.Replace("0x0000000000000401", "0x0000000000000001", + StringComparison.Ordinal); + const string Path = "eng/lua-bridge/protected-operations.json"; + GeneratorRun run = RoslynFixture.Run(Path, text); - Assert.Empty(run.GeneratedSources); - var diagnostic = Assert.Single(run.GeneratorDiagnostics); - Assert.Equal("CESDK4001", diagnostic.Id); - Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); - var lineSpan = diagnostic.Location.GetLineSpan(); - Assert.Equal(Path, lineSpan.Path); - Assert.Equal(7, lineSpan.StartLinePosition.Line); - Assert.Contains("operationBitmap", diagnostic.GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } + Assert.Empty(run.GeneratedSources); + Diagnostic diagnostic = Assert.Single(run.GeneratorDiagnostics); + Assert.Equal("CESDK4001", diagnostic.Id); + Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); + FileLinePositionSpan lineSpan = diagnostic.Location.GetLineSpan(); + Assert.Equal(Path, lineSpan.Path); + Assert.Equal(7, lineSpan.StartLinePosition.Line); + Assert.Contains("operationBitmap", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } - [Fact] - public void Duplicate_opcode_reports_both_external_file_entries_and_emits_nothing() - { - var text = CatalogSources.ReverseOpcodeOrder - .Replace("\"opcode\": 10", "\"opcode\": 0", StringComparison.Ordinal) - .Replace("0x0000000000000401", "0x0000000000000001", StringComparison.Ordinal); - var run = RoslynFixture.Run("eng/lua-bridge/protected-operations.json", text); + [Fact] + public void Duplicate_opcode_reports_both_external_file_entries_and_emits_nothing() + { + string text = CatalogSources.ReverseOpcodeOrder + .Replace("\"opcode\": 10", "\"opcode\": 0", StringComparison.Ordinal) + .Replace("0x0000000000000401", "0x0000000000000001", StringComparison.Ordinal); + GeneratorRun run = RoslynFixture.Run("eng/lua-bridge/protected-operations.json", text); - Assert.Empty(run.GeneratedSources); - Assert.Equal(2, run.GeneratorDiagnostics.Length); - for (var i = 0; i < run.GeneratorDiagnostics.Length; i++) - { - Assert.Equal("CESDK4001", run.GeneratorDiagnostics[i].Id); - Assert.Equal(LocationKind.ExternalFile, run.GeneratorDiagnostics[i].Location.Kind); - Assert.Contains("opcode", run.GeneratorDiagnostics[i].GetMessage(CultureInfo.InvariantCulture), - StringComparison.Ordinal); - } - } + Assert.Empty(run.GeneratedSources); + Assert.Equal(2, run.GeneratorDiagnostics.Length); + for (int i = 0; i < run.GeneratorDiagnostics.Length; i++) + { + Assert.Equal("CESDK4001", run.GeneratorDiagnostics[i].Id); + Assert.Equal(LocationKind.ExternalFile, run.GeneratorDiagnostics[i].Location.Kind); + Assert.Contains("opcode", run.GeneratorDiagnostics[i].GetMessage(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + } - [Fact] - public void Two_catalogue_additional_files_report_each_file_and_emit_nothing() - { - var run = RoslynFixture.Run( - ("one/protected-operations.json", CatalogSources.ReverseOpcodeOrder), - ("two/protected-operations.json", CatalogSources.ReverseOpcodeOrder)); + [Fact] + public void Two_catalogue_additional_files_report_each_file_and_emit_nothing() + { + GeneratorRun run = RoslynFixture.Run( + ("one/protected-operations.json", CatalogSources.ReverseOpcodeOrder), + ("two/protected-operations.json", CatalogSources.ReverseOpcodeOrder)); - Assert.Empty(run.GeneratedSources); - Assert.Equal(2, run.GeneratorDiagnostics.Length); - for (var i = 0; i < run.GeneratorDiagnostics.Length; i++) - Assert.Equal("CESDK4002", run.GeneratorDiagnostics[i].Id); - } + Assert.Empty(run.GeneratedSources); + Assert.Equal(2, run.GeneratorDiagnostics.Length); + for (int i = 0; i < run.GeneratorDiagnostics.Length; i++) + { + Assert.Equal("CESDK4002", run.GeneratorDiagnostics[i].Id); + } + } - [Fact] - public void A_valid_and_malformed_catalogue_both_receive_the_ambiguity_diagnostic() - { - const string ValidPath = "one/protected-operations.json"; - const string InvalidPath = "two/protected-operations.json"; - var run = RoslynFixture.Run( - (ValidPath, CatalogSources.ReverseOpcodeOrder), - (InvalidPath, "{ \"schemaVersion\":")); + [Fact] + public void A_valid_and_malformed_catalogue_both_receive_the_ambiguity_diagnostic() + { + const string ValidPath = "one/protected-operations.json"; + const string InvalidPath = "two/protected-operations.json"; + GeneratorRun run = RoslynFixture.Run( + (ValidPath, CatalogSources.ReverseOpcodeOrder), + (InvalidPath, "{ \"schemaVersion\":")); - Assert.Empty(run.GeneratedSources); - Assert.Equal(3, run.GeneratorDiagnostics.Length); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK4001", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, - StringComparison.Ordinal)); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, ValidPath, - StringComparison.Ordinal)); - Assert.Contains(run.GeneratorDiagnostics, - static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, - StringComparison.Ordinal)); - } + Assert.Empty(run.GeneratedSources); + Assert.Equal(3, run.GeneratorDiagnostics.Length); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK4001", StringComparison.Ordinal) + && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, + StringComparison.Ordinal)); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) + && string.Equals(diagnostic.Location.GetLineSpan().Path, ValidPath, + StringComparison.Ordinal)); + Assert.Contains(run.GeneratorDiagnostics, + static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) + && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, + StringComparison.Ordinal)); + } - [Fact] - public void Malformed_json_reports_the_additional_file_and_emits_nothing() - { - const string Path = "eng/lua-bridge/protected-operations.json"; - var run = RoslynFixture.Run(Path, "{ \"schemaVersion\":"); + [Fact] + public void Malformed_json_reports_the_additional_file_and_emits_nothing() + { + const string Path = "eng/lua-bridge/protected-operations.json"; + GeneratorRun run = RoslynFixture.Run(Path, "{ \"schemaVersion\":"); - Assert.Empty(run.GeneratedSources); - var diagnostic = Assert.Single(run.GeneratorDiagnostics); - Assert.Equal("CESDK4001", diagnostic.Id); - Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); - Assert.Equal(Path, diagnostic.Location.GetLineSpan().Path); - } + Assert.Empty(run.GeneratedSources); + Diagnostic diagnostic = Assert.Single(run.GeneratorDiagnostics); + Assert.Equal("CESDK4001", diagnostic.Id); + Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); + Assert.Equal(Path, diagnostic.Location.GetLineSpan().Path); + } - [Fact] - public void A_duplicate_json_property_reports_the_second_property_and_emits_nothing() - { - const string Path = "eng/lua-bridge/protected-operations.json"; - const string Text = "{\n \"schemaVersion\": 1,\n \"schemaVersion\": 1\n}"; - var run = RoslynFixture.Run(Path, Text); + [Fact] + public void A_duplicate_json_property_reports_the_second_property_and_emits_nothing() + { + const string Path = "eng/lua-bridge/protected-operations.json"; + const string Text = "{\n \"schemaVersion\": 1,\n \"schemaVersion\": 1\n}"; + GeneratorRun run = RoslynFixture.Run(Path, Text); - Assert.Empty(run.GeneratedSources); - var diagnostic = Assert.Single(run.GeneratorDiagnostics); - Assert.Equal("CESDK4001", diagnostic.Id); - Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); - var lineSpan = diagnostic.Location.GetLineSpan(); - Assert.Equal(Path, lineSpan.Path); - Assert.Equal(2, lineSpan.StartLinePosition.Line); - Assert.Equal(2, lineSpan.StartLinePosition.Character); - Assert.Contains("duplicated", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); - } + Assert.Empty(run.GeneratedSources); + Diagnostic diagnostic = Assert.Single(run.GeneratorDiagnostics); + Assert.Equal("CESDK4001", diagnostic.Id); + Assert.Equal(LocationKind.ExternalFile, diagnostic.Location.Kind); + FileLinePositionSpan lineSpan = diagnostic.Location.GetLineSpan(); + Assert.Equal(Path, lineSpan.Path); + Assert.Equal(2, lineSpan.StartLinePosition.Line); + Assert.Equal(2, lineSpan.StartLinePosition.Character); + Assert.Contains("duplicated", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs index f9e1a37c..a8311263 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs @@ -1,123 +1,131 @@ using CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Generator; /// Golden and semantic tests for the generated internal bridge operation contract. public sealed class CatalogEmissionTests { - [Fact] - public void Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap() - { - var run = RoslynFixture.Run("eng/lua-bridge/protected-operations.json", CatalogSources.ReverseOpcodeOrder); - - run.AssertCompilesClean(); - var generated = run.SingleGeneratedText; - Assert.Contains("internal enum LuaProtectedOperation", generated, StringComparison.Ordinal); - Assert.Contains("PushBytes = 0", generated, StringComparison.Ordinal); - Assert.Contains("PushHostObject = 10", generated, StringComparison.Ordinal); - Assert.True(generated.IndexOf("PushBytes = 0", StringComparison.Ordinal) - < generated.IndexOf("PushHostObject = 10", StringComparison.Ordinal)); - Assert.Contains("internal const int Count = 2;", generated, StringComparison.Ordinal); - Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000401UL;", generated, - StringComparison.Ordinal); - Assert.Contains("opcode < 64", generated, StringComparison.Ordinal); - } - - [Fact] - public void Production_catalog_emits_all_C11_operations_and_the_exact_required_bitmap() - { - var run = RoslynFixture.Run("eng/lua-bridge/protected-operations.json", ProductionCatalog.Read()); - - run.AssertCompilesClean(); - var generated = run.SingleGeneratedText; - string[] expectedMembers = - [ - "PushBytes = 0", - "CreateTable = 1", - "NewUserdata = 2", - "PushClosure = 3", - "RawSet = 4", - "RawSetIndex = 5", - "RawSetPointer = 6", - "CreateReference = 7", - "PushReference = 8", - "ReleaseReference = 9", - "PushHostObject = 10", - "PushByteTable = 11", - ]; - for (var i = 0; i < expectedMembers.Length; i++) - Assert.Contains(expectedMembers[i], generated, StringComparison.Ordinal); - - Assert.Contains("internal const int Count = 12;", generated, StringComparison.Ordinal); - Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000FFFUL;", generated, - StringComparison.Ordinal); - } - - [Fact] - public void Catalog_an_identical_rerun_is_deterministic() - { - var additionalText = new InMemoryAdditionalText("eng/lua-bridge/protected-operations.json", - CatalogSources.ReverseOpcodeOrder); - var compilation = RoslynFixture.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(additionalText), compilation); - var second = GeneratorRun.Execute(first.Driver, compilation); - - Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText, StringComparer.Ordinal); - Assert.Empty(second.GeneratorDiagnostics); - Assert.Null(second.Result.Exception); - } - - [Fact] - public void Replacing_a_valid_catalogue_updates_members_and_the_required_bitmap_without_stale_source() - { - var original = new InMemoryAdditionalText("eng/lua-bridge/protected-operations.json", - CatalogSources.ReverseOpcodeOrder); - var compilation = RoslynFixture.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); - var replacement = new InMemoryAdditionalText( - original.Path, - CatalogSources.ReverseOpcodeOrder - .Replace("0x0000000000000401", "0x0000000000000011", StringComparison.Ordinal) - .Replace("\"opcode\": 10", "\"opcode\": 4", StringComparison.Ordinal)); - - var second = GeneratorRun.Execute(first.Driver.ReplaceAdditionalText(original, replacement), compilation); - - second.AssertCompilesClean(); - var generated = second.SingleGeneratedText; - Assert.Contains("PushHostObject = 4", generated, StringComparison.Ordinal); - Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000011UL;", generated, - StringComparison.Ordinal); - Assert.DoesNotContain("PushHostObject = 10", generated, StringComparison.Ordinal); - Assert.DoesNotContain("0x0000000000000401UL", generated, StringComparison.Ordinal); - } - - [Fact] - public void Replacing_a_valid_catalogue_with_an_invalid_one_removes_generated_source() - { - var original = new InMemoryAdditionalText("eng/lua-bridge/protected-operations.json", - CatalogSources.ReverseOpcodeOrder); - var compilation = RoslynFixture.CreateCompilation(); - var first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); - var replacement = new InMemoryAdditionalText( - original.Path, - CatalogSources.ReverseOpcodeOrder.Replace("0x0000000000000401", "0x0000000000000001", - StringComparison.Ordinal)); - - var second = GeneratorRun.Execute(first.Driver.ReplaceAdditionalText(original, replacement), compilation); - - Assert.Empty(second.GeneratedSources); - var diagnostic = Assert.Single(second.GeneratorDiagnostics); - Assert.Equal("CESDK4001", diagnostic.Id); - Assert.Null(second.Result.Exception); - } - - [Fact] - public void An_unrelated_additional_file_is_silent() - { - var run = RoslynFixture.Run("eng/lua-bridge/notes.json", CatalogSources.ReverseOpcodeOrder); - - Assert.Empty(run.GeneratedSources); - Assert.Empty(run.GeneratorDiagnostics); - Assert.Null(run.Result.Exception); - } + [Fact] + public void Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap() + { + GeneratorRun run = + RoslynFixture.Run("eng/lua-bridge/protected-operations.json", CatalogSources.ReverseOpcodeOrder); + + run.AssertCompilesClean(); + string generated = run.SingleGeneratedText; + Assert.Contains("internal enum LuaProtectedOperation", generated, StringComparison.Ordinal); + Assert.Contains("PushBytes = 0", generated, StringComparison.Ordinal); + Assert.Contains("PushHostObject = 10", generated, StringComparison.Ordinal); + Assert.True(generated.IndexOf("PushBytes = 0", StringComparison.Ordinal) + < generated.IndexOf("PushHostObject = 10", StringComparison.Ordinal)); + Assert.Contains("internal const int Count = 2;", generated, StringComparison.Ordinal); + Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000401UL;", generated, + StringComparison.Ordinal); + Assert.Contains("opcode < 64", generated, StringComparison.Ordinal); + } + + [Fact] + public void Production_catalog_emits_all_C11_operations_and_the_exact_required_bitmap() + { + GeneratorRun run = RoslynFixture.Run("eng/lua-bridge/protected-operations.json", ProductionCatalog.Read()); + + run.AssertCompilesClean(); + string generated = run.SingleGeneratedText; + string[] expectedMembers = + [ + "PushBytes = 0", + "CreateTable = 1", + "NewUserdata = 2", + "PushClosure = 3", + "RawSet = 4", + "RawSetIndex = 5", + "RawSetPointer = 6", + "CreateReference = 7", + "PushReference = 8", + "ReleaseReference = 9", + "PushHostObject = 10", + "PushByteTable = 11" + ]; + for (int i = 0; i < expectedMembers.Length; i++) + { + Assert.Contains(expectedMembers[i], generated, StringComparison.Ordinal); + } + + Assert.Contains("internal const int Count = 12;", generated, StringComparison.Ordinal); + Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000FFFUL;", generated, + StringComparison.Ordinal); + } + + [Fact] + public void Catalog_an_identical_rerun_is_deterministic() + { + InMemoryAdditionalText additionalText = new("eng/lua-bridge/protected-operations.json", + CatalogSources.ReverseOpcodeOrder); + CSharpCompilation compilation = RoslynFixture.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(additionalText), compilation); + GeneratorRun second = GeneratorRun.Execute(first.Driver, compilation); + + Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText, StringComparer.Ordinal); + Assert.Empty(second.GeneratorDiagnostics); + Assert.Null(second.Result.Exception); + } + + [Fact] + public void Replacing_a_valid_catalogue_updates_members_and_the_required_bitmap_without_stale_source() + { + InMemoryAdditionalText original = new("eng/lua-bridge/protected-operations.json", + CatalogSources.ReverseOpcodeOrder); + CSharpCompilation compilation = RoslynFixture.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); + InMemoryAdditionalText replacement = new( + original.Path, + CatalogSources.ReverseOpcodeOrder + .Replace("0x0000000000000401", "0x0000000000000011", StringComparison.Ordinal) + .Replace("\"opcode\": 10", "\"opcode\": 4", StringComparison.Ordinal)); + + GeneratorRun second = + GeneratorRun.Execute(first.Driver.ReplaceAdditionalText(original, replacement), compilation); + + second.AssertCompilesClean(); + string generated = second.SingleGeneratedText; + Assert.Contains("PushHostObject = 4", generated, StringComparison.Ordinal); + Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000011UL;", generated, + StringComparison.Ordinal); + Assert.DoesNotContain("PushHostObject = 10", generated, StringComparison.Ordinal); + Assert.DoesNotContain("0x0000000000000401UL", generated, StringComparison.Ordinal); + } + + [Fact] + public void Replacing_a_valid_catalogue_with_an_invalid_one_removes_generated_source() + { + InMemoryAdditionalText original = new("eng/lua-bridge/protected-operations.json", + CatalogSources.ReverseOpcodeOrder); + CSharpCompilation compilation = RoslynFixture.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); + InMemoryAdditionalText replacement = new( + original.Path, + CatalogSources.ReverseOpcodeOrder.Replace("0x0000000000000401", "0x0000000000000001", + StringComparison.Ordinal)); + + GeneratorRun second = + GeneratorRun.Execute(first.Driver.ReplaceAdditionalText(original, replacement), compilation); + + Assert.Empty(second.GeneratedSources); + Diagnostic diagnostic = Assert.Single(second.GeneratorDiagnostics); + Assert.Equal("CESDK4001", diagnostic.Id); + Assert.Null(second.Result.Exception); + } + + [Fact] + public void An_unrelated_additional_file_is_silent() + { + GeneratorRun run = RoslynFixture.Run("eng/lua-bridge/notes.json", CatalogSources.ReverseOpcodeOrder); + + Assert.Empty(run.GeneratedSources); + Assert.Empty(run.GeneratorDiagnostics); + Assert.Null(run.Result.Exception); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/CatalogSources.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/CatalogSources.cs index b15c70b3..f9f533fa 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/CatalogSources.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/CatalogSources.cs @@ -3,32 +3,32 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructur /// Compact valid catalogue fixtures; full provenance is intentionally irrelevant to this managed projection. internal static class CatalogSources { - public static readonly string ReverseOpcodeOrder = """ - { - "schemaVersion": 1, - "catalogId": "cheatengine-sdk-lua-protected-operations", - "bridgeContract": { - "abiMajor": 1, - "minimumAbiMinor": 1, - "operationBitmapWidth": 64, - "operationBitmap": "0x0000000000000401" - }, - "operations": [ - { - "id": "PushHostObject", - "managed": { "constant": "PushHostObjectOperation", "wrapper": "PushHostObject" }, - "opcode": 10, - "protected": true, - "requiresNativeProtection": true - }, - { - "id": "PushBytes", - "managed": { "constant": "PushBytesOperation", "wrapper": "PushBytes" }, - "opcode": 0, - "protected": true, - "requiresNativeProtection": true - } - ] - } - """; + public static readonly string ReverseOpcodeOrder = """ + { + "schemaVersion": 1, + "catalogId": "cheatengine-sdk-lua-protected-operations", + "bridgeContract": { + "abiMajor": 1, + "minimumAbiMinor": 1, + "operationBitmapWidth": 64, + "operationBitmap": "0x0000000000000401" + }, + "operations": [ + { + "id": "PushHostObject", + "managed": { "constant": "PushHostObjectOperation", "wrapper": "PushHostObject" }, + "opcode": 10, + "protected": true, + "requiresNativeProtection": true + }, + { + "id": "PushBytes", + "managed": { "constant": "PushBytesOperation", "wrapper": "PushBytes" }, + "opcode": 0, + "protected": true, + "requiresNativeProtection": true + } + ] + } + """; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/GeneratorRun.cs index 5bdb0b77..416a0c70 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/GeneratorRun.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructure; @@ -6,45 +7,60 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructur /// One deterministic run of the bridge-contract generator. internal sealed class GeneratorRun { - private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics, - GeneratorRunResult result) - { - Driver = driver; - OutputCompilation = outputCompilation; - GeneratorDiagnostics = diagnostics; - Result = result; - } - - public GeneratorDriver Driver { get; } - - public Compilation OutputCompilation { get; } - - public ImmutableArray GeneratorDiagnostics { get; } - - public GeneratorRunResult Result { get; } - - public ImmutableArray GeneratedSources => Result.GeneratedSources; - - public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); - - public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) - { - var updated = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var diagnostics, - TestContext.Current.CancellationToken); - var driverResult = updated.GetRunResult(); - var generatorResult = Assert.Single(driverResult.Results); - return new GeneratorRun(updated, outputCompilation, diagnostics, generatorResult); - } - - public void AssertCompilesClean() - { - Assert.Null(Result.Exception); - Assert.Empty(GeneratorDiagnostics); - var diagnostics = OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken); - for (var i = 0; i < diagnostics.Length; i++) - Assert.True(diagnostics[i].Severity < DiagnosticSeverity.Warning, diagnostics[i].ToString()); - } + private GeneratorRun(GeneratorDriver driver, Compilation outputCompilation, ImmutableArray diagnostics, + GeneratorRunResult result) + { + Driver = driver; + OutputCompilation = outputCompilation; + GeneratorDiagnostics = diagnostics; + Result = result; + } + + public GeneratorDriver Driver + { + get; + } + + public Compilation OutputCompilation + { + get; + } + + public ImmutableArray GeneratorDiagnostics + { + get; + } + + public GeneratorRunResult Result + { + get; + } + + public ImmutableArray GeneratedSources => Result.GeneratedSources; + + public string SingleGeneratedText => Assert.Single(GeneratedSources).SourceText.ToString(); + + public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) + { + GeneratorDriver updated = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + GeneratorDriverRunResult driverResult = updated.GetRunResult(); + GeneratorRunResult generatorResult = Assert.Single(driverResult.Results); + return new GeneratorRun(updated, outputCompilation, diagnostics, generatorResult); + } + + public void AssertCompilesClean() + { + Assert.Null(Result.Exception); + Assert.Empty(GeneratorDiagnostics); + ImmutableArray + diagnostics = OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken); + for (int i = 0; i < diagnostics.Length; i++) + { + Assert.True(diagnostics[i].Severity < DiagnosticSeverity.Warning, diagnostics[i].ToString()); + } + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/InMemoryAdditionalText.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/InMemoryAdditionalText.cs index b66ad74b..7d3532b8 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/InMemoryAdditionalText.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/InMemoryAdditionalText.cs @@ -1,4 +1,5 @@ using System.Text; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -7,14 +8,17 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructur /// One compiler-provided additional text held entirely in test memory. internal sealed class InMemoryAdditionalText(string path, string text) : AdditionalText { - private readonly SourceText _text = SourceText.From(text, Encoding.UTF8); + private readonly SourceText _text = SourceText.From(text, Encoding.UTF8); - /// - public override string Path { get; } = path; + /// + public override string Path + { + get; + } = path; - /// - public override SourceText GetText(CancellationToken cancellationToken = default) - { - return _text; - } + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionCatalog.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionCatalog.cs index 0d91a128..9c6d8c32 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionCatalog.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionCatalog.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text; namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructure; @@ -5,14 +6,14 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructur /// The exact repository catalogue embedded in the test assembly as deterministic test data. internal static class ProductionCatalog { - private const string ResourceName = "CheatEngine.SDK.LuaBridgeContract.Tests.ProductionCatalog.json"; + private const string ResourceName = "CheatEngine.SDK.LuaBridgeContract.Tests.ProductionCatalog.json"; - public static string Read() - { - var assembly = typeof(ProductionCatalog).Assembly; - using var stream = assembly.GetManifestResourceStream(ResourceName); - Assert.NotNull(stream); - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - return reader.ReadToEnd(); - } + public static string Read() + { + Assembly assembly = typeof(ProductionCatalog).Assembly; + using Stream? stream = assembly.GetManifestResourceStream(ResourceName); + Assert.NotNull(stream); + using StreamReader reader = new(stream, Encoding.UTF8, true); + return reader.ReadToEnd(); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/RoslynFixture.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/RoslynFixture.cs index 0cb52632..12ff2d1c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/RoslynFixture.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/RoslynFixture.cs @@ -6,42 +6,45 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructur /// Direct in-memory Roslyn harness: the generator receives only compiler-provided additional text. internal static class RoslynFixture { - private static readonly MetadataReference[] References = - [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]; - - private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); - - private static readonly CSharpCompilationOptions CompilationOptions = new( - OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable, - warningLevel: 9999); - - public static GeneratorRun Run(string path, string text) - { - var additionalText = new InMemoryAdditionalText(path, text); - return GeneratorRun.Execute(CreateDriver(additionalText), CreateCompilation()); - } - - public static GeneratorRun Run(params (string Path, string Text)[] catalogs) - { - var additionalTexts = new AdditionalText[catalogs.Length]; - for (var i = 0; i < catalogs.Length; i++) - additionalTexts[i] = new InMemoryAdditionalText(catalogs[i].Path, catalogs[i].Text); - return GeneratorRun.Execute(CreateDriver(additionalTexts), CreateCompilation()); - } - - public static GeneratorDriver CreateDriver(params AdditionalText[] additionalTexts) - { - return CSharpGeneratorDriver.Create( - [new LuaBridgeContractGenerator().AsSourceGenerator()], - additionalTexts: additionalTexts, - parseOptions: ParseOptions, - optionsProvider: null, - driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); - } - - public static CSharpCompilation CreateCompilation() - { - return CSharpCompilation.Create("LuaBridgeContractGeneratorTest", [], References, CompilationOptions); - } + private static readonly MetadataReference[] References = + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]; + + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); + + private static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + warningLevel: 9999); + + public static GeneratorRun Run(string path, string text) + { + InMemoryAdditionalText additionalText = new(path, text); + return GeneratorRun.Execute(CreateDriver(additionalText), CreateCompilation()); + } + + public static GeneratorRun Run(params (string Path, string Text)[] catalogs) + { + AdditionalText[] additionalTexts = new AdditionalText[catalogs.Length]; + for (int i = 0; i < catalogs.Length; i++) + { + additionalTexts[i] = new InMemoryAdditionalText(catalogs[i].Path, catalogs[i].Text); + } + + return GeneratorRun.Execute(CreateDriver(additionalTexts), CreateCompilation()); + } + + public static GeneratorDriver CreateDriver(params AdditionalText[] additionalTexts) + { + return CSharpGeneratorDriver.Create( + [new LuaBridgeContractGenerator().AsSourceGenerator()], + additionalTexts, + ParseOptions, + null, + new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, true)); + } + + public static CSharpCompilation CreateCompilation() + { + return CSharpCompilation.Create("LuaBridgeContractGeneratorTest", [], References, CompilationOptions); + } } diff --git a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaLibrary.cs b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaLibrary.cs index 667b06a6..ff77a38e 100644 --- a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaLibrary.cs +++ b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaLibrary.cs @@ -16,38 +16,41 @@ namespace CheatEngine.SDK.Tests.Shared.NativeLua; /// internal static class NativeLuaLibrary { - /// Environment variable that points at a Lua 5.3 DLL of the test process architecture. - public const string PathVariable = "CHEATENGINE_SDK_LUA53_PATH"; - - // A static readonly initializer runs once, under the runtime's type-initialization lock. - private static readonly NativeLuaProbe SProbe = - NativeLuaProbe.Run(Environment.GetEnvironmentVariable(PathVariable)); - - /// - /// Where the build copies Cheat Engine's 64-bit Lua 5.3 DLL, beside the test executable. Computed on each read, so - /// the static field initializer above never depends on the order in which the members are declared. - /// - public static string BundledPath => Path.Combine(AppContext.BaseDirectory, "native", "lua53-64.dll"); - - /// Whether a Lua 5.3 library is loaded and is bound to it. - public static bool IsAvailable => SProbe.Handle != 0; - - /// Handle of the loaded module, or zero when unavailable. - public static nint Handle => SProbe.Handle; - - /// Full path of the loaded DLL, or null when unavailable. - public static string? LibraryPath => SProbe.LibraryPath; - - /// Why the library is unavailable, written as a test skip reason; empty when it is available. - public static string UnavailableReason => SProbe.Reason; - - /// For callers that cannot skip (benchmarks, fixtures of other helpers). - /// - /// The library is unavailable; the message is - /// . - /// - public static void ThrowIfUnavailable() - { - if (!IsAvailable) throw new InvalidOperationException(UnavailableReason); - } + /// Environment variable that points at a Lua 5.3 DLL of the test process architecture. + public const string PathVariable = "CHEATENGINE_SDK_LUA53_PATH"; + + // A static readonly initializer runs once, under the runtime's type-initialization lock. + private static readonly NativeLuaProbe SProbe = + NativeLuaProbe.Run(Environment.GetEnvironmentVariable(PathVariable)); + + /// + /// Where the build copies Cheat Engine's 64-bit Lua 5.3 DLL, beside the test executable. Computed on each read, so + /// the static field initializer above never depends on the order in which the members are declared. + /// + public static string BundledPath => Path.Combine(AppContext.BaseDirectory, "native", "lua53-64.dll"); + + /// Whether a Lua 5.3 library is loaded and is bound to it. + public static bool IsAvailable => SProbe.Handle != 0; + + /// Handle of the loaded module, or zero when unavailable. + public static nint Handle => SProbe.Handle; + + /// Full path of the loaded DLL, or null when unavailable. + public static string? LibraryPath => SProbe.LibraryPath; + + /// Why the library is unavailable, written as a test skip reason; empty when it is available. + public static string UnavailableReason => SProbe.Reason; + + /// For callers that cannot skip (benchmarks, fixtures of other helpers). + /// + /// The library is unavailable; the message is + /// . + /// + public static void ThrowIfUnavailable() + { + if (!IsAvailable) + { + throw new InvalidOperationException(UnavailableReason); + } + } } diff --git a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs index 02e389f8..eb90ba44 100644 --- a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs +++ b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using System.Security; + using CheatEngine.SDK.Lua.Interop.Api; namespace CheatEngine.SDK.Tests.Shared.NativeLua; @@ -13,58 +14,66 @@ namespace CheatEngine.SDK.Tests.Shared.NativeLua; /// Why the library is unavailable, written as a test skip reason; empty when it is available. internal sealed record NativeLuaProbe(nint Handle, string? LibraryPath, string Reason) { - /// - /// Locates the DLL ( when it is not blank, else - /// ), loads it and binds . Never throws: every - /// failure becomes . - /// - /// Value of the environment variable, or null. - public static NativeLuaProbe Run(string? configured) - { - var fromEnvironment = !string.IsNullOrWhiteSpace(configured); - var candidate = fromEnvironment ? configured!.Trim() : NativeLuaLibrary.BundledPath; - var origin = fromEnvironment - ? "the " + NativeLuaLibrary.PathVariable + " environment variable" - : "the Cheat Engine Lua copied next to the tests"; - var architecture = RuntimeInformation.ProcessArchitecture.ToString(); + /// + /// Locates the DLL ( when it is not blank, else + /// ), loads it and binds . Never throws: every + /// failure becomes . + /// + /// Value of the environment variable, or null. + public static NativeLuaProbe Run(string? configured) + { + bool fromEnvironment = !string.IsNullOrWhiteSpace(configured); + string candidate = fromEnvironment ? configured!.Trim() : NativeLuaLibrary.BundledPath; + string origin = fromEnvironment + ? "the " + NativeLuaLibrary.PathVariable + " environment variable" + : "the Cheat Engine Lua copied next to the tests"; + string architecture = RuntimeInformation.ProcessArchitecture.ToString(); - // One absolute path for the existence check, the load and the report. A relative value would be resolved - // against the current directory by File.Exists but by the loader's own search rules by TryLoad, so the file - // that was checked and the file that gets bound could differ. - if (!TryGetFullPath(candidate, out var path)) - return Unavailable($"No Lua 5.3 library: '{candidate}' (from {origin}) is not a valid path."); + // One absolute path for the existence check, the load and the report. A relative value would be resolved + // against the current directory by File.Exists but by the loader's own search rules by TryLoad, so the file + // that was checked and the file that gets bound could differ. + if (!TryGetFullPath(candidate, out string path)) + { + return Unavailable($"No Lua 5.3 library: '{candidate}' (from {origin}) is not a valid path."); + } - if (!File.Exists(path)) - return Unavailable( - $"No Lua 5.3 library: '{path}' (from {origin}) does not exist. Rebuild the test project, or set {NativeLuaLibrary.PathVariable} to a Lua 5.3 DLL built for {architecture}."); + if (!File.Exists(path)) + { + return Unavailable( + $"No Lua 5.3 library: '{path}' (from {origin}) does not exist. Rebuild the test project, or set {NativeLuaLibrary.PathVariable} to a Lua 5.3 DLL built for {architecture}."); + } - if (!NativeLibrary.TryLoad(path, out var handle)) - return Unavailable( - $"'{path}' (from {origin}) exists but could not be loaded into this {architecture} process: wrong architecture or a missing dependency."); + if (!NativeLibrary.TryLoad(path, out IntPtr handle)) + { + return Unavailable( + $"'{path}' (from {origin}) exists but could not be loaded into this {architecture} process: wrong architecture or a missing dependency."); + } - if (!LuaApi.TryInitialize(handle, out var failure)) - return Unavailable($"'{path}' (from {origin}) was loaded but cannot be bound: {failure}"); + if (!LuaApi.TryInitialize(handle, out string? failure)) + { + return Unavailable($"'{path}' (from {origin}) was loaded but cannot be bound: {failure}"); + } - return new NativeLuaProbe(handle, path, string.Empty); - } + return new NativeLuaProbe(handle, path, string.Empty); + } - private static bool TryGetFullPath(string candidate, out string fullPath) - { - try - { - fullPath = Path.GetFullPath(candidate); - return true; - } - catch (Exception exception) when (exception is ArgumentException or NotSupportedException - or PathTooLongException or SecurityException) - { - fullPath = string.Empty; - return false; - } - } + private static bool TryGetFullPath(string candidate, out string fullPath) + { + try + { + fullPath = Path.GetFullPath(candidate); + return true; + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or PathTooLongException or SecurityException) + { + fullPath = string.Empty; + return false; + } + } - private static NativeLuaProbe Unavailable(string reason) - { - return new NativeLuaProbe(0, null, reason); - } + private static NativeLuaProbe Unavailable(string reason) + { + return new NativeLuaProbe(0, null, reason); + } } diff --git a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaState.cs b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaState.cs index 10854141..5c3aa100 100644 --- a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaState.cs +++ b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaState.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Types; @@ -11,49 +12,58 @@ namespace CheatEngine.SDK.Tests.Shared.NativeLua; /// must not be closed from the finalizer thread); a state that is never disposed leaks until the process ends. /// [SuppressMessage("Meziantou.Analyzer", "MA0182", - Justification = "The fixture is used by friend test and benchmark assemblies through InternalsVisibleTo.")] + Justification = "The fixture is used by friend test and benchmark assemblies through InternalsVisibleTo.")] internal sealed unsafe class NativeLuaState : IDisposable { - private lua_State* _state; - - /// Creates a state with the library's default allocator. - /// True to open every standard library (luaL_openlibs); false for a bare state. - /// - /// No Lua library is available (check - /// first), or the state could not be allocated. - /// - public NativeLuaState(bool openLibraries = true) - { - NativeLuaLibrary.ThrowIfUnavailable(); - - _state = LuaApi.luaL_newstate(); - if (_state is null) - throw new InvalidOperationException( - "luaL_newstate returned null: the Lua library could not allocate a state."); - - if (openLibraries) LuaApi.luaL_openlibs(_state); - } - - /// The raw state. Borrowed: never close it yourself. - /// The state is closed. - public lua_State* L - { - get - { - ObjectDisposedException.ThrowIf(_state is null, this); - return _state; - } - } - - /// The raw state as an integer, for code that does not want a pointer type in its signatures. - /// The state is closed. - public nint Pointer => (nint)L; - - /// Closes the state; every pointer obtained from it dangles afterwards. Idempotent. - public void Dispose() - { - if (_state is null) return; - LuaApi.lua_close(_state); - _state = null; - } + private lua_State* _state; + + /// Creates a state with the library's default allocator. + /// True to open every standard library (luaL_openlibs); false for a bare state. + /// + /// No Lua library is available (check + /// first), or the state could not be allocated. + /// + public NativeLuaState(bool openLibraries = true) + { + NativeLuaLibrary.ThrowIfUnavailable(); + + _state = LuaApi.luaL_newstate(); + if (_state is null) + { + throw new InvalidOperationException( + "luaL_newstate returned null: the Lua library could not allocate a state."); + } + + if (openLibraries) + { + LuaApi.luaL_openlibs(_state); + } + } + + /// The raw state. Borrowed: never close it yourself. + /// The state is closed. + public lua_State* L + { + get + { + ObjectDisposedException.ThrowIf(_state is null, this); + return _state; + } + } + + /// The raw state as an integer, for code that does not want a pointer type in its signatures. + /// The state is closed. + public nint Pointer => (nint) L; + + /// Closes the state; every pointer obtained from it dangles afterwards. Idempotent. + public void Dispose() + { + if (_state is null) + { + return; + } + + LuaApi.lua_close(_state); + _state = null; + } } diff --git a/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs b/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs index 0d1aca36..d6dac8d3 100644 --- a/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs +++ b/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs @@ -1,5 +1,6 @@ using System.Text; using System.Xml; + using CheatEngine.SDK.Tests.Infrastructure; namespace CheatEngine.SDK.Tests.Architecture; @@ -12,334 +13,397 @@ namespace CheatEngine.SDK.Tests.Architecture; /// public sealed class ProjectDependencyDirectionTests { - private const string UmbrellaProject = "src/CheatEngine.SDK/CheatEngine.SDK.csproj"; - - private static readonly IReadOnlyDictionary ExpectedLibraryRuntimeDependencies = - new Dictionary(StringComparer.Ordinal) - { - ["libs/CheatEngine.SDK.Abi/CheatEngine.SDK.Abi.csproj"] = [], - ["libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj"] = [], - ["libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj"] = - [ - "libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj", - "libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj", - "libs/CheatEngine.SDK.Lua/CheatEngine.SDK.Lua.csproj", - ], - ["libs/CheatEngine.SDK.Hosting/CheatEngine.SDK.Hosting.csproj"] = - [ - "libs/CheatEngine.SDK.Abi/CheatEngine.SDK.Abi.csproj", - "libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj", - "libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj", - "libs/CheatEngine.SDK.Lua/CheatEngine.SDK.Lua.csproj", - ], - ["libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj"] = [], - ["libs/CheatEngine.SDK.Lua/CheatEngine.SDK.Lua.csproj"] = - [ - "libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj", - "libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj", - ], - }; - - [Fact] - public void Every_SDK_project_has_a_sibling_README() - { - List missingReadmes = []; - - foreach (var projectPath in EnumerateRepositoryFiles("*.csproj")) - { - var projectDirectory = Path.GetDirectoryName(projectPath); - if (projectDirectory is null) - { - missingReadmes.Add(GetRepositoryRelativePath(projectPath)); - continue; - } - - if (!File.Exists(Path.Combine(projectDirectory, "README.md"))) - missingReadmes.Add(GetRepositoryRelativePath(projectPath)); - } - - AssertNoViolations("Every SDK project must have a sibling README.md.", missingReadmes); - } - - [Fact] - public void Shipping_libraries_keep_the_declared_lower_layer_runtime_graph() - { - List violations = []; - HashSet discoveredLibraryProjects = new(StringComparer.Ordinal); - - foreach (var projectPath in EnumerateRepositoryFiles("*.csproj")) - { - var projectRelativePath = GetRepositoryRelativePath(projectPath); - if (!projectRelativePath.StartsWith("libs/", StringComparison.Ordinal)) - continue; - - discoveredLibraryProjects.Add(projectRelativePath); - if (!ExpectedLibraryRuntimeDependencies.TryGetValue(projectRelativePath, out var expectedDependencies)) - { - violations.Add($"{projectRelativePath}: is not declared in the shipping library graph."); - continue; - } - - SortedSet actualDependencies = new(StringComparer.Ordinal); - foreach (var reference in ReadProjectReferences(projectPath)) - { - if (IsRoslynComponent(reference.TargetRelativePath)) - continue; - - actualDependencies.Add(reference.TargetRelativePath); - } - - AddSetDifference(violations, projectRelativePath, "unexpected runtime dependency", actualDependencies, - expectedDependencies); - AddSetDifference(violations, projectRelativePath, "missing runtime dependency", expectedDependencies, - actualDependencies); - } - - foreach (var expectedProject in ExpectedLibraryRuntimeDependencies.Keys) - if (!discoveredLibraryProjects.Contains(expectedProject)) - violations.Add($"{expectedProject}: expected shipping library project was not found."); - - AssertNoViolations("Shipping libraries must reference only their declared lower-layer runtime dependencies.", - violations); - } - - [Fact] - public void Umbrella_runtime_references_are_limited_to_shipping_libraries() - { - var umbrellaPath = RepositoryLayout.PathOf(UmbrellaProject); - List violations = []; - - foreach (var reference in ReadProjectReferences(umbrellaPath)) - { - if (IsRoslynComponent(reference.TargetRelativePath)) - continue; - - if (!reference.TargetRelativePath.StartsWith("libs/", StringComparison.Ordinal)) - violations.Add( - $"{UmbrellaProject}: runtime ProjectReference '{reference.Include}' targets '{reference.TargetRelativePath}', not libs/."); - } - - AssertNoViolations("The umbrella package may compose lower shipping libraries but not a higher layer.", - violations); - } - - [Fact] - public void Shipping_projects_consume_Roslyn_components_as_compile_time_only_inputs() - { - List violations = []; - - foreach (var projectPath in EnumerateRepositoryFiles("*.csproj")) - { - var projectRelativePath = GetRepositoryRelativePath(projectPath); - if (!IsShippingProject(projectRelativePath)) - continue; - - foreach (var reference in ReadProjectReferences(projectPath)) - { - if (!IsRoslynComponent(reference.TargetRelativePath)) - continue; - - if (!string.Equals(reference.OutputItemType, "Analyzer", StringComparison.Ordinal)) - violations.Add( - $"{projectRelativePath}: Roslyn component '{reference.TargetRelativePath}' must set OutputItemType=\"Analyzer\"."); - - if (!string.Equals(reference.ReferenceOutputAssembly, "false", StringComparison.OrdinalIgnoreCase)) - violations.Add( - $"{projectRelativePath}: Roslyn component '{reference.TargetRelativePath}' must set ReferenceOutputAssembly=\"false\"."); - } - } - - AssertNoViolations( - "Shipping projects must consume analyzers and generators without runtime assembly references.", - violations); - } - - [Fact] - public void Hosting_applies_the_SDK_analyzer_without_a_runtime_reference() - { - const string hostingProject = "libs/CheatEngine.SDK.Hosting/CheatEngine.SDK.Hosting.csproj"; - const string analyzerProject = "analyzers/CheatEngine.SDK.Analyzers/CheatEngine.SDK.Analyzers.csproj"; - List violations = []; - var foundAnalyzer = false; - - foreach (var reference in ReadProjectReferences(RepositoryLayout.PathOf(hostingProject))) - { - if (!string.Equals(reference.TargetRelativePath, analyzerProject, StringComparison.Ordinal)) - continue; - - foundAnalyzer = true; - if (!string.Equals(reference.OutputItemType, "Analyzer", StringComparison.Ordinal)) - violations.Add($"{hostingProject}: SDK analyzer must set OutputItemType=\"Analyzer\"."); - - if (!string.Equals(reference.ReferenceOutputAssembly, "false", StringComparison.OrdinalIgnoreCase)) - violations.Add($"{hostingProject}: SDK analyzer must set ReferenceOutputAssembly=\"false\"."); - } - - if (!foundAnalyzer) - violations.Add($"{hostingProject}: expected compile-time analyzer '{analyzerProject}' was not found."); - - AssertNoViolations("Hosting must compile with the SDK analyzer and never reference it at runtime.", violations); - } - - [Fact] - public void SDK_build_metadata_has_no_Client_or_Mcp_dependency() - { - List violations = []; - - foreach (var metadataPath in EnumerateBuildMetadataFiles()) - { - var document = LoadProjectDocument(metadataPath); - var nodes = document.SelectNodes("//*[@Include or @Update or @Remove]"); - if (nodes is null) - continue; - - foreach (XmlNode node in nodes) - { - AddForbiddenDependencyViolations(violations, metadataPath, node, "Include"); - AddForbiddenDependencyViolations(violations, metadataPath, node, "Update"); - AddForbiddenDependencyViolations(violations, metadataPath, node, "Remove"); - } - } - - foreach (var projectPath in EnumerateRepositoryFiles("*.csproj")) - foreach (var reference in ReadProjectReferences(projectPath)) - { - if (reference.Include.Contains("$(", StringComparison.Ordinal)) - violations.Add( - $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' is dynamic and cannot be checked for a higher-layer dependency."); - - if (reference.TargetRelativePath.StartsWith("../", StringComparison.Ordinal)) - violations.Add( - $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' escapes the SDK repository."); - } - - AssertNoViolations("The SDK build graph must not take a Client or MCP dependency.", violations); - } - - private static void AddForbiddenDependencyViolations(List violations, string metadataPath, XmlNode node, - string attributeName) - { - var value = GetAttribute(node, attributeName); - if (string.IsNullOrWhiteSpace(value)) - return; - - if (value.Contains("CheatEngine.Client", StringComparison.OrdinalIgnoreCase) || - value.Contains("CheatEngine.Mcp", StringComparison.OrdinalIgnoreCase)) - violations.Add( - $"{GetRepositoryRelativePath(metadataPath)}: {node.LocalName} {attributeName}='{value}' references a higher layer."); - } - - private static void AddSetDifference(List violations, string projectRelativePath, string violationName, - IEnumerable source, IEnumerable valuesToRemove) - { - HashSet valuesToRemoveSet = new(valuesToRemove, StringComparer.Ordinal); - foreach (var value in source) - if (!valuesToRemoveSet.Contains(value)) - violations.Add($"{projectRelativePath}: {violationName} '{value}'."); - } - - private static void AssertNoViolations(string expectation, List violations) - { - if (violations.Count == 0) - return; - - StringBuilder message = new(expectation); - message.AppendLine(); - for (var index = 0; index < violations.Count; index++) - message.Append(" - ").AppendLine(violations[index]); - - Assert.Fail(message.ToString()); - } - - private static IEnumerable EnumerateBuildMetadataFiles() - { - foreach (var projectPath in EnumerateRepositoryFiles("*.csproj")) - yield return projectPath; - - foreach (var propsPath in EnumerateRepositoryFiles("*.props")) - yield return propsPath; - - foreach (var targetsPath in EnumerateRepositoryFiles("*.targets")) - yield return targetsPath; - - foreach (var solutionPath in EnumerateRepositoryFiles("*.slnx")) - yield return solutionPath; - } - - private static IEnumerable EnumerateRepositoryFiles(string searchPattern) - { - foreach (var path in - Directory.EnumerateFiles(RepositoryLayout.Root, searchPattern, SearchOption.AllDirectories)) - { - var relativePath = GetRepositoryRelativePath(path); - if (!IsGeneratedPath(relativePath)) - yield return path; - } - } - - private static string GetAttribute(XmlNode node, string attributeName) - { - var attribute = node.Attributes?[attributeName]; - return attribute?.Value ?? string.Empty; - } - - private static string GetRepositoryRelativePath(string fullPath) - { - return Path.GetRelativePath(RepositoryLayout.Root, fullPath).Replace('\\', '/'); - } - - private static bool IsGeneratedPath(string relativePath) - { - return relativePath.StartsWith("artifacts/", StringComparison.Ordinal) || - relativePath.Contains("/bin/", StringComparison.Ordinal) || - relativePath.Contains("/obj/", StringComparison.Ordinal); - } - - private static bool IsRoslynComponent(string projectRelativePath) - { - return projectRelativePath.StartsWith("analyzers/", StringComparison.Ordinal) || - projectRelativePath.StartsWith("source-generators/", StringComparison.Ordinal); - } - - private static bool IsShippingProject(string projectRelativePath) - { - return projectRelativePath.StartsWith("libs/", StringComparison.Ordinal) || - string.Equals(projectRelativePath, UmbrellaProject, StringComparison.Ordinal); - } - - private static XmlDocument LoadProjectDocument(string projectPath) - { - XmlDocument document = new(); - document.Load(projectPath); - return document; - } - - private static IEnumerable ReadProjectReferences(string projectPath) - { - var document = LoadProjectDocument(projectPath); - var nodes = document.SelectNodes("//*[local-name()='ProjectReference']"); - if (nodes is null) - yield break; - - foreach (XmlNode node in nodes) - { - var include = GetAttribute(node, "Include"); - if (string.IsNullOrWhiteSpace(include)) - continue; - - var projectDirectory = Path.GetDirectoryName(projectPath)!; - var targetPath = Path.GetFullPath(Path.Combine(projectDirectory, include)); - yield return new ProjectReferenceInfo( - include, - GetRepositoryRelativePath(targetPath), - GetAttribute(node, "OutputItemType"), - GetAttribute(node, "ReferenceOutputAssembly")); - } - } - - private readonly record struct ProjectReferenceInfo( - string Include, - string TargetRelativePath, - string OutputItemType, - string ReferenceOutputAssembly); + private const string UmbrellaProject = "src/CheatEngine.SDK/CheatEngine.SDK.csproj"; + + private static readonly IReadOnlyDictionary ExpectedLibraryRuntimeDependencies = + new Dictionary(StringComparer.Ordinal) + { + ["libs/CheatEngine.SDK.Abi/CheatEngine.SDK.Abi.csproj"] = [], + ["libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj"] = [], + ["libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj"] = + [ + "libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj", + "libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj", + "libs/CheatEngine.SDK.Lua/CheatEngine.SDK.Lua.csproj" + ], + ["libs/CheatEngine.SDK.Hosting/CheatEngine.SDK.Hosting.csproj"] = + [ + "libs/CheatEngine.SDK.Abi/CheatEngine.SDK.Abi.csproj", + "libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj", + "libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj", + "libs/CheatEngine.SDK.Lua/CheatEngine.SDK.Lua.csproj" + ], + ["libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj"] = [], + ["libs/CheatEngine.SDK.Lua/CheatEngine.SDK.Lua.csproj"] = + [ + "libs/CheatEngine.SDK.Annotations/CheatEngine.SDK.Annotations.csproj", + "libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj" + ] + }; + + [Fact] + public void Every_SDK_project_has_a_sibling_README() + { + List missingReadmes = []; + + foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) + { + string? projectDirectory = Path.GetDirectoryName(projectPath); + if (projectDirectory is null) + { + missingReadmes.Add(GetRepositoryRelativePath(projectPath)); + continue; + } + + if (!File.Exists(Path.Combine(projectDirectory, "README.md"))) + { + missingReadmes.Add(GetRepositoryRelativePath(projectPath)); + } + } + + AssertNoViolations("Every SDK project must have a sibling README.md.", missingReadmes); + } + + [Fact] + public void Shipping_libraries_keep_the_declared_lower_layer_runtime_graph() + { + List violations = []; + HashSet discoveredLibraryProjects = new(StringComparer.Ordinal); + + foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) + { + string projectRelativePath = GetRepositoryRelativePath(projectPath); + if (!projectRelativePath.StartsWith("libs/", StringComparison.Ordinal)) + { + continue; + } + + discoveredLibraryProjects.Add(projectRelativePath); + if (!ExpectedLibraryRuntimeDependencies.TryGetValue(projectRelativePath, + out string[]? expectedDependencies)) + { + violations.Add($"{projectRelativePath}: is not declared in the shipping library graph."); + continue; + } + + SortedSet actualDependencies = new(StringComparer.Ordinal); + foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) + { + if (IsRoslynComponent(reference.TargetRelativePath)) + { + continue; + } + + actualDependencies.Add(reference.TargetRelativePath); + } + + AddSetDifference(violations, projectRelativePath, "unexpected runtime dependency", actualDependencies, + expectedDependencies); + AddSetDifference(violations, projectRelativePath, "missing runtime dependency", expectedDependencies, + actualDependencies); + } + + foreach (string expectedProject in ExpectedLibraryRuntimeDependencies.Keys) + { + if (!discoveredLibraryProjects.Contains(expectedProject)) + { + violations.Add($"{expectedProject}: expected shipping library project was not found."); + } + } + + AssertNoViolations("Shipping libraries must reference only their declared lower-layer runtime dependencies.", + violations); + } + + [Fact] + public void Umbrella_runtime_references_are_limited_to_shipping_libraries() + { + string umbrellaPath = RepositoryLayout.PathOf(UmbrellaProject); + List violations = []; + + foreach (ProjectReferenceInfo reference in ReadProjectReferences(umbrellaPath)) + { + if (IsRoslynComponent(reference.TargetRelativePath)) + { + continue; + } + + if (!reference.TargetRelativePath.StartsWith("libs/", StringComparison.Ordinal)) + { + violations.Add( + $"{UmbrellaProject}: runtime ProjectReference '{reference.Include}' targets '{reference.TargetRelativePath}', not libs/."); + } + } + + AssertNoViolations("The umbrella package may compose lower shipping libraries but not a higher layer.", + violations); + } + + [Fact] + public void Shipping_projects_consume_Roslyn_components_as_compile_time_only_inputs() + { + List violations = []; + + foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) + { + string projectRelativePath = GetRepositoryRelativePath(projectPath); + if (!IsShippingProject(projectRelativePath)) + { + continue; + } + + foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) + { + if (!IsRoslynComponent(reference.TargetRelativePath)) + { + continue; + } + + if (!string.Equals(reference.OutputItemType, "Analyzer", StringComparison.Ordinal)) + { + violations.Add( + $"{projectRelativePath}: Roslyn component '{reference.TargetRelativePath}' must set OutputItemType=\"Analyzer\"."); + } + + if (!string.Equals(reference.ReferenceOutputAssembly, "false", StringComparison.OrdinalIgnoreCase)) + { + violations.Add( + $"{projectRelativePath}: Roslyn component '{reference.TargetRelativePath}' must set ReferenceOutputAssembly=\"false\"."); + } + } + } + + AssertNoViolations( + "Shipping projects must consume analyzers and generators without runtime assembly references.", + violations); + } + + [Fact] + public void Hosting_applies_the_SDK_analyzer_without_a_runtime_reference() + { + const string hostingProject = "libs/CheatEngine.SDK.Hosting/CheatEngine.SDK.Hosting.csproj"; + const string analyzerProject = "analyzers/CheatEngine.SDK.Analyzers/CheatEngine.SDK.Analyzers.csproj"; + List violations = []; + bool foundAnalyzer = false; + + foreach (ProjectReferenceInfo reference in ReadProjectReferences(RepositoryLayout.PathOf(hostingProject))) + { + if (!string.Equals(reference.TargetRelativePath, analyzerProject, StringComparison.Ordinal)) + { + continue; + } + + foundAnalyzer = true; + if (!string.Equals(reference.OutputItemType, "Analyzer", StringComparison.Ordinal)) + { + violations.Add($"{hostingProject}: SDK analyzer must set OutputItemType=\"Analyzer\"."); + } + + if (!string.Equals(reference.ReferenceOutputAssembly, "false", StringComparison.OrdinalIgnoreCase)) + { + violations.Add($"{hostingProject}: SDK analyzer must set ReferenceOutputAssembly=\"false\"."); + } + } + + if (!foundAnalyzer) + { + violations.Add($"{hostingProject}: expected compile-time analyzer '{analyzerProject}' was not found."); + } + + AssertNoViolations("Hosting must compile with the SDK analyzer and never reference it at runtime.", violations); + } + + [Fact] + public void SDK_build_metadata_has_no_Client_or_Mcp_dependency() + { + List violations = []; + + foreach (string metadataPath in EnumerateBuildMetadataFiles()) + { + XmlDocument document = LoadProjectDocument(metadataPath); + XmlNodeList? nodes = document.SelectNodes("//*[@Include or @Update or @Remove]"); + if (nodes is null) + { + continue; + } + + foreach (XmlNode node in nodes) + { + AddForbiddenDependencyViolations(violations, metadataPath, node, "Include"); + AddForbiddenDependencyViolations(violations, metadataPath, node, "Update"); + AddForbiddenDependencyViolations(violations, metadataPath, node, "Remove"); + } + } + + foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) + foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) + { + if (reference.Include.Contains("$(", StringComparison.Ordinal)) + { + violations.Add( + $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' is dynamic and cannot be checked for a higher-layer dependency."); + } + + if (reference.TargetRelativePath.StartsWith("../", StringComparison.Ordinal)) + { + violations.Add( + $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' escapes the SDK repository."); + } + } + + AssertNoViolations("The SDK build graph must not take a Client or MCP dependency.", violations); + } + + private static void AddForbiddenDependencyViolations(List violations, string metadataPath, XmlNode node, + string attributeName) + { + string value = GetAttribute(node, attributeName); + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (value.Contains("CheatEngine.Client", StringComparison.OrdinalIgnoreCase) || + value.Contains("CheatEngine.Mcp", StringComparison.OrdinalIgnoreCase)) + { + violations.Add( + $"{GetRepositoryRelativePath(metadataPath)}: {node.LocalName} {attributeName}='{value}' references a higher layer."); + } + } + + private static void AddSetDifference(List violations, string projectRelativePath, string violationName, + IEnumerable source, IEnumerable valuesToRemove) + { + HashSet valuesToRemoveSet = new(valuesToRemove, StringComparer.Ordinal); + foreach (string value in source) + { + if (!valuesToRemoveSet.Contains(value)) + { + violations.Add($"{projectRelativePath}: {violationName} '{value}'."); + } + } + } + + private static void AssertNoViolations(string expectation, List violations) + { + if (violations.Count == 0) + { + return; + } + + StringBuilder message = new(expectation); + message.AppendLine(); + for (int index = 0; index < violations.Count; index++) + { + message.Append(" - ").AppendLine(violations[index]); + } + + Assert.Fail(message.ToString()); + } + + private static IEnumerable EnumerateBuildMetadataFiles() + { + foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) + { + yield return projectPath; + } + + foreach (string propsPath in EnumerateRepositoryFiles("*.props")) + { + yield return propsPath; + } + + foreach (string targetsPath in EnumerateRepositoryFiles("*.targets")) + { + yield return targetsPath; + } + + foreach (string solutionPath in EnumerateRepositoryFiles("*.slnx")) + { + yield return solutionPath; + } + } + + private static IEnumerable EnumerateRepositoryFiles(string searchPattern) + { + foreach (string path in + Directory.EnumerateFiles(RepositoryLayout.Root, searchPattern, SearchOption.AllDirectories)) + { + string relativePath = GetRepositoryRelativePath(path); + if (!IsGeneratedPath(relativePath)) + { + yield return path; + } + } + } + + private static string GetAttribute(XmlNode node, string attributeName) + { + XmlAttribute? attribute = node.Attributes?[attributeName]; + return attribute?.Value ?? string.Empty; + } + + private static string GetRepositoryRelativePath(string fullPath) + { + return Path.GetRelativePath(RepositoryLayout.Root, fullPath).Replace('\\', '/'); + } + + private static bool IsGeneratedPath(string relativePath) + { + return relativePath.StartsWith("artifacts/", StringComparison.Ordinal) || + relativePath.Contains("/bin/", StringComparison.Ordinal) || + relativePath.Contains("/obj/", StringComparison.Ordinal); + } + + private static bool IsRoslynComponent(string projectRelativePath) + { + return projectRelativePath.StartsWith("analyzers/", StringComparison.Ordinal) || + projectRelativePath.StartsWith("source-generators/", StringComparison.Ordinal); + } + + private static bool IsShippingProject(string projectRelativePath) + { + return projectRelativePath.StartsWith("libs/", StringComparison.Ordinal) || + string.Equals(projectRelativePath, UmbrellaProject, StringComparison.Ordinal); + } + + private static XmlDocument LoadProjectDocument(string projectPath) + { + XmlDocument document = new(); + document.Load(projectPath); + return document; + } + + private static IEnumerable ReadProjectReferences(string projectPath) + { + XmlDocument document = LoadProjectDocument(projectPath); + XmlNodeList? nodes = document.SelectNodes("//*[local-name()='ProjectReference']"); + if (nodes is null) + { + yield break; + } + + foreach (XmlNode node in nodes) + { + string include = GetAttribute(node, "Include"); + if (string.IsNullOrWhiteSpace(include)) + { + continue; + } + + string projectDirectory = Path.GetDirectoryName(projectPath)!; + string targetPath = Path.GetFullPath(Path.Combine(projectDirectory, include)); + yield return new ProjectReferenceInfo( + include, + GetRepositoryRelativePath(targetPath), + GetAttribute(node, "OutputItemType"), + GetAttribute(node, "ReferenceOutputAssembly")); + } + } + + private readonly record struct ProjectReferenceInfo( + string Include, + string TargetRelativePath, + string OutputItemType, + string ReferenceOutputAssembly); } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs index 164a5928..ac57aa94 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs @@ -11,35 +11,40 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// internal static class EntryPointProbe { - // The type name Cheat Engine itself looks up in every plugin assembly. It is not this SDK's name (CheatEngine.SDK). - private const string TypeNamespace = "CESDK"; - private const string TypeName = "CESDK"; - private const string MethodName = "CEPluginInitialize"; + // The type name Cheat Engine itself looks up in every plugin assembly. It is not this SDK's name (CheatEngine.SDK). + private const string TypeNamespace = "CESDK"; + private const string TypeName = "CESDK"; + private const string MethodName = "CEPluginInitialize"; - /// Whether the CESDK.CESDK type exists, and whether it declares a two-parameter CEPluginInitialize. - public static (bool TypeExists, bool MethodExists) Probe(string assemblyPath) - { - using var stream = File.OpenRead(assemblyPath); - using PEReader peReader = new(stream); - var reader = peReader.GetMetadataReader(); + /// Whether the CESDK.CESDK type exists, and whether it declares a two-parameter CEPluginInitialize. + public static (bool TypeExists, bool MethodExists) Probe(string assemblyPath) + { + using FileStream stream = File.OpenRead(assemblyPath); + using PEReader peReader = new(stream); + MetadataReader reader = peReader.GetMetadataReader(); - foreach (var typeHandle in reader.TypeDefinitions) - { - var type = reader.GetTypeDefinition(typeHandle); - if (!string.Equals(reader.GetString(type.Namespace), TypeNamespace, StringComparison.Ordinal) - || !string.Equals(reader.GetString(type.Name), TypeName, StringComparison.Ordinal)) - continue; + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) + { + TypeDefinition type = reader.GetTypeDefinition(typeHandle); + if (!string.Equals(reader.GetString(type.Namespace), TypeNamespace, StringComparison.Ordinal) + || !string.Equals(reader.GetString(type.Name), TypeName, StringComparison.Ordinal)) + { + continue; + } - foreach (var methodHandle in type.GetMethods()) - { - var method = reader.GetMethodDefinition(methodHandle); - if (string.Equals(reader.GetString(method.Name), MethodName, StringComparison.Ordinal) && - method.GetParameters().Count == 2) return (TypeExists: true, MethodExists: true); - } + foreach (MethodDefinitionHandle methodHandle in type.GetMethods()) + { + MethodDefinition method = reader.GetMethodDefinition(methodHandle); + if (string.Equals(reader.GetString(method.Name), MethodName, StringComparison.Ordinal) && + method.GetParameters().Count == 2) + { + return (TypeExists: true, MethodExists: true); + } + } - return (TypeExists: true, MethodExists: false); - } + return (TypeExists: true, MethodExists: false); + } - return (TypeExists: false, MethodExists: false); - } + return (TypeExists: false, MethodExists: false); + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs index 61c20267..682c14a4 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs @@ -5,25 +5,25 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// Reads a packed .nupkg (a zip archive) without extracting it: entry names and the nuspec. internal static class NupkgInspector { - /// Every entry path inside the package, and its parsed .nuspec. - public static (IReadOnlyList Entries, XDocument Nuspec) Read(string nupkgPath) - { - using var archive = ZipFile.OpenRead(nupkgPath); - List entries = [.. archive.Entries.Select(static e => e.FullName)]; + /// Every entry path inside the package, and its parsed .nuspec. + public static (IReadOnlyList Entries, XDocument Nuspec) Read(string nupkgPath) + { + using ZipArchive archive = ZipFile.OpenRead(nupkgPath); + List entries = [.. archive.Entries.Select(static e => e.FullName)]; - var nuspecEntry = - archive.Entries.Single(static e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); - using var nuspecStream = nuspecEntry.Open(); - var nuspec = XDocument.Load(nuspecStream); + ZipArchiveEntry nuspecEntry = + archive.Entries.Single(static e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + using Stream nuspecStream = nuspecEntry.Open(); + XDocument nuspec = XDocument.Load(nuspecStream); - return (entries, nuspec); - } + return (entries, nuspec); + } - /// Every id attribute of every <dependency> element, across every target-framework group. - public static IReadOnlyList GetDependencyIds(XDocument nuspec) - { - var ns = nuspec.Root!.GetDefaultNamespace(); - return - [.. nuspec.Descendants(ns + "dependency").Select(static e => (string?)e.Attribute("id") ?? string.Empty)]; - } + /// Every id attribute of every <dependency> element, across every target-framework group. + public static IReadOnlyList GetDependencyIds(XDocument nuspec) + { + XNamespace ns = nuspec.Root!.GetDefaultNamespace(); + return + [.. nuspec.Descendants(ns + "dependency").Select(static e => (string?) e.Attribute("id") ?? string.Empty)]; + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs index 83cbe891..cb235401 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs @@ -30,502 +30,662 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// public sealed class PackagedUmbrellaFixture : IAsyncLifetime { - private static readonly TimeSpan PackTimeout = TimeSpan.FromMinutes(3); - private static readonly TimeSpan RestoreTimeout = TimeSpan.FromMinutes(3); - private static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(2); - private static readonly TimeSpan PublishTimeout = TimeSpan.FromMinutes(2); - private static readonly TimeSpan RuntimeRunTimeout = TimeSpan.FromMinutes(2); - private static readonly TimeSpan AotPublishTimeout = TimeSpan.FromMinutes(10); - - private static readonly (string Key, string ConsumerName, string? PlatformTarget)[] PlatformTargetConsumers = - [ - ("Unset", "UnsetPlatformTargetConsumer", null), - ("AnyCPU", "AnyCpuPlatformTargetConsumer", "AnyCPU"), - ("x64", "X64PlatformTargetConsumer", "x64"), - ("x86", "X86PlatformTargetConsumer", "x86"), - ("ARM", "ArmPlatformTargetConsumer", "ARM"), - ("ARM64", "Arm64PlatformTargetConsumer", "ARM64"), - ("Itanium", "ItaniumPlatformTargetConsumer", "Itanium"), - ("Unsupported", "UnsupportedPlatformTargetConsumer", "Unsupported"), - ]; - - private readonly Dictionary _platformTargetConsumerBuildOutput = new(StringComparer.Ordinal); - private readonly Dictionary _platformTargetConsumerBuildSucceeded = new(StringComparer.Ordinal); - private readonly Dictionary _platformTargetConsumerEffectiveValues = new(StringComparer.Ordinal); - - private DirectoryInfo? _tempRoot; - - /// The PackageVersion MinVer gave the packed .nupkg (read back from its file name). - public string PackageVersion { get; private set; } = ""; - - /// - /// The fixture-local NuGet global-packages folder (dotnet restore --packages) every consumer restore - /// extracts into, isolated per fixture run under this fixture's own _tempRoot so a stale extraction left - /// by an earlier run can never shadow the nupkg this run just packed (see this class's remarks). - /// - public string PackagesDirectory { get; private set; } = ""; - - /// Every entry path inside the packed .nupkg. - public IReadOnlyList PackageEntries { get; private set; } = []; - - /// The id of every <dependency> in the packed .nuspec, across every group. - public IReadOnlyList NuspecDependencyIds { get; private set; } = []; - - /// Whether CESDK.CESDK exists in the default consumer's built assembly. - public bool DefaultEntryPointTypeExists { get; private set; } - - /// Whether that type declares a two-parameter CEPluginInitialize. - public bool DefaultEntryPointMethodExists { get; private set; } - - /// Path of the native bridge copied into the default consumer's build output. - public string DefaultNativeBridgePath { get; private set; } = ""; - - /// Whether normal MSBuild clean bookkeeping removed the direct-only native bridge before rebuilding. - public bool DefaultNativeBridgeWasRemovedByClean { get; private set; } - - /// The direct consumer's atomic plugin deployment directory. - public string DefaultDeploymentDirectory { get; private set; } = ""; - - /// Path of the direct consumer's runtime configuration file. - public string DefaultRuntimeConfigPath { get; private set; } = ""; - - /// Path of the direct consumer's dependency manifest. - public string DefaultDepsJsonPath { get; private set; } = ""; - - /// Path of the native bridge copied into the default consumer's publish output. - public string DefaultPublishedNativeBridgePath { get; private set; } = ""; - - /// - /// AllowUnsafeBlocks, EnableDynamicLoading, CheatEngineSdkGenerateEntryPoint for the default - /// consumer. - /// - public IReadOnlyDictionary DefaultProperties { get; private set; } = - new Dictionary(StringComparer.Ordinal); - - /// AllowUnsafeBlocks for the consumer that set it to itself. - public IReadOnlyDictionary ExplicitUnsafeFalseProperties { get; private set; } = - new Dictionary(StringComparer.Ordinal); - - /// - /// Whether the manual CESDK.CESDK bootstrap exists for the consumer that set - /// CheatEngineSdkGenerateEntryPoint=false. - /// - public bool EntryPointOffTypeExists { get; private set; } - - /// Whether that manual bootstrap declares the host-required two-parameter initialization method. - public bool EntryPointOffMethodExists { get; private set; } - - /// Whether a packed consumer with a Lua function and explicit unsafe opt-in built successfully. - public bool LuaFunctionOptInConsumerBuildSucceeded { get; private set; } - - /// - /// Whether the default packaged consumer built the historical AobScanner.TryScan overload compilation - /// probe. - /// - public bool LegacyAobConsumerBuildSucceeded { get; private set; } - - /// Whether a packed consumer with a Lua function but no unsafe opt-in unexpectedly built successfully. - public bool LuaFunctionWithoutUnsafeConsumerBuildSucceeded { get; private set; } - - /// Build output from the Lua-function consumer that intentionally leaves unsafe compilation disabled. - public string LuaFunctionWithoutUnsafeConsumerBuildOutput { get; private set; } = ""; - - /// - /// Whether the package-only Lua runtime executable completed its controlled, offline Lua fixture run - /// successfully. - /// - /// - /// The executable receives this repository's bundled test-only native/cheat-engine/lua53-64.dll as an - /// explicit argument. That DLL is an offline controlled Lua fixture, never a Cheat Engine host; this result does - /// not claim a live Cheat Engine load or host interaction. - /// - public bool PackedRuntimeConsumerRunSucceeded { get; private set; } - - /// Console output captured from the package-only Lua runtime executable. - public string PackedRuntimeConsumerRunOutput { get; private set; } = ""; - - /// - /// Whether the package-only consumer with two otherwise valid Lua exports of one name unexpectedly built. - /// - public bool DuplicateLuaFunctionConsumerBuildSucceeded { get; private set; } - - /// Build output from the package-only duplicate-Lua-function consumer. - public string DuplicateLuaFunctionConsumerBuildOutput { get; private set; } = ""; - - /// - /// Whether the package-only trim and Native AOT executable published successfully as a self-contained - /// win-x64 executable. - /// - /// - /// This proves only standalone package-consumer publication. It neither establishes nor implies that Cheat - /// Engine can load, host, or unload an AOT plugin. - /// - public bool PackedAotConsumerPublishSucceeded { get; private set; } - - /// Console output captured from the package-only trim and Native AOT publish operation. - public string PackedAotConsumerPublishOutput { get; private set; } = ""; - - /// - /// Whether the published package-only trim and Native AOT executable completed successfully. - /// - /// - /// This standalone execution is not a Cheat Engine plugin load, host, or unload result. - /// - public bool PackedAotConsumerRunSucceeded { get; private set; } - - /// Console output captured from the published package-only trim and Native AOT executable. - public string PackedAotConsumerRunOutput { get; private set; } = ""; - - /// The package-controlled properties evaluated by a consumer that references only the carrier package. - public IReadOnlyDictionary IndirectProperties { get; private set; } = - new Dictionary(StringComparer.Ordinal); - - /// Whether the indirect consumer incorrectly received the generated bootstrap. - public bool IndirectEntryPointTypeExists { get; private set; } - - /// Path where an indirect consumer would incorrectly receive the direct-only native bridge at build time. - public string IndirectNativeBridgePath { get; private set; } = ""; - - /// Path where an indirect consumer would incorrectly receive the direct-only native bridge at publish time. - public string IndirectPublishedNativeBridgePath { get; private set; } = ""; - - /// - /// Whether the direct package target completed the build for each named PlatformTarget consumer. The - /// keys are Unset, AnyCPU, x64, x86, ARM, ARM64, Itanium and - /// Unsupported. - /// - public IReadOnlyDictionary PlatformTargetConsumerBuildSucceeded => - _platformTargetConsumerBuildSucceeded; - - /// Diagnostic output from each named PlatformTarget consumer build. - public IReadOnlyDictionary PlatformTargetConsumerBuildOutput => _platformTargetConsumerBuildOutput; - - /// - /// The evaluated PlatformTarget property for each named consumer, captured before its build target runs. - /// - public IReadOnlyDictionary PlatformTargetConsumerEffectiveValues => - _platformTargetConsumerEffectiveValues; - - /// - public async ValueTask InitializeAsync() - { - _tempRoot = Directory.CreateTempSubdirectory("cheatengine-sdk-umbrella-tests-"); - var feedDirectory = Path.Combine(_tempRoot.FullName, "feed"); - Directory.CreateDirectory(feedDirectory); - var packagesDirectory = Path.Combine(_tempRoot.FullName, "packages"); - Directory.CreateDirectory(packagesDirectory); - PackagesDirectory = packagesDirectory; - - await PackUmbrellaAsync(feedDirectory).ConfigureAwait(false); - ReadPackedNupkg(feedDirectory); - - await InitializeDefaultConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializeExplicitUnsafeFalseConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializeEntryPointOffConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializeLuaFunctionConsumersAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializePackedRuntimeConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializeDuplicateLuaFunctionConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializePackedAotConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializeIndirectConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - await InitializePlatformTargetConsumersAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) - .ConfigureAwait(false); - } - - /// - public ValueTask DisposeAsync() - { - if (_tempRoot is not null) - try - { - _tempRoot.Delete(true); - } - catch (IOException) - { - // Best effort: a file a virus scanner or editor still has open must not fail the test run. - } - catch (UnauthorizedAccessException) - { - } - - return ValueTask.CompletedTask; - } - - /// - /// Packing builds every project the umbrella embeds (the six libs, four active shipping components and their - /// shared loader dependency), through the - /// repository's own shared artifacts/ directory - the one resource another build running at the same - /// time might be touching, so a file-lock error is retried rather than treated as a real failure. A plain, - /// unrelated compile error is not retried. - /// - private static async Task PackUmbrellaAsync(string feedDirectory) - { - const int maxAttempts = 5; - var cheatEngineSdkProjectPath = RepositoryLayout.PathOf(UmbrellaPackage.ProjectPath); - ProcessResult result = default; - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - result = await ProcessRunner - .RunAsync("dotnet", $"pack \"{cheatEngineSdkProjectPath}\" -c Release -o \"{feedDirectory}\" --nologo", - RepositoryLayout.Root, PackTimeout) - .ConfigureAwait(false); - if (result.ExitCode == 0) return; - - if (attempt == maxAttempts || !LooksLikeFileLockContention(result.CombinedOutput)) break; - - await Task.Delay(TimeSpan.FromSeconds(20 * attempt), TestContext.Current.CancellationToken) - .ConfigureAwait(false); - } - - throw new InvalidOperationException( - $"'dotnet pack' of the umbrella package failed (exit {result.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{result.CombinedOutput}"); - } - - private static bool LooksLikeFileLockContention(string output) - { - return output.Contains("being used by another process", StringComparison.OrdinalIgnoreCase) - || output.Contains("cannot access the file", StringComparison.OrdinalIgnoreCase) - || output.Contains("MSB3021", StringComparison.Ordinal) - || output.Contains("MSB3027", StringComparison.Ordinal) - || output.Contains("MSB3061", StringComparison.Ordinal); - } - - private async Task InitializeDefaultConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) - { - var consumer = ThrowawayConsumer.Create(tempRoot, "DefaultConsumer", PackageVersion, feedDirectory, - includeLegacyAobConsumer: true, includeTargetBoundAllocationConsumer: true, - includeRecordAndSymbolContract: true, includeValueScanConsumer: true); - await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); - LegacyAobConsumerBuildSucceeded = File.Exists(Path.Combine(consumer.Directory, "LegacyAobConsumer.cs")); - DefaultProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks", "EnableDynamicLoading", - "CheatEngineSdkGenerateEntryPoint") - .ConfigureAwait(false); - DefaultNativeBridgeWasRemovedByClean = await CleanAndRebuildAsync(consumer).ConfigureAwait(false); - - (DefaultEntryPointTypeExists, DefaultEntryPointMethodExists) = EntryPointProbe.Probe(consumer.AssemblyPath); - DefaultNativeBridgePath = consumer.NativeBridgePath; - DefaultDeploymentDirectory = Path.GetDirectoryName(consumer.AssemblyPath)!; - DefaultRuntimeConfigPath = Path.Combine(DefaultDeploymentDirectory, "DefaultConsumer.runtimeconfig.json"); - DefaultDepsJsonPath = Path.Combine(DefaultDeploymentDirectory, "DefaultConsumer.deps.json"); - - var publishDirectory = Path.Combine(tempRoot, "published-default"); - await PublishAsync(consumer, publishDirectory).ConfigureAwait(false); - DefaultPublishedNativeBridgePath = Path.Combine(publishDirectory, "cheatengine-sdk-lua-bridge.dll"); - } - - private async Task InitializeExplicitUnsafeFalseConsumerAsync(string tempRoot, string feedDirectory, - string packagesDirectory) - { - var consumer = ThrowawayConsumer.Create(tempRoot, "ExplicitUnsafeFalseConsumer", PackageVersion, feedDirectory, - " false\n"); - await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); - ExplicitUnsafeFalseProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks") - .ConfigureAwait(false); - } - - private async Task InitializeEntryPointOffConsumerAsync(string tempRoot, string feedDirectory, - string packagesDirectory) - { - var consumer = ThrowawayConsumer.Create(tempRoot, "EntryPointOffConsumer", PackageVersion, feedDirectory, - " false\n"); - // CESDK0003 deliberately makes the handoff explicit: disabling generation transfers ownership of the exact - // host lookup identity to the plugin author. If the generator ignored the false switch, this source would also - // make the consumer fail with the duplicate CESDK.CESDK type - so a successful build proves both contracts. - await File.WriteAllTextAsync(Path.Combine(consumer.Directory, "ManualBootstrap.cs"), """ - namespace CESDK; - - public static class CESDK - { - public static int CEPluginInitialize(System.IntPtr bootstrap, int opaqueArgument) => 1; - } - """, TestContext.Current.CancellationToken).ConfigureAwait(false); - await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); - (EntryPointOffTypeExists, EntryPointOffMethodExists) = EntryPointProbe.Probe(consumer.AssemblyPath); - } - - private async Task InitializeLuaFunctionConsumersAsync(string tempRoot, string feedDirectory, - string packagesDirectory) - { - var optInConsumer = ThrowawayConsumer.Create(tempRoot, "LuaFunctionOptInConsumer", PackageVersion, - feedDirectory, - " true\n", includeLuaFunction: true); - var optInRestore = await optInConsumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(optInRestore, "dotnet restore", optInConsumer.ProjectPath); - var optInBuild = await optInConsumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - LuaFunctionOptInConsumerBuildSucceeded = optInBuild.ExitCode == 0; - EnsureSucceeded(optInBuild, "dotnet build", optInConsumer.ProjectPath); - - var withoutUnsafeConsumer = ThrowawayConsumer.Create(tempRoot, "LuaFunctionWithoutUnsafeConsumer", - PackageVersion, - feedDirectory, includeLuaFunction: true); - var restore = await withoutUnsafeConsumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(restore, "dotnet restore", withoutUnsafeConsumer.ProjectPath); - - var build = await withoutUnsafeConsumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - LuaFunctionWithoutUnsafeConsumerBuildSucceeded = build.ExitCode == 0; - LuaFunctionWithoutUnsafeConsumerBuildOutput = build.CombinedOutput; - } - - private async Task InitializePackedRuntimeConsumerAsync(string tempRoot, string feedDirectory, - string packagesDirectory) - { - var consumer = ThrowawayConsumer.CreateRuntimeExecutable(tempRoot, "PackedRuntimeConsumer", PackageVersion, - feedDirectory, " true\n"); - var restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); - var build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - EnsureSucceeded(build, "dotnet build", consumer.ProjectPath); - - var bundledLuaPath = RepositoryLayout.PathOf("native/cheat-engine/lua53-64.dll"); - if (!File.Exists(bundledLuaPath)) - throw new InvalidOperationException( - $"The package-only Lua runtime consumer requires the bundled test-only Lua fixture at '{bundledLuaPath}'."); - - var run = await consumer.RunAsync(bundledLuaPath, RuntimeRunTimeout).ConfigureAwait(false); - PackedRuntimeConsumerRunSucceeded = run.ExitCode == 0; - PackedRuntimeConsumerRunOutput = run.CombinedOutput; - } - - private async Task InitializeDuplicateLuaFunctionConsumerAsync(string tempRoot, string feedDirectory, - string packagesDirectory) - { - var consumer = ThrowawayConsumer.CreateInvalidDuplicateLuaFunctionConsumer(tempRoot, - "DuplicateLuaFunctionConsumer", PackageVersion, feedDirectory, - " true\n"); - var restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); - - var build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - DuplicateLuaFunctionConsumerBuildSucceeded = build.ExitCode == 0; - DuplicateLuaFunctionConsumerBuildOutput = build.CombinedOutput; - } - - private async Task InitializePackedAotConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) - { - const string consumerName = "PackedAotConsumer"; - var consumer = ThrowawayConsumer.CreateAotExecutable(tempRoot, consumerName, PackageVersion, feedDirectory, - """ - win-x64 - true - true - true - true - IL3058 - """); - var restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); - var build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - EnsureSucceeded(build, "dotnet build", consumer.ProjectPath); - - var publishDirectory = Path.Combine(tempRoot, "published-packed-aot"); - var publish = await consumer.PublishAsync(AotPublishTimeout, publishDirectory).ConfigureAwait(false); - PackedAotConsumerPublishSucceeded = publish.ExitCode == 0; - PackedAotConsumerPublishOutput = publish.CombinedOutput; - if (!PackedAotConsumerPublishSucceeded) return; - - var run = await consumer.RunPublishedAsync(publishDirectory, RuntimeRunTimeout).ConfigureAwait(false); - PackedAotConsumerRunSucceeded = run.ExitCode == 0; - PackedAotConsumerRunOutput = run.CombinedOutput; - } - - private async Task InitializeIndirectConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) - { - var carrier = ThrowawayPackageCarrier.Create(tempRoot, PackageVersion, feedDirectory); - var carrierRestore = await carrier.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(carrierRestore, "dotnet restore", carrier.ProjectPath); - var carrierPack = await carrier.PackAsync(PackTimeout, feedDirectory).ConfigureAwait(false); - EnsureSucceeded(carrierPack, "dotnet pack", carrier.ProjectPath); - - var consumer = ThrowawayConsumer.CreateIndirect(tempRoot, ThrowawayPackageCarrier.PackageId, - ThrowawayPackageCarrier.PackageVersion, feedDirectory); - await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); - IndirectProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks", - "EnableDynamicLoading", - "CheatEngineSdkGenerateEntryPoint") - .ConfigureAwait(false); - (IndirectEntryPointTypeExists, _) = EntryPointProbe.Probe(consumer.AssemblyPath); - IndirectNativeBridgePath = consumer.NativeBridgePath; - - var publishDirectory = Path.Combine(tempRoot, "published-indirect"); - await PublishAsync(consumer, publishDirectory).ConfigureAwait(false); - IndirectPublishedNativeBridgePath = Path.Combine(publishDirectory, "cheatengine-sdk-lua-bridge.dll"); - } - - private async Task InitializePlatformTargetConsumersAsync(string tempRoot, string feedDirectory, - string packagesDirectory) - { - foreach (var (key, consumerName, platformTarget) in PlatformTargetConsumers) - { - var consumer = ThrowawayConsumer.Create(tempRoot, consumerName, PackageVersion, feedDirectory, - platformTarget: platformTarget); - var restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); - - var effectiveProperties = await consumer.GetPropertiesAsync(BuildTimeout, "PlatformTarget") - .ConfigureAwait(false); - _platformTargetConsumerEffectiveValues[key] = effectiveProperties["PlatformTarget"]; - - var build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - _platformTargetConsumerBuildSucceeded[key] = build.ExitCode == 0; - _platformTargetConsumerBuildOutput[key] = build.CombinedOutput; - } - } - - private static async Task CleanAndRebuildAsync(ThrowawayConsumer consumer) - { - var clean = await consumer.CleanAsync(BuildTimeout).ConfigureAwait(false); - EnsureSucceeded(clean, "dotnet clean", consumer.ProjectPath); - var bridgeWasRemoved = !File.Exists(consumer.NativeBridgePath); - var rebuild = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - EnsureSucceeded(rebuild, "dotnet build after clean", consumer.ProjectPath); - return bridgeWasRemoved; - } - - private static async Task PublishAsync(ThrowawayConsumer consumer, string outputDirectory) - { - var publish = await consumer.PublishAsync(PublishTimeout, outputDirectory).ConfigureAwait(false); - EnsureSucceeded(publish, "dotnet publish", consumer.ProjectPath); - } - - private static void EnsureSucceeded(ProcessResult result, string operation, string projectPath) - { - if (result.ExitCode != 0) - throw new InvalidOperationException( - $"'{operation}' failed for '{projectPath}' (exit {result.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{result.CombinedOutput}"); - } - - private void ReadPackedNupkg(string feedDirectory) - { - var nupkgPaths = Directory.GetFiles(feedDirectory, $"{UmbrellaPackage.Id}.*.nupkg"); - if (nupkgPaths.Length != 1) - throw new InvalidOperationException( - $"Expected exactly one {UmbrellaPackage.Id}.*.nupkg in '{feedDirectory}', found {nupkgPaths.Length.ToString(CultureInfo.InvariantCulture)}: {string.Join(", ", nupkgPaths)}"); - - var fileName = Path.GetFileName(nupkgPaths[0]); - PackageVersion = fileName[(UmbrellaPackage.Id.Length + 1)..^".nupkg".Length]; - - var (entries, nuspec) = NupkgInspector.Read(nupkgPaths[0]); - PackageEntries = entries; - NuspecDependencyIds = NupkgInspector.GetDependencyIds(nuspec); - } - - private static async Task RestoreAndBuildAsync(ThrowawayConsumer consumer, string packagesDirectory) - { - var restoreResult = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); - if (restoreResult.ExitCode != 0) - throw new InvalidOperationException( - $"'dotnet restore' failed for '{consumer.ProjectPath}' (exit {restoreResult.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{restoreResult.CombinedOutput}"); - - var buildResult = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); - if (buildResult.ExitCode != 0) - throw new InvalidOperationException( - $"'dotnet build' failed for '{consumer.ProjectPath}' (exit {buildResult.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{buildResult.CombinedOutput}"); - } + private static readonly TimeSpan PackTimeout = TimeSpan.FromMinutes(3); + private static readonly TimeSpan RestoreTimeout = TimeSpan.FromMinutes(3); + private static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan PublishTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan RuntimeRunTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan AotPublishTimeout = TimeSpan.FromMinutes(10); + + private static readonly (string Key, string ConsumerName, string? PlatformTarget)[] PlatformTargetConsumers = + [ + ("Unset", "UnsetPlatformTargetConsumer", null), + ("AnyCPU", "AnyCpuPlatformTargetConsumer", "AnyCPU"), + ("x64", "X64PlatformTargetConsumer", "x64"), + ("x86", "X86PlatformTargetConsumer", "x86"), + ("ARM", "ArmPlatformTargetConsumer", "ARM"), + ("ARM64", "Arm64PlatformTargetConsumer", "ARM64"), + ("Itanium", "ItaniumPlatformTargetConsumer", "Itanium"), + ("Unsupported", "UnsupportedPlatformTargetConsumer", "Unsupported") + ]; + + private readonly Dictionary _platformTargetConsumerBuildOutput = new(StringComparer.Ordinal); + private readonly Dictionary _platformTargetConsumerBuildSucceeded = new(StringComparer.Ordinal); + private readonly Dictionary _platformTargetConsumerEffectiveValues = new(StringComparer.Ordinal); + + private DirectoryInfo? _tempRoot; + + /// The PackageVersion MinVer gave the packed .nupkg (read back from its file name). + public string PackageVersion + { + get; + private set; + } = ""; + + /// + /// The fixture-local NuGet global-packages folder (dotnet restore --packages) every consumer restore + /// extracts into, isolated per fixture run under this fixture's own _tempRoot so a stale extraction left + /// by an earlier run can never shadow the nupkg this run just packed (see this class's remarks). + /// + public string PackagesDirectory + { + get; + private set; + } = ""; + + /// Every entry path inside the packed .nupkg. + public IReadOnlyList PackageEntries + { + get; + private set; + } = []; + + /// The id of every <dependency> in the packed .nuspec, across every group. + public IReadOnlyList NuspecDependencyIds + { + get; + private set; + } = []; + + /// Whether CESDK.CESDK exists in the default consumer's built assembly. + public bool DefaultEntryPointTypeExists + { + get; + private set; + } + + /// Whether that type declares a two-parameter CEPluginInitialize. + public bool DefaultEntryPointMethodExists + { + get; + private set; + } + + /// Path of the native bridge copied into the default consumer's build output. + public string DefaultNativeBridgePath + { + get; + private set; + } = ""; + + /// Whether normal MSBuild clean bookkeeping removed the direct-only native bridge before rebuilding. + public bool DefaultNativeBridgeWasRemovedByClean + { + get; + private set; + } + + /// The direct consumer's atomic plugin deployment directory. + public string DefaultDeploymentDirectory + { + get; + private set; + } = ""; + + /// Path of the direct consumer's runtime configuration file. + public string DefaultRuntimeConfigPath + { + get; + private set; + } = ""; + + /// Path of the direct consumer's dependency manifest. + public string DefaultDepsJsonPath + { + get; + private set; + } = ""; + + /// Path of the native bridge copied into the default consumer's publish output. + public string DefaultPublishedNativeBridgePath + { + get; + private set; + } = ""; + + /// + /// AllowUnsafeBlocks, EnableDynamicLoading, CheatEngineSdkGenerateEntryPoint for the default + /// consumer. + /// + public IReadOnlyDictionary DefaultProperties + { + get; + private set; + } = + new Dictionary(StringComparer.Ordinal); + + /// AllowUnsafeBlocks for the consumer that set it to itself. + public IReadOnlyDictionary ExplicitUnsafeFalseProperties + { + get; + private set; + } = + new Dictionary(StringComparer.Ordinal); + + /// + /// Whether the manual CESDK.CESDK bootstrap exists for the consumer that set + /// CheatEngineSdkGenerateEntryPoint=false. + /// + public bool EntryPointOffTypeExists + { + get; + private set; + } + + /// Whether that manual bootstrap declares the host-required two-parameter initialization method. + public bool EntryPointOffMethodExists + { + get; + private set; + } + + /// Whether a packed consumer with a Lua function and explicit unsafe opt-in built successfully. + public bool LuaFunctionOptInConsumerBuildSucceeded + { + get; + private set; + } + + /// + /// Whether the default packaged consumer built the historical AobScanner.TryScan overload compilation + /// probe. + /// + public bool LegacyAobConsumerBuildSucceeded + { + get; + private set; + } + + /// Whether a packed consumer with a Lua function but no unsafe opt-in unexpectedly built successfully. + public bool LuaFunctionWithoutUnsafeConsumerBuildSucceeded + { + get; + private set; + } + + /// Build output from the Lua-function consumer that intentionally leaves unsafe compilation disabled. + public string LuaFunctionWithoutUnsafeConsumerBuildOutput + { + get; + private set; + } = ""; + + /// + /// Whether the package-only Lua runtime executable completed its controlled, offline Lua fixture run + /// successfully. + /// + /// + /// The executable receives this repository's bundled test-only native/cheat-engine/lua53-64.dll as an + /// explicit argument. That DLL is an offline controlled Lua fixture, never a Cheat Engine host; this result does + /// not claim a live Cheat Engine load or host interaction. + /// + public bool PackedRuntimeConsumerRunSucceeded + { + get; + private set; + } + + /// Console output captured from the package-only Lua runtime executable. + public string PackedRuntimeConsumerRunOutput + { + get; + private set; + } = ""; + + /// + /// Whether the package-only consumer with two otherwise valid Lua exports of one name unexpectedly built. + /// + public bool DuplicateLuaFunctionConsumerBuildSucceeded + { + get; + private set; + } + + /// Build output from the package-only duplicate-Lua-function consumer. + public string DuplicateLuaFunctionConsumerBuildOutput + { + get; + private set; + } = ""; + + /// + /// Whether the package-only trim and Native AOT executable published successfully as a self-contained + /// win-x64 executable. + /// + /// + /// This proves only standalone package-consumer publication. It neither establishes nor implies that Cheat + /// Engine can load, host, or unload an AOT plugin. + /// + public bool PackedAotConsumerPublishSucceeded + { + get; + private set; + } + + /// Console output captured from the package-only trim and Native AOT publish operation. + public string PackedAotConsumerPublishOutput + { + get; + private set; + } = ""; + + /// + /// Whether the published package-only trim and Native AOT executable completed successfully. + /// + /// + /// This standalone execution is not a Cheat Engine plugin load, host, or unload result. + /// + public bool PackedAotConsumerRunSucceeded + { + get; + private set; + } + + /// Console output captured from the published package-only trim and Native AOT executable. + public string PackedAotConsumerRunOutput + { + get; + private set; + } = ""; + + /// The package-controlled properties evaluated by a consumer that references only the carrier package. + public IReadOnlyDictionary IndirectProperties + { + get; + private set; + } = + new Dictionary(StringComparer.Ordinal); + + /// Whether the indirect consumer incorrectly received the generated bootstrap. + public bool IndirectEntryPointTypeExists + { + get; + private set; + } + + /// Path where an indirect consumer would incorrectly receive the direct-only native bridge at build time. + public string IndirectNativeBridgePath + { + get; + private set; + } = ""; + + /// Path where an indirect consumer would incorrectly receive the direct-only native bridge at publish time. + public string IndirectPublishedNativeBridgePath + { + get; + private set; + } = ""; + + /// + /// Whether the direct package target completed the build for each named PlatformTarget consumer. The + /// keys are Unset, AnyCPU, x64, x86, ARM, ARM64, Itanium and + /// Unsupported. + /// + public IReadOnlyDictionary PlatformTargetConsumerBuildSucceeded => + _platformTargetConsumerBuildSucceeded; + + /// Diagnostic output from each named PlatformTarget consumer build. + public IReadOnlyDictionary PlatformTargetConsumerBuildOutput => _platformTargetConsumerBuildOutput; + + /// + /// The evaluated PlatformTarget property for each named consumer, captured before its build target runs. + /// + public IReadOnlyDictionary PlatformTargetConsumerEffectiveValues => + _platformTargetConsumerEffectiveValues; + + /// + public async ValueTask InitializeAsync() + { + _tempRoot = Directory.CreateTempSubdirectory("cheatengine-sdk-umbrella-tests-"); + string feedDirectory = Path.Combine(_tempRoot.FullName, "feed"); + Directory.CreateDirectory(feedDirectory); + string packagesDirectory = Path.Combine(_tempRoot.FullName, "packages"); + Directory.CreateDirectory(packagesDirectory); + PackagesDirectory = packagesDirectory; + + await PackUmbrellaAsync(feedDirectory).ConfigureAwait(false); + ReadPackedNupkg(feedDirectory); + + await InitializeDefaultConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializeExplicitUnsafeFalseConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializeEntryPointOffConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializeLuaFunctionConsumersAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializePackedRuntimeConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializeDuplicateLuaFunctionConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializePackedAotConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializeIndirectConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + await InitializePlatformTargetConsumersAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) + .ConfigureAwait(false); + } + + /// + public ValueTask DisposeAsync() + { + if (_tempRoot is not null) + { + try + { + _tempRoot.Delete(true); + } + catch (IOException) + { + // Best effort: a file a virus scanner or editor still has open must not fail the test run. + } + catch (UnauthorizedAccessException) + { + } + } + + return ValueTask.CompletedTask; + } + + /// + /// Packing builds every project the umbrella embeds (the six libs, four active shipping components and their + /// shared loader dependency), through the + /// repository's own shared artifacts/ directory - the one resource another build running at the same + /// time might be touching, so a file-lock error is retried rather than treated as a real failure. A plain, + /// unrelated compile error is not retried. + /// + private static async Task PackUmbrellaAsync(string feedDirectory) + { + const int maxAttempts = 5; + string cheatEngineSdkProjectPath = RepositoryLayout.PathOf(UmbrellaPackage.ProjectPath); + ProcessResult result = default; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + result = await ProcessRunner + .RunAsync("dotnet", $"pack \"{cheatEngineSdkProjectPath}\" -c Release -o \"{feedDirectory}\" --nologo", + RepositoryLayout.Root, PackTimeout) + .ConfigureAwait(false); + if (result.ExitCode == 0) + { + return; + } + + if (attempt == maxAttempts || !LooksLikeFileLockContention(result.CombinedOutput)) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(20 * attempt), TestContext.Current.CancellationToken) + .ConfigureAwait(false); + } + + throw new InvalidOperationException( + $"'dotnet pack' of the umbrella package failed (exit {result.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{result.CombinedOutput}"); + } + + private static bool LooksLikeFileLockContention(string output) + { + return output.Contains("being used by another process", StringComparison.OrdinalIgnoreCase) + || output.Contains("cannot access the file", StringComparison.OrdinalIgnoreCase) + || output.Contains("MSB3021", StringComparison.Ordinal) + || output.Contains("MSB3027", StringComparison.Ordinal) + || output.Contains("MSB3061", StringComparison.Ordinal); + } + + private async Task InitializeDefaultConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) + { + ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, "DefaultConsumer", PackageVersion, + feedDirectory, + includeLegacyAobConsumer: true, includeTargetBoundAllocationConsumer: true, + includeRecordAndSymbolContract: true, includeValueScanConsumer: true); + await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); + LegacyAobConsumerBuildSucceeded = File.Exists(Path.Combine(consumer.Directory, "LegacyAobConsumer.cs")); + DefaultProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks", "EnableDynamicLoading", + "CheatEngineSdkGenerateEntryPoint") + .ConfigureAwait(false); + DefaultNativeBridgeWasRemovedByClean = await CleanAndRebuildAsync(consumer).ConfigureAwait(false); + + (DefaultEntryPointTypeExists, DefaultEntryPointMethodExists) = EntryPointProbe.Probe(consumer.AssemblyPath); + DefaultNativeBridgePath = consumer.NativeBridgePath; + DefaultDeploymentDirectory = Path.GetDirectoryName(consumer.AssemblyPath)!; + DefaultRuntimeConfigPath = Path.Combine(DefaultDeploymentDirectory, "DefaultConsumer.runtimeconfig.json"); + DefaultDepsJsonPath = Path.Combine(DefaultDeploymentDirectory, "DefaultConsumer.deps.json"); + + string publishDirectory = Path.Combine(tempRoot, "published-default"); + await PublishAsync(consumer, publishDirectory).ConfigureAwait(false); + DefaultPublishedNativeBridgePath = Path.Combine(publishDirectory, "cheatengine-sdk-lua-bridge.dll"); + } + + private async Task InitializeExplicitUnsafeFalseConsumerAsync(string tempRoot, string feedDirectory, + string packagesDirectory) + { + ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, "ExplicitUnsafeFalseConsumer", PackageVersion, + feedDirectory, + " false\n"); + await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); + ExplicitUnsafeFalseProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks") + .ConfigureAwait(false); + } + + private async Task InitializeEntryPointOffConsumerAsync(string tempRoot, string feedDirectory, + string packagesDirectory) + { + ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, "EntryPointOffConsumer", PackageVersion, + feedDirectory, + " false\n"); + // CESDK0003 deliberately makes the handoff explicit: disabling generation transfers ownership of the exact + // host lookup identity to the plugin author. If the generator ignored the false switch, this source would also + // make the consumer fail with the duplicate CESDK.CESDK type - so a successful build proves both contracts. + await File.WriteAllTextAsync(Path.Combine(consumer.Directory, "ManualBootstrap.cs"), """ + namespace CESDK; + + public static class CESDK + { + public static int CEPluginInitialize(System.IntPtr bootstrap, int opaqueArgument) => 1; + } + """, TestContext.Current.CancellationToken).ConfigureAwait(false); + await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); + (EntryPointOffTypeExists, EntryPointOffMethodExists) = EntryPointProbe.Probe(consumer.AssemblyPath); + } + + private async Task InitializeLuaFunctionConsumersAsync(string tempRoot, string feedDirectory, + string packagesDirectory) + { + ThrowawayConsumer optInConsumer = ThrowawayConsumer.Create(tempRoot, "LuaFunctionOptInConsumer", PackageVersion, + feedDirectory, + " true\n", includeLuaFunction: true); + ProcessResult optInRestore = + await optInConsumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + EnsureSucceeded(optInRestore, "dotnet restore", optInConsumer.ProjectPath); + ProcessResult optInBuild = await optInConsumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + LuaFunctionOptInConsumerBuildSucceeded = optInBuild.ExitCode == 0; + EnsureSucceeded(optInBuild, "dotnet build", optInConsumer.ProjectPath); + + ThrowawayConsumer withoutUnsafeConsumer = ThrowawayConsumer.Create(tempRoot, "LuaFunctionWithoutUnsafeConsumer", + PackageVersion, + feedDirectory, includeLuaFunction: true); + ProcessResult restore = await withoutUnsafeConsumer.RestoreAsync(RestoreTimeout, packagesDirectory) + .ConfigureAwait(false); + EnsureSucceeded(restore, "dotnet restore", withoutUnsafeConsumer.ProjectPath); + + ProcessResult build = await withoutUnsafeConsumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + LuaFunctionWithoutUnsafeConsumerBuildSucceeded = build.ExitCode == 0; + LuaFunctionWithoutUnsafeConsumerBuildOutput = build.CombinedOutput; + } + + private async Task InitializePackedRuntimeConsumerAsync(string tempRoot, string feedDirectory, + string packagesDirectory) + { + ThrowawayConsumer consumer = ThrowawayConsumer.CreateRuntimeExecutable(tempRoot, "PackedRuntimeConsumer", + PackageVersion, + feedDirectory, " true\n"); + ProcessResult restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); + ProcessResult build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + EnsureSucceeded(build, "dotnet build", consumer.ProjectPath); + + string bundledLuaPath = RepositoryLayout.PathOf("native/cheat-engine/lua53-64.dll"); + if (!File.Exists(bundledLuaPath)) + { + throw new InvalidOperationException( + $"The package-only Lua runtime consumer requires the bundled test-only Lua fixture at '{bundledLuaPath}'."); + } + + ProcessResult run = await consumer.RunAsync(bundledLuaPath, RuntimeRunTimeout).ConfigureAwait(false); + PackedRuntimeConsumerRunSucceeded = run.ExitCode == 0; + PackedRuntimeConsumerRunOutput = run.CombinedOutput; + } + + private async Task InitializeDuplicateLuaFunctionConsumerAsync(string tempRoot, string feedDirectory, + string packagesDirectory) + { + ThrowawayConsumer consumer = ThrowawayConsumer.CreateInvalidDuplicateLuaFunctionConsumer(tempRoot, + "DuplicateLuaFunctionConsumer", PackageVersion, feedDirectory, + " true\n"); + ProcessResult restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); + + ProcessResult build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + DuplicateLuaFunctionConsumerBuildSucceeded = build.ExitCode == 0; + DuplicateLuaFunctionConsumerBuildOutput = build.CombinedOutput; + } + + private async Task InitializePackedAotConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) + { + const string consumerName = "PackedAotConsumer"; + ThrowawayConsumer consumer = ThrowawayConsumer.CreateAotExecutable(tempRoot, consumerName, PackageVersion, + feedDirectory, + """ + win-x64 + true + true + true + true + IL3058 + """); + ProcessResult restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); + ProcessResult build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + EnsureSucceeded(build, "dotnet build", consumer.ProjectPath); + + string publishDirectory = Path.Combine(tempRoot, "published-packed-aot"); + ProcessResult publish = await consumer.PublishAsync(AotPublishTimeout, publishDirectory).ConfigureAwait(false); + PackedAotConsumerPublishSucceeded = publish.ExitCode == 0; + PackedAotConsumerPublishOutput = publish.CombinedOutput; + if (!PackedAotConsumerPublishSucceeded) + { + return; + } + + ProcessResult run = await consumer.RunPublishedAsync(publishDirectory, RuntimeRunTimeout).ConfigureAwait(false); + PackedAotConsumerRunSucceeded = run.ExitCode == 0; + PackedAotConsumerRunOutput = run.CombinedOutput; + } + + private async Task InitializeIndirectConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) + { + ThrowawayPackageCarrier carrier = ThrowawayPackageCarrier.Create(tempRoot, PackageVersion, feedDirectory); + ProcessResult carrierRestore = + await carrier.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + EnsureSucceeded(carrierRestore, "dotnet restore", carrier.ProjectPath); + ProcessResult carrierPack = await carrier.PackAsync(PackTimeout, feedDirectory).ConfigureAwait(false); + EnsureSucceeded(carrierPack, "dotnet pack", carrier.ProjectPath); + + ThrowawayConsumer consumer = ThrowawayConsumer.CreateIndirect(tempRoot, ThrowawayPackageCarrier.PackageId, + ThrowawayPackageCarrier.PackageVersion, feedDirectory); + await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); + IndirectProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks", + "EnableDynamicLoading", + "CheatEngineSdkGenerateEntryPoint") + .ConfigureAwait(false); + (IndirectEntryPointTypeExists, _) = EntryPointProbe.Probe(consumer.AssemblyPath); + IndirectNativeBridgePath = consumer.NativeBridgePath; + + string publishDirectory = Path.Combine(tempRoot, "published-indirect"); + await PublishAsync(consumer, publishDirectory).ConfigureAwait(false); + IndirectPublishedNativeBridgePath = Path.Combine(publishDirectory, "cheatengine-sdk-lua-bridge.dll"); + } + + private async Task InitializePlatformTargetConsumersAsync(string tempRoot, string feedDirectory, + string packagesDirectory) + { + foreach ((string key, string consumerName, string? platformTarget) in PlatformTargetConsumers) + { + ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, consumerName, PackageVersion, feedDirectory, + platformTarget: platformTarget); + ProcessResult restore = + await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); + + IReadOnlyDictionary effectiveProperties = await consumer + .GetPropertiesAsync(BuildTimeout, "PlatformTarget") + .ConfigureAwait(false); + _platformTargetConsumerEffectiveValues[key] = effectiveProperties["PlatformTarget"]; + + ProcessResult build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + _platformTargetConsumerBuildSucceeded[key] = build.ExitCode == 0; + _platformTargetConsumerBuildOutput[key] = build.CombinedOutput; + } + } + + private static async Task CleanAndRebuildAsync(ThrowawayConsumer consumer) + { + ProcessResult clean = await consumer.CleanAsync(BuildTimeout).ConfigureAwait(false); + EnsureSucceeded(clean, "dotnet clean", consumer.ProjectPath); + bool bridgeWasRemoved = !File.Exists(consumer.NativeBridgePath); + ProcessResult rebuild = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + EnsureSucceeded(rebuild, "dotnet build after clean", consumer.ProjectPath); + return bridgeWasRemoved; + } + + private static async Task PublishAsync(ThrowawayConsumer consumer, string outputDirectory) + { + ProcessResult publish = await consumer.PublishAsync(PublishTimeout, outputDirectory).ConfigureAwait(false); + EnsureSucceeded(publish, "dotnet publish", consumer.ProjectPath); + } + + private static void EnsureSucceeded(ProcessResult result, string operation, string projectPath) + { + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"'{operation}' failed for '{projectPath}' (exit {result.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{result.CombinedOutput}"); + } + } + + private void ReadPackedNupkg(string feedDirectory) + { + string[] nupkgPaths = Directory.GetFiles(feedDirectory, $"{UmbrellaPackage.Id}.*.nupkg"); + if (nupkgPaths.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one {UmbrellaPackage.Id}.*.nupkg in '{feedDirectory}', found {nupkgPaths.Length.ToString(CultureInfo.InvariantCulture)}: {string.Join(", ", nupkgPaths)}"); + } + + string fileName = Path.GetFileName(nupkgPaths[0]); + PackageVersion = fileName[(UmbrellaPackage.Id.Length + 1)..^".nupkg".Length]; + + (IReadOnlyList entries, XDocument nuspec) = NupkgInspector.Read(nupkgPaths[0]); + PackageEntries = entries; + NuspecDependencyIds = NupkgInspector.GetDependencyIds(nuspec); + } + + private static async Task RestoreAndBuildAsync(ThrowawayConsumer consumer, string packagesDirectory) + { + ProcessResult restoreResult = + await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); + if (restoreResult.ExitCode != 0) + { + throw new InvalidOperationException( + $"'dotnet restore' failed for '{consumer.ProjectPath}' (exit {restoreResult.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{restoreResult.CombinedOutput}"); + } + + ProcessResult buildResult = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); + if (buildResult.ExitCode != 0) + { + throw new InvalidOperationException( + $"'dotnet build' failed for '{consumer.ProjectPath}' (exit {buildResult.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{buildResult.CombinedOutput}"); + } + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaSuite.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaSuite.cs index 817f6b41..5f2acc12 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaSuite.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaSuite.cs @@ -9,6 +9,6 @@ namespace CheatEngine.SDK.Tests.Infrastructure; [CollectionDefinition(Name)] public sealed class PackagedUmbrellaSuite : ICollectionFixture { - /// The collection name every Packaging/*.cs test class passes to [Collection]. - public const string Name = "Packaged umbrella"; + /// The collection name every Packaging/*.cs test class passes to [Collection]. + public const string Name = "Packaged umbrella"; } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PortableExecutableInspector.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PortableExecutableInspector.cs index 303ff87f..43b714dc 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PortableExecutableInspector.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PortableExecutableInspector.cs @@ -11,272 +11,319 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// internal sealed class PortableExecutableInspector { - private readonly PEHeaders _headers; - private readonly byte[] _image; - - private PortableExecutableInspector(byte[] image, PEHeaders headers) - { - _image = image; - _headers = headers; - } - - /// The machine architecture declared by the COFF header. - public Machine Machine => _headers.CoffHeader.Machine; - - /// Whether the PE characteristics identify this image as a DLL. - public bool IsDll => _headers.IsDll; - - /// The optional-header format. - public PEMagic Magic => GetRequiredPeHeader().Magic; - - /// Whether the image has a delay-load import directory. - public bool HasDelayImports - { - get - { - var delayImports = GetRequiredPeHeader().DelayImportTableDirectory; - return delayImports.RelativeVirtualAddress != 0 || delayImports.Size != 0; - } - } - - /// Reads a PE image without loading it into the current process. - public static PortableExecutableInspector Read(string path) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - - var image = File.ReadAllBytes(path); - using var stream = new MemoryStream(image, writable: false); - using var reader = new PEReader(stream, PEStreamOptions.PrefetchEntireImage); - - if (reader.PEHeaders.IsCoffOnly) - throw new InvalidDataException($"'{path}' is a COFF object, not a PE image."); - - return new PortableExecutableInspector(image, reader.PEHeaders); - } - - /// Returns all named exports and their implementation RVAs. - public IReadOnlyList GetExports() - { - var directory = GetRequiredPeHeader().ExportTableDirectory; - if (directory.RelativeVirtualAddress == 0 || directory.Size == 0) - throw new InvalidDataException("The PE image has no export directory."); - - var directoryOffset = MapRva(GetDirectoryRva(directory, "export"), 40u); - var numberOfFunctions = ReadUInt32(directoryOffset + 20); - var numberOfNames = ReadUInt32(directoryOffset + 24); - var functionsRva = ReadUInt32(directoryOffset + 28); - var namesRva = ReadUInt32(directoryOffset + 32); - var ordinalsRva = ReadUInt32(directoryOffset + 36); - - var functionsOffset = MapRva(functionsRva, CheckedByteCount(numberOfFunctions, sizeof(uint))); - var namesOffset = MapRva(namesRva, CheckedByteCount(numberOfNames, sizeof(uint))); - var ordinalsOffset = MapRva(ordinalsRva, CheckedByteCount(numberOfNames, sizeof(ushort))); - var exports = new List(checked((int)numberOfNames)); - - for (var index = 0u; index < numberOfNames; index++) - { - var nameRva = ReadUInt32(namesOffset + checked((int)(index * sizeof(uint)))); - var ordinalIndex = ReadUInt16(ordinalsOffset + checked((int)(index * sizeof(ushort)))); - if (ordinalIndex >= numberOfFunctions) - throw new InvalidDataException("An export ordinal points outside the export address table."); - - var implementationRva = ReadUInt32(functionsOffset + checked(ordinalIndex * sizeof(uint))); - exports.Add(new PortableExecutableExport(ReadAsciiZ(nameRva), implementationRva)); - } - - return exports; - } - - /// Returns all normal import modules and their named or ordinal imports. - public IReadOnlyList GetImports() - { - var header = GetRequiredPeHeader(); - var directory = header.ImportTableDirectory; - if (directory.RelativeVirtualAddress == 0 && directory.Size == 0) - return []; - if (directory.RelativeVirtualAddress == 0 || directory.Size == 0) - throw new InvalidDataException("The PE import directory has an incomplete RVA/size pair."); - - var directorySize = GetDirectorySize(directory, "import"); - var directoryOffset = MapRva(GetDirectoryRva(directory, "import"), directorySize); - var directoryEnd = checked(directoryOffset + (int)directorySize); - var imports = new List(); - - for (var descriptorOffset = directoryOffset; descriptorOffset <= directoryEnd - 20; descriptorOffset += 20) - { - var originalFirstThunk = ReadUInt32(descriptorOffset); - var timeDateStamp = ReadUInt32(descriptorOffset + 4); - var forwarderChain = ReadUInt32(descriptorOffset + 8); - var nameRva = ReadUInt32(descriptorOffset + 12); - var firstThunk = ReadUInt32(descriptorOffset + 16); - if (originalFirstThunk == 0 && timeDateStamp == 0 && forwarderChain == 0 && nameRva == 0 && firstThunk == 0) - return imports; - - if (nameRva == 0) - throw new InvalidDataException("An import descriptor has no module name."); - - var lookupTableRva = originalFirstThunk != 0 ? originalFirstThunk : firstThunk; - if (lookupTableRva == 0) - throw new InvalidDataException("An import descriptor has no lookup table."); - - imports.Add(new PortableExecutableImport(ReadAsciiZ(nameRva), ReadImportSymbols(lookupTableRva))); - } - - throw new InvalidDataException("The PE import directory is missing its null descriptor."); - } - - /// Reads a NUL-terminated ANSI payload from a named export. - public string ReadExportedAsciiZ(string exportName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(exportName); - - var exports = GetExports(); - for (var index = 0; index < exports.Count; index++) - if (string.Equals(exports[index].Name, exportName, StringComparison.Ordinal)) - return ReadAsciiZ(exports[index].RelativeVirtualAddress); - - throw new MissingMethodException($"The PE image does not export '{exportName}'."); - } - - private List ReadImportSymbols(uint lookupTableRva) - { - var symbols = new List(); - var thunkRva = lookupTableRva; - while (true) - { - var thunkOffset = MapRva(thunkRva, sizeof(ulong)); - var thunk = ReadUInt64(thunkOffset); - if (thunk == 0) - return symbols; - - const ulong ordinalMask = 0x8000000000000000UL; - if ((thunk & ordinalMask) != 0) - { - symbols.Add($"#{thunk & 0xFFFFUL}"); - } - else - { - if (thunk > uint.MaxValue) - throw new InvalidDataException("A PE32+ import name RVA exceeds 32 bits."); - - var hintNameRva = (uint)thunk; - _ = MapRva(hintNameRva, sizeof(ushort)); - symbols.Add(ReadAsciiZ(checked(hintNameRva + sizeof(ushort)))); - } - - thunkRva = checked(thunkRva + sizeof(ulong)); - } - } - - private string ReadAsciiZ(uint rva) - { - var offset = MapRva(rva, 1u, out var bytesAvailable); - var end = offset; - var maximum = checked(offset + bytesAvailable); - while (end < maximum && _image[end] != 0) - end++; - - if (end == maximum) - throw new InvalidDataException("A PE ASCII string is not NUL-terminated inside its mapped section."); - - return Encoding.ASCII.GetString(_image, offset, end - offset); - } - - private int MapRva(uint rva, uint byteCount) - { - return MapRva(rva, byteCount, out _); - } - - private int MapRva(uint rva, uint byteCount, out int bytesAvailable) - { - var header = GetRequiredPeHeader(); - if (header.SizeOfHeaders < 0) - throw new InvalidDataException("The PE image has a negative SizeOfHeaders value."); - - var headerSize = (uint)header.SizeOfHeaders; - if (rva < headerSize) - { - if ((ulong)rva + byteCount > headerSize || (ulong)rva + byteCount > (uint)_image.Length) - throw new InvalidDataException("An RVA range extends beyond the PE headers."); - - bytesAvailable = checked((int)(headerSize - rva)); - return checked((int)rva); - } - - foreach (var section in _headers.SectionHeaders) - { - if (section.VirtualAddress < 0 || section.SizeOfRawData < 0 || section.PointerToRawData < 0) - throw new InvalidDataException("The PE image has a negative section field."); - - var sectionRva = (uint)section.VirtualAddress; - var rawSize = (uint)section.SizeOfRawData; - var virtualSize = section.VirtualSize < 0 ? 0u : (uint)section.VirtualSize; - var mappedSize = Math.Max(rawSize, virtualSize); - if ((ulong)rva < sectionRva || rva >= (ulong)sectionRva + mappedSize) - continue; - - var delta = checked(rva - sectionRva); - if ((ulong)delta + byteCount > rawSize) - throw new InvalidDataException("An RVA range extends past raw bytes in its PE section."); - - var fileOffset = checked((uint)section.PointerToRawData + delta); - if ((ulong)fileOffset + byteCount > (uint)_image.Length) - throw new InvalidDataException("An RVA maps past the end of the PE image."); - - bytesAvailable = checked((int)(rawSize - delta)); - return checked((int)fileOffset); - } - - throw new InvalidDataException("An RVA does not map to a PE section."); - } - - private PEHeader GetRequiredPeHeader() - { - return _headers.PEHeader ?? throw new InvalidDataException("The image has no PE optional header."); - } - - private uint ReadUInt32(int offset) - { - EnsureImageRange(offset, sizeof(uint)); - return BinaryPrimitives.ReadUInt32LittleEndian(_image.AsSpan(offset, sizeof(uint))); - } - - private ushort ReadUInt16(int offset) - { - EnsureImageRange(offset, sizeof(ushort)); - return BinaryPrimitives.ReadUInt16LittleEndian(_image.AsSpan(offset, sizeof(ushort))); - } - - private ulong ReadUInt64(int offset) - { - EnsureImageRange(offset, sizeof(ulong)); - return BinaryPrimitives.ReadUInt64LittleEndian(_image.AsSpan(offset, sizeof(ulong))); - } - - private void EnsureImageRange(int offset, int length) - { - if (offset < 0 || length < 0 || offset > _image.Length - length) - throw new InvalidDataException("A PE field lies outside the image."); - } - - private static uint CheckedByteCount(uint count, int elementSize) - { - return checked(count * (uint)elementSize); - } - - private static uint GetDirectoryRva(DirectoryEntry directory, string name) - { - if (directory.RelativeVirtualAddress <= 0) - throw new InvalidDataException($"The PE {name} directory has no positive RVA."); - - return (uint)directory.RelativeVirtualAddress; - } - - private static uint GetDirectorySize(DirectoryEntry directory, string name) - { - if (directory.Size <= 0) - throw new InvalidDataException($"The PE {name} directory has no positive size."); - - return (uint)directory.Size; - } + private readonly PEHeaders _headers; + private readonly byte[] _image; + + private PortableExecutableInspector(byte[] image, PEHeaders headers) + { + _image = image; + _headers = headers; + } + + /// The machine architecture declared by the COFF header. + public Machine Machine => _headers.CoffHeader.Machine; + + /// Whether the PE characteristics identify this image as a DLL. + public bool IsDll => _headers.IsDll; + + /// The optional-header format. + public PEMagic Magic => GetRequiredPeHeader().Magic; + + /// Whether the image has a delay-load import directory. + public bool HasDelayImports + { + get + { + DirectoryEntry delayImports = GetRequiredPeHeader().DelayImportTableDirectory; + return delayImports.RelativeVirtualAddress != 0 || delayImports.Size != 0; + } + } + + /// Reads a PE image without loading it into the current process. + public static PortableExecutableInspector Read(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + byte[] image = File.ReadAllBytes(path); + using MemoryStream stream = new(image, false); + using PEReader reader = new(stream, PEStreamOptions.PrefetchEntireImage); + + if (reader.PEHeaders.IsCoffOnly) + { + throw new InvalidDataException($"'{path}' is a COFF object, not a PE image."); + } + + return new PortableExecutableInspector(image, reader.PEHeaders); + } + + /// Returns all named exports and their implementation RVAs. + public IReadOnlyList GetExports() + { + DirectoryEntry directory = GetRequiredPeHeader().ExportTableDirectory; + if (directory.RelativeVirtualAddress == 0 || directory.Size == 0) + { + throw new InvalidDataException("The PE image has no export directory."); + } + + int directoryOffset = MapRva(GetDirectoryRva(directory, "export"), 40u); + uint numberOfFunctions = ReadUInt32(directoryOffset + 20); + uint numberOfNames = ReadUInt32(directoryOffset + 24); + uint functionsRva = ReadUInt32(directoryOffset + 28); + uint namesRva = ReadUInt32(directoryOffset + 32); + uint ordinalsRva = ReadUInt32(directoryOffset + 36); + + int functionsOffset = MapRva(functionsRva, CheckedByteCount(numberOfFunctions, sizeof(uint))); + int namesOffset = MapRva(namesRva, CheckedByteCount(numberOfNames, sizeof(uint))); + int ordinalsOffset = MapRva(ordinalsRva, CheckedByteCount(numberOfNames, sizeof(ushort))); + List exports = new(checked((int) numberOfNames)); + + for (uint index = 0u; index < numberOfNames; index++) + { + uint nameRva = ReadUInt32(namesOffset + checked((int) (index * sizeof(uint)))); + ushort ordinalIndex = ReadUInt16(ordinalsOffset + checked((int) (index * sizeof(ushort)))); + if (ordinalIndex >= numberOfFunctions) + { + throw new InvalidDataException("An export ordinal points outside the export address table."); + } + + uint implementationRva = ReadUInt32(functionsOffset + checked(ordinalIndex * sizeof(uint))); + exports.Add(new PortableExecutableExport(ReadAsciiZ(nameRva), implementationRva)); + } + + return exports; + } + + /// Returns all normal import modules and their named or ordinal imports. + public IReadOnlyList GetImports() + { + PEHeader header = GetRequiredPeHeader(); + DirectoryEntry directory = header.ImportTableDirectory; + if (directory.RelativeVirtualAddress == 0 && directory.Size == 0) + { + return []; + } + + if (directory.RelativeVirtualAddress == 0 || directory.Size == 0) + { + throw new InvalidDataException("The PE import directory has an incomplete RVA/size pair."); + } + + uint directorySize = GetDirectorySize(directory, "import"); + int directoryOffset = MapRva(GetDirectoryRva(directory, "import"), directorySize); + int directoryEnd = checked(directoryOffset + (int) directorySize); + List imports = new(); + + for (int descriptorOffset = directoryOffset; descriptorOffset <= directoryEnd - 20; descriptorOffset += 20) + { + uint originalFirstThunk = ReadUInt32(descriptorOffset); + uint timeDateStamp = ReadUInt32(descriptorOffset + 4); + uint forwarderChain = ReadUInt32(descriptorOffset + 8); + uint nameRva = ReadUInt32(descriptorOffset + 12); + uint firstThunk = ReadUInt32(descriptorOffset + 16); + if (originalFirstThunk == 0 && timeDateStamp == 0 && forwarderChain == 0 && nameRva == 0 && firstThunk == 0) + { + return imports; + } + + if (nameRva == 0) + { + throw new InvalidDataException("An import descriptor has no module name."); + } + + uint lookupTableRva = originalFirstThunk != 0 ? originalFirstThunk : firstThunk; + if (lookupTableRva == 0) + { + throw new InvalidDataException("An import descriptor has no lookup table."); + } + + imports.Add(new PortableExecutableImport(ReadAsciiZ(nameRva), ReadImportSymbols(lookupTableRva))); + } + + throw new InvalidDataException("The PE import directory is missing its null descriptor."); + } + + /// Reads a NUL-terminated ANSI payload from a named export. + public string ReadExportedAsciiZ(string exportName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(exportName); + + IReadOnlyList exports = GetExports(); + for (int index = 0; index < exports.Count; index++) + { + if (string.Equals(exports[index].Name, exportName, StringComparison.Ordinal)) + { + return ReadAsciiZ(exports[index].RelativeVirtualAddress); + } + } + + throw new MissingMethodException($"The PE image does not export '{exportName}'."); + } + + private List ReadImportSymbols(uint lookupTableRva) + { + List symbols = new(); + uint thunkRva = lookupTableRva; + while (true) + { + int thunkOffset = MapRva(thunkRva, sizeof(ulong)); + ulong thunk = ReadUInt64(thunkOffset); + if (thunk == 0) + { + return symbols; + } + + const ulong ordinalMask = 0x8000000000000000UL; + if ((thunk & ordinalMask) != 0) + { + symbols.Add($"#{thunk & 0xFFFFUL}"); + } + else + { + if (thunk > uint.MaxValue) + { + throw new InvalidDataException("A PE32+ import name RVA exceeds 32 bits."); + } + + uint hintNameRva = (uint) thunk; + _ = MapRva(hintNameRva, sizeof(ushort)); + symbols.Add(ReadAsciiZ(checked(hintNameRva + sizeof(ushort)))); + } + + thunkRva = checked(thunkRva + sizeof(ulong)); + } + } + + private string ReadAsciiZ(uint rva) + { + int offset = MapRva(rva, 1u, out int bytesAvailable); + int end = offset; + int maximum = checked(offset + bytesAvailable); + while (end < maximum && _image[end] != 0) + { + end++; + } + + if (end == maximum) + { + throw new InvalidDataException("A PE ASCII string is not NUL-terminated inside its mapped section."); + } + + return Encoding.ASCII.GetString(_image, offset, end - offset); + } + + private int MapRva(uint rva, uint byteCount) + { + return MapRva(rva, byteCount, out _); + } + + private int MapRva(uint rva, uint byteCount, out int bytesAvailable) + { + PEHeader header = GetRequiredPeHeader(); + if (header.SizeOfHeaders < 0) + { + throw new InvalidDataException("The PE image has a negative SizeOfHeaders value."); + } + + uint headerSize = (uint) header.SizeOfHeaders; + if (rva < headerSize) + { + if ((ulong) rva + byteCount > headerSize || (ulong) rva + byteCount > (uint) _image.Length) + { + throw new InvalidDataException("An RVA range extends beyond the PE headers."); + } + + bytesAvailable = checked((int) (headerSize - rva)); + return checked((int) rva); + } + + foreach (SectionHeader section in _headers.SectionHeaders) + { + if (section.VirtualAddress < 0 || section.SizeOfRawData < 0 || section.PointerToRawData < 0) + { + throw new InvalidDataException("The PE image has a negative section field."); + } + + uint sectionRva = (uint) section.VirtualAddress; + uint rawSize = (uint) section.SizeOfRawData; + uint virtualSize = section.VirtualSize < 0 ? 0u : (uint) section.VirtualSize; + uint mappedSize = Math.Max(rawSize, virtualSize); + if ((ulong) rva < sectionRva || rva >= (ulong) sectionRva + mappedSize) + { + continue; + } + + uint delta = checked(rva - sectionRva); + if ((ulong) delta + byteCount > rawSize) + { + throw new InvalidDataException("An RVA range extends past raw bytes in its PE section."); + } + + uint fileOffset = checked((uint) section.PointerToRawData + delta); + if ((ulong) fileOffset + byteCount > (uint) _image.Length) + { + throw new InvalidDataException("An RVA maps past the end of the PE image."); + } + + bytesAvailable = checked((int) (rawSize - delta)); + return checked((int) fileOffset); + } + + throw new InvalidDataException("An RVA does not map to a PE section."); + } + + private PEHeader GetRequiredPeHeader() + { + return _headers.PEHeader ?? throw new InvalidDataException("The image has no PE optional header."); + } + + private uint ReadUInt32(int offset) + { + EnsureImageRange(offset, sizeof(uint)); + return BinaryPrimitives.ReadUInt32LittleEndian(_image.AsSpan(offset, sizeof(uint))); + } + + private ushort ReadUInt16(int offset) + { + EnsureImageRange(offset, sizeof(ushort)); + return BinaryPrimitives.ReadUInt16LittleEndian(_image.AsSpan(offset, sizeof(ushort))); + } + + private ulong ReadUInt64(int offset) + { + EnsureImageRange(offset, sizeof(ulong)); + return BinaryPrimitives.ReadUInt64LittleEndian(_image.AsSpan(offset, sizeof(ulong))); + } + + private void EnsureImageRange(int offset, int length) + { + if (offset < 0 || length < 0 || offset > _image.Length - length) + { + throw new InvalidDataException("A PE field lies outside the image."); + } + } + + private static uint CheckedByteCount(uint count, int elementSize) + { + return checked(count * (uint) elementSize); + } + + private static uint GetDirectoryRva(DirectoryEntry directory, string name) + { + if (directory.RelativeVirtualAddress <= 0) + { + throw new InvalidDataException($"The PE {name} directory has no positive RVA."); + } + + return (uint) directory.RelativeVirtualAddress; + } + + private static uint GetDirectorySize(DirectoryEntry directory, string name) + { + if (directory.Size <= 0) + { + throw new InvalidDataException($"The PE {name} directory has no positive size."); + } + + return (uint) directory.Size; + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessResult.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessResult.cs index e068a65b..4293211a 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessResult.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessResult.cs @@ -6,10 +6,10 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// Everything the process wrote to standard error, newline-joined. internal readonly record struct ProcessResult(int ExitCode, string StandardOutput, string StandardError) { - /// Both streams, for an exception or assertion message: nothing to dig through separately. - public string CombinedOutput => StandardOutput.Length == 0 - ? StandardError - : StandardError.Length == 0 - ? StandardOutput - : StandardOutput + Environment.NewLine + StandardError; + /// Both streams, for an exception or assertion message: nothing to dig through separately. + public string CombinedOutput => StandardOutput.Length == 0 + ? StandardError + : StandardError.Length == 0 + ? StandardOutput + : StandardOutput + Environment.NewLine + StandardError; } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs index e5304d62..7b013832 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs @@ -10,60 +10,66 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// internal static class ProcessRunner { - /// Starts , waits up to , and returns its output. - /// The process did not exit in time; it is killed (with its child tree) first. - public static async Task RunAsync(string fileName, string arguments, string workingDirectory, - TimeSpan timeout) - { - ProcessStartInfo startInfo = new(fileName, arguments) - { - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + /// Starts , waits up to , and returns its output. + /// The process did not exit in time; it is killed (with its child tree) first. + public static async Task RunAsync(string fileName, string arguments, string workingDirectory, + TimeSpan timeout) + { + ProcessStartInfo startInfo = new(fileName, arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; - using Process process = new() { StartInfo = startInfo }; - StringBuilder standardOutput = new(); - StringBuilder standardError = new(); - process.OutputDataReceived += (_, e) => - { - if (e.Data is not null) standardOutput.AppendLine(e.Data); - }; - process.ErrorDataReceived += (_, e) => - { - if (e.Data is not null) standardError.AppendLine(e.Data); - }; + using Process process = new() { StartInfo = startInfo }; + StringBuilder standardOutput = new(); + StringBuilder standardError = new(); + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + standardOutput.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + standardError.AppendLine(e.Data); + } + }; - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); - using CancellationTokenSource cancellation = new(timeout); - try - { - await process.WaitForExitAsync(cancellation.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellation.IsCancellationRequested) - { - TryKill(process); - throw new TimeoutException( - $"'{fileName} {arguments}' in '{workingDirectory}' did not exit within {timeout}. Output so far:{Environment.NewLine}{standardOutput}{Environment.NewLine}{standardError}"); - } + using CancellationTokenSource cancellation = new(timeout); + try + { + await process.WaitForExitAsync(cancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + TryKill(process); + throw new TimeoutException( + $"'{fileName} {arguments}' in '{workingDirectory}' did not exit within {timeout}. Output so far:{Environment.NewLine}{standardOutput}{Environment.NewLine}{standardError}"); + } - return new ProcessResult(process.ExitCode, standardOutput.ToString(), standardError.ToString()); - } + return new ProcessResult(process.ExitCode, standardOutput.ToString(), standardError.ToString()); + } - private static void TryKill(Process process) - { - try - { - process.Kill(true); - } - catch (InvalidOperationException) - { - // Already exited between the timeout firing and the kill: nothing left to do. - } - } + private static void TryKill(Process process) + { + try + { + process.Kill(true); + } + catch (InvalidOperationException) + { + // Already exited between the timeout firing and the kill: nothing left to do. + } + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs index 9e0798a7..fbb0c418 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs @@ -3,26 +3,33 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// Locates files of the repository from the test output directory, for packing src/CheatEngine.SDK. internal static class RepositoryLayout { - private const string SolutionFileName = "CheatEngine.SDK.slnx"; + private const string SolutionFileName = "CheatEngine.SDK.slnx"; - /// The directory that contains CheatEngine.SDK.slnx, found by walking up from the test binaries. - public static string Root { get; } = FindRoot(); + /// The directory that contains CheatEngine.SDK.slnx, found by walking up from the test binaries. + public static string Root + { + get; + } = FindRoot(); - /// Absolute path of a repository-relative path written with forward slashes. - public static string PathOf(string relativePath) - { - return Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); - } + /// Absolute path of a repository-relative path written with forward slashes. + public static string PathOf(string relativePath) + { + return Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } - private static string FindRoot() - { - for (DirectoryInfo? directory = new(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) - if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) - return directory.FullName; + private static string FindRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) + { + return directory.FullName; + } + } - throw new InvalidOperationException( - $"'{SolutionFileName}' was not found above '{AppContext.BaseDirectory}': the tests expect to run from the repository's artifacts directory."); - } + throw new InvalidOperationException( + $"'{SolutionFileName}' was not found above '{AppContext.BaseDirectory}': the tests expect to run from the repository's artifacts directory."); + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs index 01d92dac..98a911a3 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs @@ -11,734 +11,766 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// internal sealed class ThrowawayConsumer { - private const string PluginSource = """ - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Hosting.Plugin; - - namespace ThrowawayPlugin; - - [CheatEnginePlugin("Throwaway consumer plugin")] - public sealed class Plugin : CheatEnginePlugin - { - protected override void OnEnable() - { - } - - protected override void OnDisable() - { - } - } - """; - - private const string LuaFunctionSource = """ - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Registration; - using CheatEngine.SDK.Lua.State; - - namespace ThrowawayPlugin; - - internal static partial class Functions - { - [LuaFunction("throwaway_ping")] - public static long Ping() => 1; - - // Compile the generated lease surface from the freshly packed package. - // This method is intentionally not a lifecycle recipe and is never invoked here. - public static void CompileLeaseConsumer(LuaState state) - { - LuaRegistrationResult registration = TryRegisterLuaFunctions(state, - LuaRegistrationCollisionPolicy.RejectExisting); - registration.Lease?.Dispose(); - } - } - """; - - // This is a standalone native-Lua proof, not a Cheat Engine host integration. It binds only the fixture-provided - // Lua 5.3 module, creates one state it owns and supplies that state through a short-lived SDK runtime binding so - // generated [LuaGlobal] bodies and ownership-aware generated [LuaFunction] registration both run for real. - private const string RuntimeProgramSource = """" - using System; - using System.Runtime.CompilerServices; - using System.Runtime.InteropServices; - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Calls; - using CheatEngine.SDK.Lua.Interop.Api; - using CheatEngine.SDK.Lua.Interop.Types; - using CheatEngine.SDK.Lua.Marshalling; - using CheatEngine.SDK.Lua.Registration; - using CheatEngine.SDK.Lua.Runtime; - using CheatEngine.SDK.Lua.State; - - namespace ThrowawayRuntime; - - internal readonly struct FixtureToken - { - public FixtureToken(long value) - { - Value = value; - } - - public long Value { get; } - } - - internal readonly struct FixtureTokenMarshaller : ILuaMarshaller> - { - public static void Push(LuaState state, FixtureToken value) - { - state.PushInteger(value.Value); - } - - public static bool TryRead(LuaState state, int index, out FixtureToken value) - { - if (state.TryReadInteger(index, out var number)) - { - value = new FixtureToken(number); - return true; - } - - value = default; - return false; - } - } - - internal static partial class GeneratedGlobals - { - [LuaGlobal("sdk022_increment")] - public static partial LuaOperationStatus TryIncrement( - LuaState state, - [LuaMarshaller(typeof(FixtureTokenMarshaller))] FixtureToken value, - [LuaMarshaller(typeof(FixtureTokenMarshaller))] out FixtureToken result); - - [LuaGlobal("sdk022_fail")] - public static partial LuaOperationStatus TryFail(LuaState state); - } - - internal static partial class GeneratedFunctions - { - [LuaFunction("sdk022_callback")] - [return: LuaMarshaller(typeof(FixtureTokenMarshaller))] - public static FixtureToken Increment( - [LuaMarshaller(typeof(FixtureTokenMarshaller))] FixtureToken value) - { - return new FixtureToken(value.Value + 1); - } - } - - internal static unsafe class Program - { - private static nint s_state; - - public static int Main(string[] args) - { - if (args.Length != 1) - { - Console.Error.WriteLine("Expected exactly one Lua 5.3 DLL path."); - return 64; - } - - try - { - Run(args[0]); - return 0; - } - catch (Exception exception) - { - Console.Error.WriteLine(exception); - return 1; - } - } - - private static void Run(string luaLibraryPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(luaLibraryPath); - - // LuaApi retains raw function pointers for the process lifetime, so this loaded fixture module - // intentionally remains loaded until process exit. - LuaApi.Initialize(NativeLibrary.Load(luaLibraryPath)); - lua_State* statePointer = LuaApi.luaL_newstate(); - if (statePointer is null) - throw new InvalidOperationException("The Lua fixture could not create a state."); - - try - { - LuaApi.luaL_openlibs(statePointer); - LuaState state = new((nint)statePointer); - s_state = state.Handle; - delegate* unmanaged[Stdcall] stateProvider = &ProvideState; - LuaHostBinding binding = new((nint)stateProvider, 0, - Environment.CurrentManagedThreadId); - LuaRuntime.Attach(in binding); - try - { - VerifyGeneratedBindings(state); - } - finally - { - LuaRuntime.Detach(); - s_state = 0; - } - } - finally - { - LuaApi.lua_close(statePointer); - } - } - - private static void VerifyGeneratedBindings(LuaState state) - { - Execute(state, """ - function sdk022_increment(value) - return value + 1 - end - function sdk022_fail() - error("sdk-022 fixture failure") - end - sdk022_callback = function(value) - return -1 - end - """u8); - - LuaOperationStatus global = GeneratedGlobals.TryIncrement(state, - new FixtureToken(41), out var globalResult); - if (!global.IsSuccess || globalResult.Value != 42) - throw new InvalidOperationException("The generated Lua global did not marshal its generic token."); - Console.WriteLine("SDK-022-RUNTIME-GLOBAL-MARSHALLER"); - - LuaRegistrationResult collision = GeneratedFunctions.TryRegisterLuaFunctions(state, - LuaRegistrationCollisionPolicy.RejectExisting); - if (collision.Kind != LuaRegistrationResultKind.Collision || collision.Lease is not null) - throw new InvalidOperationException("Generated registration did not reject the existing callback global."); - - LuaRegistrationResult registration = GeneratedFunctions.TryRegisterLuaFunctions(state, - LuaRegistrationCollisionPolicy.ReplaceExisting); - LuaRegistrationLease lease = registration.Lease - ?? throw new InvalidOperationException("Generated registration did not return its ownership lease."); - if (!registration.IsSuccess) - throw new InvalidOperationException("Generated registration did not replace the callback global."); - - if (ExecuteForInteger(state, "return sdk022_callback(41)"u8) != 42) - throw new InvalidOperationException("The generated Lua callback did not marshal its generic token."); - Console.WriteLine("SDK-022-RUNTIME-CALLBACK-MARSHALLER"); - - LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(state); - if (released.Kind != LuaRegistrationReleaseKind.Released || released.RestoredCount != 1 || - ExecuteForInteger(state, "return sdk022_callback(41)"u8) != -1) - throw new InvalidOperationException("The generated registration lease did not restore the prior callback."); - Console.WriteLine("SDK-022-RUNTIME-COLLISION-LEASE"); - - LuaOperationStatus failure = GeneratedGlobals.TryFail(state); - if (failure.Kind != LuaOperationStatusKind.LuaFailure || failure.LuaStatus != LuaStatus.RuntimeError) - throw new InvalidOperationException("The generated Lua global did not preserve the runtime-error status."); - Console.WriteLine("SDK-022-RUNTIME-LUA-RUNTIME-ERROR"); - Console.WriteLine("SDK-022-RUNTIME-PROOF"); - } - - private static void Execute(LuaState state, ReadOnlySpan source) - { - using LuaFrame frame = new(state); - LuaStatus status = state.TryExecute(source, 0, "=sdk022-runtime"u8); - if (!status.IsOk) - throw new InvalidOperationException("The Lua fixture setup failed with " + status + "."); - } - - private static long ExecuteForInteger(LuaState state, ReadOnlySpan source) - { - using LuaFrame frame = new(state); - LuaStatus status = state.TryExecute(source, 1, "=sdk022-runtime"u8); - if (!status.IsOk || !state.TryReadInteger(-1, out var value)) - throw new InvalidOperationException("The Lua fixture did not return the expected integer."); - - return value; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static void* ProvideState() - { - return (void*)s_state; - } - } - """"; - - private const string AotProgramSource = """ - using System; - using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Calls; - using CheatEngine.SDK.Lua.State; - - namespace ThrowawayAot; - - internal static partial class GeneratedAotBinding - { - [LuaGlobal("sdk022_aot_probe")] - public static partial LuaOperationStatus TryProbe(LuaState state); - } - - internal static class Program - { - public static int Main(string[] args) - { - // Native AOT keeps this generated body because the non-default command-line - // path references it, while the normal no-host test run never invokes it. - if (args.Length != 0) - _ = GeneratedAotBinding.TryProbe(default); - - Console.WriteLine("SDK-022-AOT-STANDALONE"); - Console.WriteLine("SDK-022-AOT-NO-CE-HOST"); - return 0; - } - } - """; - - private const string DuplicateLuaFunctionProgramSource = """ - using CheatEngine.SDK.Annotations.Lua; - - namespace ThrowawayDuplicate; - - internal static partial class DuplicateFunctions - { - [LuaFunction("sdk022_duplicate")] - public static int First() => 1; - - [LuaFunction("sdk022_duplicate")] - public static int Second() => 2; - } - - internal static class Program - { - public static int Main() => 0; - } - """; - - private const string LegacyAobSource = """ - using CheatEngine.SDK.Engine.Objects; - using CheatEngine.SDK.Engine.Scanning.Aob; - - namespace ThrowawayPlugin; - - internal static class LegacyAobConsumer - { - internal static void CompileOnly() - { - if (AobScanner.TryScan("90", out Owned? defaultResults)) - defaultResults.Dispose(); - - if (AobScanner.TryScan("90", AobScanOptions.Default, - out Owned? configuredResults)) - configuredResults.Dispose(); - } - } - """; - - private const string TargetBoundAllocationSource = """ - using CheatEngine.SDK.Engine.Allocation; - using CheatEngine.SDK.Engine.Errors; - using CheatEngine.SDK.Engine.Targets; - using CheatEngine.SDK.Engine.Values; - using CheatEngine.SDK.Lua.Calls; - - namespace ThrowawayPlugin; - - internal sealed class TargetBoundAllocationBackend : ITargetBoundMemoryAllocationOperations - { - public TargetMemoryAllocationOutcome AllocateBoundWithOutcome( - TargetAllocationRequest request, out TargetProcessIncarnation incarnation, - out TargetSelectionObservation observation) - { - observation = TargetSelection.ObserveCurrent(); - incarnation = observation.Incarnation.GetValueOrDefault(); - return TargetMemoryAllocationOutcome.Failed(observation.IsQualified - ? TargetMemoryOperationOutcome.Failed( - EngineFailureKind.ExpectedOperationFailure) - : CreateObservationFailure(observation)); - } - - public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, - TargetAllocationSize size, out TargetIdentityCheck targetCheck) - { - var outcome = DeallocateBoundWithOutcome(expected, address, size, out targetCheck); - return targetCheck.IsCurrent && outcome.IsSuccess; - } - - public TargetMemoryOperationOutcome DeallocateBoundWithOutcome( - TargetProcessIncarnation expected, Address address, TargetAllocationSize size, - out TargetIdentityCheck targetCheck) - { - targetCheck = TargetSelection.ValidateCurrent(expected); - return targetCheck.IsCurrent - ? TargetMemoryOperationOutcome.Succeeded() - : CreateTargetCheckFailure(targetCheck); - } - - private static TargetMemoryOperationOutcome CreateObservationFailure( - TargetSelectionObservation observation) - { - return observation.Status == TargetSelectionObservationStatus.LuaFailure - ? TargetMemoryOperationOutcome.Failed( - EngineFailureKind.ProtectedLuaFailure, LuaStatus.RuntimeError) - : TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable); - } - - private static TargetMemoryOperationOutcome CreateTargetCheckFailure( - TargetIdentityCheck targetCheck) - { - return targetCheck.Kind is TargetIdentityCheckKind.TargetChanged - or TargetIdentityCheckKind.ProcessReused - ? TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityMismatch) - : TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable); - } - } - """; - - private const string RecordAndSymbolContractSource = """ - using CheatEngine.SDK.Engine.AddressList; - using CheatEngine.SDK.Engine.Inspection; - using CheatEngine.SDK.Engine.Values; - - namespace ThrowawayPlugin; - - internal static class RecordAndSymbolContractConsumer - { - internal static void CompileOnly() - { - MemoryRecordId record = new(17); - _ = AddressListMutations.Delete(record); - _ = AddressListMutations.SetParent(record, parentId: null); - SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned( - new SymbolName("throwaway_symbol"), new Address(0x140001000)); - if (acquired.Lease is SymbolRegistrationLease lease) - _ = lease.Release(); - } - } - """; - - private const string ValueScanSource = """ - using System; - using CheatEngine.SDK.Engine.Scanning.Values; - - namespace ThrowawayPlugin; - - internal static class ValueScanConsumer - { - internal static MemoryScanCreationStatus Create(out MemoryScanSession? session) - { - return MemoryScanSessions.TryCreateDetailed(out session); - } - - internal static MemoryScanMaterializationStatus Copy(MemoryScanSession session, - Span destination, out ulong totalCount, out int written) - { - return session.TryCopyResults(destination, out totalCount, out written); - } - } - """; - - private ThrowawayConsumer(string directory, string projectPath, string assemblyPath) - { - Directory = directory; - ProjectPath = projectPath; - AssemblyPath = assemblyPath; - } - - /// The consumer project's own directory. - public string Directory { get; } - - /// Full path of the generated .csproj. - public string ProjectPath { get; } - - /// - /// Where a Release build places the compiled plugin assembly. Only valid after has - /// succeeded. - /// - public string AssemblyPath { get; } - - /// The native protection bridge copied beside the built plugin. - public string NativeBridgePath => - Path.Combine(Path.GetDirectoryName(AssemblyPath)!, "cheatengine-sdk-lua-bridge.dll"); - - /// - /// Scaffolds a project named under : an - /// net10.0 class library with one PackageReference to CheatEngine.SDK. Its generated - /// NuGet.Config maps that exact package identity to while retaining - /// nuget.org for other package identities. The project has one minimal but valid plugin class and whatever - /// adds to its single PropertyGroup. When - /// is , - /// the project also declares one valid [LuaFunction] export. When - /// is , it compiles both historical AobScanner.TryScan overloads against the packed - /// SDK. When is , it compiles an - /// independent implementation of the target-bound allocation backend seam against that package. When - /// is , it compiles the - /// SDK-021 typed mutation and coordinated-symbol surfaces without accessing raw CE handles. - /// When is , it compiles the value-scan - /// factory and bounded-copy APIs against the same packed SDK. - /// defaults to x64, but may be to prove the package - /// behavior when the consumer does not declare it. - /// - public static ThrowawayConsumer Create(string parentDirectory, string name, string cheatEngineSdkVersion, - string localFeedDirectory, string extraProperties = "", string? platformTarget = "x64", - bool includeLuaFunction = false, bool includeLegacyAobConsumer = false, - bool includeTargetBoundAllocationConsumer = false, bool includeRecordAndSymbolContract = false, - bool includeValueScanConsumer = false) - { - var directory = Path.Combine(parentDirectory, name); - System.IO.Directory.CreateDirectory(directory); - - var projectPath = Path.Combine(directory, $"{name}.csproj"); - var platformTargetProperty = platformTarget is null - ? "" - : $" {platformTarget}\n"; - File.WriteAllText(projectPath, $""" - - - net10.0 - - {platformTargetProperty} enable - {extraProperties} - - - - - """); - - File.WriteAllText(Path.Combine(directory, "Plugin.cs"), PluginSource); - if (includeLuaFunction) - File.WriteAllText(Path.Combine(directory, "Functions.cs"), LuaFunctionSource); - if (includeLegacyAobConsumer) - File.WriteAllText(Path.Combine(directory, "LegacyAobConsumer.cs"), LegacyAobSource); - if (includeTargetBoundAllocationConsumer) - File.WriteAllText(Path.Combine(directory, "TargetBoundAllocationBackend.cs"), TargetBoundAllocationSource); - if (includeRecordAndSymbolContract) - File.WriteAllText(Path.Combine(directory, "RecordAndSymbolContractConsumer.cs"), RecordAndSymbolContractSource); - if (includeValueScanConsumer) - File.WriteAllText(Path.Combine(directory, "ValueScanConsumer.cs"), ValueScanSource); - WriteNuGetConfig(directory, localFeedDirectory, UmbrellaPackage.Id); - - var assemblyPath = Path.Combine(directory, "bin", "Release", "net10.0", $"{name}.dll"); - return new ThrowawayConsumer(directory, projectPath, assemblyPath); - } - - /// - /// Scaffolds a package-only executable that runs generated Lua bindings against exactly one fixture-supplied - /// Lua 5.3 DLL. is required so the fixture, rather than this scaffold, - /// explicitly opts into the unsafe compilation generated [LuaFunction] thunks require. - /// - public static ThrowawayConsumer CreateRuntimeExecutable(string parentDirectory, string name, - string cheatEngineSdkVersion, string localFeedDirectory, string extraProperties) - { - return CreateExecutable(parentDirectory, name, cheatEngineSdkVersion, localFeedDirectory, extraProperties, - "Program.cs", RuntimeProgramSource); - } - - /// - /// Scaffolds the intentional CESDK2005 consumer: two otherwise valid generated Lua functions share one Lua - /// global name. must explicitly enable unsafe code so CESDK2001 does not - /// mask that duplicate-name diagnostic. - /// - public static ThrowawayConsumer CreateInvalidDuplicateLuaFunctionConsumer(string parentDirectory, string name, - string cheatEngineSdkVersion, string localFeedDirectory, string extraProperties) - { - return CreateExecutable(parentDirectory, name, cheatEngineSdkVersion, localFeedDirectory, extraProperties, - "Program.cs", DuplicateLuaFunctionProgramSource); - } - - /// - /// Scaffolds a package-only executable whose source includes a generated Lua binding but whose program neither - /// loads a native Lua module nor activates a Cheat Engine host. The fixture supplies trim/AOT/RID properties. - /// - public static ThrowawayConsumer CreateAotExecutable(string parentDirectory, string name, string cheatEngineSdkVersion, - string localFeedDirectory, string extraProperties) - { - return CreateExecutable(parentDirectory, name, cheatEngineSdkVersion, localFeedDirectory, extraProperties, - "Program.cs", AotProgramSource); - } - - /// - /// Scaffolds a valid plugin that reaches the umbrella package only through a packed relay package. The source - /// intentionally remains the same as a direct consumer: package compile references must still flow, while the - /// package's direct-only build assets must not. - /// - public static ThrowawayConsumer CreateIndirect(string parentDirectory, string carrierPackageId, - string carrierPackageVersion, string localFeedDirectory) - { - const string consumerName = "IndirectConsumer"; - var directory = Path.Combine(parentDirectory, consumerName); - System.IO.Directory.CreateDirectory(directory); - - var projectPath = Path.Combine(directory, $"{consumerName}.csproj"); - File.WriteAllText(projectPath, $""" - - - net10.0 - x64 - enable - - - - - - """); - File.WriteAllText(Path.Combine(directory, "Plugin.cs"), PluginSource); - WriteNuGetConfig(directory, localFeedDirectory, carrierPackageId, UmbrellaPackage.Id); - - var assemblyPath = Path.Combine(directory, "bin", "Release", "net10.0", $"{consumerName}.dll"); - return new ThrowawayConsumer(directory, projectPath, assemblyPath); - } - - private static ThrowawayConsumer CreateExecutable(string parentDirectory, string name, string cheatEngineSdkVersion, - string localFeedDirectory, string extraProperties, string sourceFileName, string source) - { - var directory = Path.Combine(parentDirectory, name); - System.IO.Directory.CreateDirectory(directory); - - var projectPath = Path.Combine(directory, $"{name}.csproj"); - File.WriteAllText(projectPath, $""" - - - net10.0 - Exe - enable - {extraProperties} - - - - - """); - File.WriteAllText(Path.Combine(directory, sourceFileName), source); - WriteNuGetConfig(directory, localFeedDirectory, UmbrellaPackage.Id); - - var assemblyPath = Path.Combine(directory, "bin", "Release", "net10.0", $"{name}.dll"); - return new ThrowawayConsumer(directory, projectPath, assemblyPath); - } - - // isolates restore from arbitrary user/machine configuration. The exact CheatEngine.SDK mapping keeps - // package restore on this fixture's freshly packed feed; nuget.org remains only for external SDK dependencies. - private static void WriteNuGetConfig(string directory, string localFeedDirectory, params string[] localPackageIds) - { - StringBuilder packageMappings = new(); - foreach (var packageId in localPackageIds) - { - packageMappings.Append(" \n"); - } - - File.WriteAllText(Path.Combine(directory, "NuGet.Config"), $""" - - - - - - - - - - - {packageMappings} - - - - - - """); - } - - /// - /// Restores the package identities mapped to the local feed (see ); no other consumer step - /// restores again. Nuget.org remains available only for package identities not mapped to that feed. - /// is passed as --packages so extraction lands in a directory the - /// caller controls, never the machine-wide global-packages folder: NuGet treats a given package id+version as - /// immutable once extracted there, so a stale extraction left by an earlier run (this fixture, a developer's own - /// restore, or another parallel build) would otherwise be reused silently even though this run's - /// dotnet pack produced different content under the same MinVer-derived version (see - /// PackagedUmbrellaFixture's own remarks). - /// - public Task RestoreAsync(TimeSpan timeout, string packagesDirectory) - { - return ProcessRunner.RunAsync( - "dotnet", - $"restore \"{ProjectPath}\" --configfile \"{Path.Combine(Directory, "NuGet.Config")}\" --packages \"{packagesDirectory}\" --no-http-cache --force-evaluate --nologo", - Directory, - timeout); - } - - /// A real Release build: what produces and runs the packaged generators/analyzers. - public Task BuildAsync(TimeSpan timeout) - { - return ProcessRunner.RunAsync("dotnet", $"build \"{ProjectPath}\" -c Release --no-restore --nologo", Directory, - timeout); - } - - /// Runs the built executable with its required one argument: the test fixture's Lua 5.3 DLL path. - public Task RunAsync(string luaLibraryPath, TimeSpan timeout) - { - ArgumentException.ThrowIfNullOrWhiteSpace(luaLibraryPath); - return ProcessRunner.RunAsync("dotnet", $"\"{AssemblyPath}\" \"{luaLibraryPath}\"", Directory, timeout); - } - - /// Cleans the consumer output without restoring, so the following build validates normal SDK copy bookkeeping. - public Task CleanAsync(TimeSpan timeout) - { - return ProcessRunner.RunAsync("dotnet", $"clean \"{ProjectPath}\" -c Release --nologo", Directory, - timeout); - } - - /// Publishes the consumer into without restoring again. - public Task PublishAsync(TimeSpan timeout, string outputDirectory) - { - return ProcessRunner.RunAsync("dotnet", - $"publish \"{ProjectPath}\" -c Release --no-restore --nologo -o \"{outputDirectory}\"", Directory, - timeout); - } - - /// Runs the native executable emitted by a publish into . - public Task RunPublishedAsync(string outputDirectory, TimeSpan timeout) - { - ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); - var executableName = Path.GetFileNameWithoutExtension(ProjectPath) + ".exe"; - var executablePath = Path.Combine(outputDirectory, executableName); - return ProcessRunner.RunAsync(executablePath, "", outputDirectory, timeout); - } - - /// - /// Evaluates (does not build: no -target, per the MSBuild command-line reference) the named MSBuild - /// properties after restore, exactly as the packaged build/CheatEngine.SDK.props and this project's own - /// PropertyGroup leave them. - /// - public async Task> GetPropertiesAsync(TimeSpan timeout, - params string[] propertyNames) - { - var switches = string.Join(' ', propertyNames.Select(static p => $"-getProperty:{p}")); - var result = await ProcessRunner.RunAsync("dotnet", - $"build \"{ProjectPath}\" -c Release --no-restore --nologo {switches}", Directory, timeout) - .ConfigureAwait(false); - if (result.ExitCode != 0) - throw new InvalidOperationException( - $"'dotnet build -getProperty' failed for '{ProjectPath}' (exit {result.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{result.CombinedOutput}"); - - Dictionary values = new(StringComparer.Ordinal); - - // MSBuild's own documented split: "-getProperty to request a single property" emits a bare string; - // several properties (this project always requests at least one, so >= 2 here) emit one JSON object. - if (propertyNames.Length == 1) - { - values[propertyNames[0]] = result.StandardOutput.Trim(); - return values; - } - - var jsonStart = result.StandardOutput.AsSpan().IndexOf('{'); - if (jsonStart < 0) - throw new InvalidOperationException( - $"'dotnet build -getProperty' for '{ProjectPath}' produced no JSON on standard output:{Environment.NewLine}{result.CombinedOutput}"); - - using var document = JsonDocument.Parse(result.StandardOutput[jsonStart..]); - var properties = document.RootElement.GetProperty("Properties"); - foreach (var name in propertyNames) - values[name] = properties.TryGetProperty(name, out var value) - ? value.GetString() ?? string.Empty - : string.Empty; - - return values; - } + private const string PluginSource = """ + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + + namespace ThrowawayPlugin; + + [CheatEnginePlugin("Throwaway consumer plugin")] + public sealed class Plugin : CheatEnginePlugin + { + protected override void OnEnable() + { + } + + protected override void OnDisable() + { + } + } + """; + + private const string LuaFunctionSource = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Registration; + using CheatEngine.SDK.Lua.State; + + namespace ThrowawayPlugin; + + internal static partial class Functions + { + [LuaFunction("throwaway_ping")] + public static long Ping() => 1; + + // Compile the generated lease surface from the freshly packed package. + // This method is intentionally not a lifecycle recipe and is never invoked here. + public static void CompileLeaseConsumer(LuaState state) + { + LuaRegistrationResult registration = TryRegisterLuaFunctions(state, + LuaRegistrationCollisionPolicy.RejectExisting); + registration.Lease?.Dispose(); + } + } + """; + + // This is a standalone native-Lua proof, not a Cheat Engine host integration. It binds only the fixture-provided + // Lua 5.3 module, creates one state it owns and supplies that state through a short-lived SDK runtime binding so + // generated [LuaGlobal] bodies and ownership-aware generated [LuaFunction] registration both run for real. + private const string RuntimeProgramSource = """" + using System; + using System.Runtime.CompilerServices; + using System.Runtime.InteropServices; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.Interop.Api; + using CheatEngine.SDK.Lua.Interop.Types; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.Registration; + using CheatEngine.SDK.Lua.Runtime; + using CheatEngine.SDK.Lua.State; + + namespace ThrowawayRuntime; + + internal readonly struct FixtureToken + { + public FixtureToken(long value) + { + Value = value; + } + + public long Value { get; } + } + + internal readonly struct FixtureTokenMarshaller : ILuaMarshaller> + { + public static void Push(LuaState state, FixtureToken value) + { + state.PushInteger(value.Value); + } + + public static bool TryRead(LuaState state, int index, out FixtureToken value) + { + if (state.TryReadInteger(index, out var number)) + { + value = new FixtureToken(number); + return true; + } + + value = default; + return false; + } + } + + internal static partial class GeneratedGlobals + { + [LuaGlobal("sdk022_increment")] + public static partial LuaOperationStatus TryIncrement( + LuaState state, + [LuaMarshaller(typeof(FixtureTokenMarshaller))] FixtureToken value, + [LuaMarshaller(typeof(FixtureTokenMarshaller))] out FixtureToken result); + + [LuaGlobal("sdk022_fail")] + public static partial LuaOperationStatus TryFail(LuaState state); + } + + internal static partial class GeneratedFunctions + { + [LuaFunction("sdk022_callback")] + [return: LuaMarshaller(typeof(FixtureTokenMarshaller))] + public static FixtureToken Increment( + [LuaMarshaller(typeof(FixtureTokenMarshaller))] FixtureToken value) + { + return new FixtureToken(value.Value + 1); + } + } + + internal static unsafe class Program + { + private static nint s_state; + + public static int Main(string[] args) + { + if (args.Length != 1) + { + Console.Error.WriteLine("Expected exactly one Lua 5.3 DLL path."); + return 64; + } + + try + { + Run(args[0]); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } + + private static void Run(string luaLibraryPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(luaLibraryPath); + + // LuaApi retains raw function pointers for the process lifetime, so this loaded fixture module + // intentionally remains loaded until process exit. + LuaApi.Initialize(NativeLibrary.Load(luaLibraryPath)); + lua_State* statePointer = LuaApi.luaL_newstate(); + if (statePointer is null) + throw new InvalidOperationException("The Lua fixture could not create a state."); + + try + { + LuaApi.luaL_openlibs(statePointer); + LuaState state = new((nint)statePointer); + s_state = state.Handle; + delegate* unmanaged[Stdcall] stateProvider = &ProvideState; + LuaHostBinding binding = new((nint)stateProvider, 0, + Environment.CurrentManagedThreadId); + LuaRuntime.Attach(in binding); + try + { + VerifyGeneratedBindings(state); + } + finally + { + LuaRuntime.Detach(); + s_state = 0; + } + } + finally + { + LuaApi.lua_close(statePointer); + } + } + + private static void VerifyGeneratedBindings(LuaState state) + { + Execute(state, """ + function sdk022_increment(value) + return value + 1 + end + function sdk022_fail() + error("sdk-022 fixture failure") + end + sdk022_callback = function(value) + return -1 + end + """u8); + + LuaOperationStatus global = GeneratedGlobals.TryIncrement(state, + new FixtureToken(41), out var globalResult); + if (!global.IsSuccess || globalResult.Value != 42) + throw new InvalidOperationException("The generated Lua global did not marshal its generic token."); + Console.WriteLine("SDK-022-RUNTIME-GLOBAL-MARSHALLER"); + + LuaRegistrationResult collision = GeneratedFunctions.TryRegisterLuaFunctions(state, + LuaRegistrationCollisionPolicy.RejectExisting); + if (collision.Kind != LuaRegistrationResultKind.Collision || collision.Lease is not null) + throw new InvalidOperationException("Generated registration did not reject the existing callback global."); + + LuaRegistrationResult registration = GeneratedFunctions.TryRegisterLuaFunctions(state, + LuaRegistrationCollisionPolicy.ReplaceExisting); + LuaRegistrationLease lease = registration.Lease + ?? throw new InvalidOperationException("Generated registration did not return its ownership lease."); + if (!registration.IsSuccess) + throw new InvalidOperationException("Generated registration did not replace the callback global."); + + if (ExecuteForInteger(state, "return sdk022_callback(41)"u8) != 42) + throw new InvalidOperationException("The generated Lua callback did not marshal its generic token."); + Console.WriteLine("SDK-022-RUNTIME-CALLBACK-MARSHALLER"); + + LuaRegistrationReleaseOutcome released = lease.ReleaseWithOutcome(state); + if (released.Kind != LuaRegistrationReleaseKind.Released || released.RestoredCount != 1 || + ExecuteForInteger(state, "return sdk022_callback(41)"u8) != -1) + throw new InvalidOperationException("The generated registration lease did not restore the prior callback."); + Console.WriteLine("SDK-022-RUNTIME-COLLISION-LEASE"); + + LuaOperationStatus failure = GeneratedGlobals.TryFail(state); + if (failure.Kind != LuaOperationStatusKind.LuaFailure || failure.LuaStatus != LuaStatus.RuntimeError) + throw new InvalidOperationException("The generated Lua global did not preserve the runtime-error status."); + Console.WriteLine("SDK-022-RUNTIME-LUA-RUNTIME-ERROR"); + Console.WriteLine("SDK-022-RUNTIME-PROOF"); + } + + private static void Execute(LuaState state, ReadOnlySpan source) + { + using LuaFrame frame = new(state); + LuaStatus status = state.TryExecute(source, 0, "=sdk022-runtime"u8); + if (!status.IsOk) + throw new InvalidOperationException("The Lua fixture setup failed with " + status + "."); + } + + private static long ExecuteForInteger(LuaState state, ReadOnlySpan source) + { + using LuaFrame frame = new(state); + LuaStatus status = state.TryExecute(source, 1, "=sdk022-runtime"u8); + if (!status.IsOk || !state.TryReadInteger(-1, out var value)) + throw new InvalidOperationException("The Lua fixture did not return the expected integer."); + + return value; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* ProvideState() + { + return (void*)s_state; + } + } + """"; + + private const string AotProgramSource = """ + using System; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.State; + + namespace ThrowawayAot; + + internal static partial class GeneratedAotBinding + { + [LuaGlobal("sdk022_aot_probe")] + public static partial LuaOperationStatus TryProbe(LuaState state); + } + + internal static class Program + { + public static int Main(string[] args) + { + // Native AOT keeps this generated body because the non-default command-line + // path references it, while the normal no-host test run never invokes it. + if (args.Length != 0) + _ = GeneratedAotBinding.TryProbe(default); + + Console.WriteLine("SDK-022-AOT-STANDALONE"); + Console.WriteLine("SDK-022-AOT-NO-CE-HOST"); + return 0; + } + } + """; + + private const string DuplicateLuaFunctionProgramSource = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace ThrowawayDuplicate; + + internal static partial class DuplicateFunctions + { + [LuaFunction("sdk022_duplicate")] + public static int First() => 1; + + [LuaFunction("sdk022_duplicate")] + public static int Second() => 2; + } + + internal static class Program + { + public static int Main() => 0; + } + """; + + private const string LegacyAobSource = """ + using CheatEngine.SDK.Engine.Objects; + using CheatEngine.SDK.Engine.Scanning.Aob; + + namespace ThrowawayPlugin; + + internal static class LegacyAobConsumer + { + internal static void CompileOnly() + { + if (AobScanner.TryScan("90", out Owned? defaultResults)) + defaultResults.Dispose(); + + if (AobScanner.TryScan("90", AobScanOptions.Default, + out Owned? configuredResults)) + configuredResults.Dispose(); + } + } + """; + + private const string TargetBoundAllocationSource = """ + using CheatEngine.SDK.Engine.Allocation; + using CheatEngine.SDK.Engine.Errors; + using CheatEngine.SDK.Engine.Targets; + using CheatEngine.SDK.Engine.Values; + using CheatEngine.SDK.Lua.Calls; + + namespace ThrowawayPlugin; + + internal sealed class TargetBoundAllocationBackend : ITargetBoundMemoryAllocationOperations + { + public TargetMemoryAllocationOutcome AllocateBoundWithOutcome( + TargetAllocationRequest request, out TargetProcessIncarnation incarnation, + out TargetSelectionObservation observation) + { + observation = TargetSelection.ObserveCurrent(); + incarnation = observation.Incarnation.GetValueOrDefault(); + return TargetMemoryAllocationOutcome.Failed(observation.IsQualified + ? TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ExpectedOperationFailure) + : CreateObservationFailure(observation)); + } + + public bool TryDeallocateBound(TargetProcessIncarnation expected, Address address, + TargetAllocationSize size, out TargetIdentityCheck targetCheck) + { + var outcome = DeallocateBoundWithOutcome(expected, address, size, out targetCheck); + return targetCheck.IsCurrent && outcome.IsSuccess; + } + + public TargetMemoryOperationOutcome DeallocateBoundWithOutcome( + TargetProcessIncarnation expected, Address address, TargetAllocationSize size, + out TargetIdentityCheck targetCheck) + { + targetCheck = TargetSelection.ValidateCurrent(expected); + return targetCheck.IsCurrent + ? TargetMemoryOperationOutcome.Succeeded() + : CreateTargetCheckFailure(targetCheck); + } + + private static TargetMemoryOperationOutcome CreateObservationFailure( + TargetSelectionObservation observation) + { + return observation.Status == TargetSelectionObservationStatus.LuaFailure + ? TargetMemoryOperationOutcome.Failed( + EngineFailureKind.ProtectedLuaFailure, LuaStatus.RuntimeError) + : TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable); + } + + private static TargetMemoryOperationOutcome CreateTargetCheckFailure( + TargetIdentityCheck targetCheck) + { + return targetCheck.Kind is TargetIdentityCheckKind.TargetChanged + or TargetIdentityCheckKind.ProcessReused + ? TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityMismatch) + : TargetMemoryOperationOutcome.Failed( + EngineFailureKind.TargetIdentityUnavailable); + } + } + """; + + private const string RecordAndSymbolContractSource = """ + using CheatEngine.SDK.Engine.AddressList; + using CheatEngine.SDK.Engine.Inspection; + using CheatEngine.SDK.Engine.Values; + + namespace ThrowawayPlugin; + + internal static class RecordAndSymbolContractConsumer + { + internal static void CompileOnly() + { + MemoryRecordId record = new(17); + _ = AddressListMutations.Delete(record); + _ = AddressListMutations.SetParent(record, parentId: null); + SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned( + new SymbolName("throwaway_symbol"), new Address(0x140001000)); + if (acquired.Lease is SymbolRegistrationLease lease) + _ = lease.Release(); + } + } + """; + + private const string ValueScanSource = """ + using System; + using CheatEngine.SDK.Engine.Scanning.Values; + + namespace ThrowawayPlugin; + + internal static class ValueScanConsumer + { + internal static MemoryScanCreationStatus Create(out MemoryScanSession? session) + { + return MemoryScanSessions.TryCreateDetailed(out session); + } + + internal static MemoryScanMaterializationStatus Copy(MemoryScanSession session, + Span destination, out ulong totalCount, out int written) + { + return session.TryCopyResults(destination, out totalCount, out written); + } + } + """; + + private ThrowawayConsumer(string directory, string projectPath, string assemblyPath) + { + Directory = directory; + ProjectPath = projectPath; + AssemblyPath = assemblyPath; + } + + /// The consumer project's own directory. + public string Directory + { + get; + } + + /// Full path of the generated .csproj. + public string ProjectPath + { + get; + } + + /// + /// Where a Release build places the compiled plugin assembly. Only valid after has + /// succeeded. + /// + public string AssemblyPath + { + get; + } + + /// The native protection bridge copied beside the built plugin. + public string NativeBridgePath => + Path.Combine(Path.GetDirectoryName(AssemblyPath)!, "cheatengine-sdk-lua-bridge.dll"); + + /// + /// Scaffolds a project named under : an + /// net10.0 class library with one PackageReference to CheatEngine.SDK. Its generated + /// NuGet.Config maps that exact package identity to while retaining + /// nuget.org for other package identities. The project has one minimal but valid plugin class and whatever + /// adds to its single PropertyGroup. When + /// is , + /// the project also declares one valid [LuaFunction] export. When + /// is , it compiles both historical AobScanner.TryScan overloads against the packed + /// SDK. When is , it compiles an + /// independent implementation of the target-bound allocation backend seam against that package. When + /// is , it compiles the + /// SDK-021 typed mutation and coordinated-symbol surfaces without accessing raw CE handles. + /// When is , it compiles the value-scan + /// factory and bounded-copy APIs against the same packed SDK. + /// defaults to x64, but may be to prove the package + /// behavior when the consumer does not declare it. + /// + public static ThrowawayConsumer Create(string parentDirectory, string name, string cheatEngineSdkVersion, + string localFeedDirectory, string extraProperties = "", string? platformTarget = "x64", + bool includeLuaFunction = false, bool includeLegacyAobConsumer = false, + bool includeTargetBoundAllocationConsumer = false, bool includeRecordAndSymbolContract = false, + bool includeValueScanConsumer = false) + { + string directory = Path.Combine(parentDirectory, name); + System.IO.Directory.CreateDirectory(directory); + + string projectPath = Path.Combine(directory, $"{name}.csproj"); + string platformTargetProperty = platformTarget is null + ? "" + : $" {platformTarget}\n"; + File.WriteAllText(projectPath, $""" + + + net10.0 + + {platformTargetProperty} enable + {extraProperties} + + + + + """); + + File.WriteAllText(Path.Combine(directory, "Plugin.cs"), PluginSource); + if (includeLuaFunction) + { + File.WriteAllText(Path.Combine(directory, "Functions.cs"), LuaFunctionSource); + } + + if (includeLegacyAobConsumer) + { + File.WriteAllText(Path.Combine(directory, "LegacyAobConsumer.cs"), LegacyAobSource); + } + + if (includeTargetBoundAllocationConsumer) + { + File.WriteAllText(Path.Combine(directory, "TargetBoundAllocationBackend.cs"), TargetBoundAllocationSource); + } + + if (includeRecordAndSymbolContract) + { + File.WriteAllText(Path.Combine(directory, "RecordAndSymbolContractConsumer.cs"), + RecordAndSymbolContractSource); + } + + if (includeValueScanConsumer) + { + File.WriteAllText(Path.Combine(directory, "ValueScanConsumer.cs"), ValueScanSource); + } + + WriteNuGetConfig(directory, localFeedDirectory, UmbrellaPackage.Id); + + string assemblyPath = Path.Combine(directory, "bin", "Release", "net10.0", $"{name}.dll"); + return new ThrowawayConsumer(directory, projectPath, assemblyPath); + } + + /// + /// Scaffolds a package-only executable that runs generated Lua bindings against exactly one fixture-supplied + /// Lua 5.3 DLL. is required so the fixture, rather than this scaffold, + /// explicitly opts into the unsafe compilation generated [LuaFunction] thunks require. + /// + public static ThrowawayConsumer CreateRuntimeExecutable(string parentDirectory, string name, + string cheatEngineSdkVersion, string localFeedDirectory, string extraProperties) + { + return CreateExecutable(parentDirectory, name, cheatEngineSdkVersion, localFeedDirectory, extraProperties, + "Program.cs", RuntimeProgramSource); + } + + /// + /// Scaffolds the intentional CESDK2005 consumer: two otherwise valid generated Lua functions share one Lua + /// global name. must explicitly enable unsafe code so CESDK2001 does not + /// mask that duplicate-name diagnostic. + /// + public static ThrowawayConsumer CreateInvalidDuplicateLuaFunctionConsumer(string parentDirectory, string name, + string cheatEngineSdkVersion, string localFeedDirectory, string extraProperties) + { + return CreateExecutable(parentDirectory, name, cheatEngineSdkVersion, localFeedDirectory, extraProperties, + "Program.cs", DuplicateLuaFunctionProgramSource); + } + + /// + /// Scaffolds a package-only executable whose source includes a generated Lua binding but whose program neither + /// loads a native Lua module nor activates a Cheat Engine host. The fixture supplies trim/AOT/RID properties. + /// + public static ThrowawayConsumer CreateAotExecutable(string parentDirectory, string name, + string cheatEngineSdkVersion, + string localFeedDirectory, string extraProperties) + { + return CreateExecutable(parentDirectory, name, cheatEngineSdkVersion, localFeedDirectory, extraProperties, + "Program.cs", AotProgramSource); + } + + /// + /// Scaffolds a valid plugin that reaches the umbrella package only through a packed relay package. The source + /// intentionally remains the same as a direct consumer: package compile references must still flow, while the + /// package's direct-only build assets must not. + /// + public static ThrowawayConsumer CreateIndirect(string parentDirectory, string carrierPackageId, + string carrierPackageVersion, string localFeedDirectory) + { + const string consumerName = "IndirectConsumer"; + string directory = Path.Combine(parentDirectory, consumerName); + System.IO.Directory.CreateDirectory(directory); + + string projectPath = Path.Combine(directory, $"{consumerName}.csproj"); + File.WriteAllText(projectPath, $""" + + + net10.0 + x64 + enable + + + + + + """); + File.WriteAllText(Path.Combine(directory, "Plugin.cs"), PluginSource); + WriteNuGetConfig(directory, localFeedDirectory, carrierPackageId, UmbrellaPackage.Id); + + string assemblyPath = Path.Combine(directory, "bin", "Release", "net10.0", $"{consumerName}.dll"); + return new ThrowawayConsumer(directory, projectPath, assemblyPath); + } + + private static ThrowawayConsumer CreateExecutable(string parentDirectory, string name, string cheatEngineSdkVersion, + string localFeedDirectory, string extraProperties, string sourceFileName, string source) + { + string directory = Path.Combine(parentDirectory, name); + System.IO.Directory.CreateDirectory(directory); + + string projectPath = Path.Combine(directory, $"{name}.csproj"); + File.WriteAllText(projectPath, $""" + + + net10.0 + Exe + enable + {extraProperties} + + + + + """); + File.WriteAllText(Path.Combine(directory, sourceFileName), source); + WriteNuGetConfig(directory, localFeedDirectory, UmbrellaPackage.Id); + + string assemblyPath = Path.Combine(directory, "bin", "Release", "net10.0", $"{name}.dll"); + return new ThrowawayConsumer(directory, projectPath, assemblyPath); + } + + // isolates restore from arbitrary user/machine configuration. The exact CheatEngine.SDK mapping keeps + // package restore on this fixture's freshly packed feed; nuget.org remains only for external SDK dependencies. + private static void WriteNuGetConfig(string directory, string localFeedDirectory, params string[] localPackageIds) + { + StringBuilder packageMappings = new(); + foreach (string packageId in localPackageIds) + { + packageMappings.Append(" \n"); + } + + File.WriteAllText(Path.Combine(directory, "NuGet.Config"), $""" + + + + + + + + + + + {packageMappings} + + + + + + """); + } + + /// + /// Restores the package identities mapped to the local feed (see ); no other consumer step + /// restores again. Nuget.org remains available only for package identities not mapped to that feed. + /// is passed as --packages so extraction lands in a directory the + /// caller controls, never the machine-wide global-packages folder: NuGet treats a given package id+version as + /// immutable once extracted there, so a stale extraction left by an earlier run (this fixture, a developer's own + /// restore, or another parallel build) would otherwise be reused silently even though this run's + /// dotnet pack produced different content under the same MinVer-derived version (see + /// PackagedUmbrellaFixture's own remarks). + /// + public Task RestoreAsync(TimeSpan timeout, string packagesDirectory) + { + return ProcessRunner.RunAsync( + "dotnet", + $"restore \"{ProjectPath}\" --configfile \"{Path.Combine(Directory, "NuGet.Config")}\" --packages \"{packagesDirectory}\" --no-http-cache --force-evaluate --nologo", + Directory, + timeout); + } + + /// A real Release build: what produces and runs the packaged generators/analyzers. + public Task BuildAsync(TimeSpan timeout) + { + return ProcessRunner.RunAsync("dotnet", $"build \"{ProjectPath}\" -c Release --no-restore --nologo", Directory, + timeout); + } + + /// Runs the built executable with its required one argument: the test fixture's Lua 5.3 DLL path. + public Task RunAsync(string luaLibraryPath, TimeSpan timeout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(luaLibraryPath); + return ProcessRunner.RunAsync("dotnet", $"\"{AssemblyPath}\" \"{luaLibraryPath}\"", Directory, timeout); + } + + /// Cleans the consumer output without restoring, so the following build validates normal SDK copy bookkeeping. + public Task CleanAsync(TimeSpan timeout) + { + return ProcessRunner.RunAsync("dotnet", $"clean \"{ProjectPath}\" -c Release --nologo", Directory, + timeout); + } + + /// Publishes the consumer into without restoring again. + public Task PublishAsync(TimeSpan timeout, string outputDirectory) + { + return ProcessRunner.RunAsync("dotnet", + $"publish \"{ProjectPath}\" -c Release --no-restore --nologo -o \"{outputDirectory}\"", Directory, + timeout); + } + + /// Runs the native executable emitted by a publish into . + public Task RunPublishedAsync(string outputDirectory, TimeSpan timeout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); + string executableName = Path.GetFileNameWithoutExtension(ProjectPath) + ".exe"; + string executablePath = Path.Combine(outputDirectory, executableName); + return ProcessRunner.RunAsync(executablePath, "", outputDirectory, timeout); + } + + /// + /// Evaluates (does not build: no -target, per the MSBuild command-line reference) the named MSBuild + /// properties after restore, exactly as the packaged build/CheatEngine.SDK.props and this project's own + /// PropertyGroup leave them. + /// + public async Task> GetPropertiesAsync(TimeSpan timeout, + params string[] propertyNames) + { + string switches = string.Join(' ', propertyNames.Select(static p => $"-getProperty:{p}")); + ProcessResult result = await ProcessRunner.RunAsync("dotnet", + $"build \"{ProjectPath}\" -c Release --no-restore --nologo {switches}", Directory, timeout) + .ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"'dotnet build -getProperty' failed for '{ProjectPath}' (exit {result.ExitCode.ToString(CultureInfo.InvariantCulture)}):{Environment.NewLine}{result.CombinedOutput}"); + } + + Dictionary values = new(StringComparer.Ordinal); + + // MSBuild's own documented split: "-getProperty to request a single property" emits a bare string; + // several properties (this project always requests at least one, so >= 2 here) emit one JSON object. + if (propertyNames.Length == 1) + { + values[propertyNames[0]] = result.StandardOutput.Trim(); + return values; + } + + int jsonStart = result.StandardOutput.AsSpan().IndexOf('{'); + if (jsonStart < 0) + { + throw new InvalidOperationException( + $"'dotnet build -getProperty' for '{ProjectPath}' produced no JSON on standard output:{Environment.NewLine}{result.CombinedOutput}"); + } + + using JsonDocument document = JsonDocument.Parse(result.StandardOutput[jsonStart..]); + JsonElement properties = document.RootElement.GetProperty("Properties"); + foreach (string name in propertyNames) + { + values[name] = properties.TryGetProperty(name, out JsonElement value) + ? value.GetString() ?? string.Empty + : string.Empty; + } + + return values; + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs index d569a8ac..efefb81a 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs @@ -6,76 +6,82 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// internal sealed class ThrowawayPackageCarrier { - /// The fixed identity is unique within one fixture-local feed. - public const string PackageId = "CheatEngine.SDK.PackageAssetCarrier"; + /// The fixed identity is unique within one fixture-local feed. + public const string PackageId = "CheatEngine.SDK.PackageAssetCarrier"; - /// The fixture controls the feed, so a stable test-only version is sufficient. - public const string PackageVersion = "1.0.0"; + /// The fixture controls the feed, so a stable test-only version is sufficient. + public const string PackageVersion = "1.0.0"; - private ThrowawayPackageCarrier(string directory, string projectPath) - { - Directory = directory; - ProjectPath = projectPath; - } + private ThrowawayPackageCarrier(string directory, string projectPath) + { + Directory = directory; + ProjectPath = projectPath; + } - /// The carrier project's temporary directory. - public string Directory { get; } + /// The carrier project's temporary directory. + public string Directory + { + get; + } - /// The carrier project that is restored and packed into the fixture-local feed. - public string ProjectPath { get; } + /// The carrier project that is restored and packed into the fixture-local feed. + public string ProjectPath + { + get; + } - /// Creates the carrier package project and its isolated NuGet configuration. - public static ThrowawayPackageCarrier Create(string parentDirectory, string cheatEngineSdkVersion, - string localFeedDirectory) - { - var directory = Path.Combine(parentDirectory, "PackageAssetCarrier"); - System.IO.Directory.CreateDirectory(directory); - var projectPath = Path.Combine(directory, "PackageAssetCarrier.csproj"); - File.WriteAllText(projectPath, $""" - - - net10.0 - {PackageId} - {PackageVersion} - enable - - - - - - """); - File.WriteAllText(Path.Combine(directory, "PackageBoundary.cs"), """ - namespace PackageAssetCarrier; + /// Creates the carrier package project and its isolated NuGet configuration. + public static ThrowawayPackageCarrier Create(string parentDirectory, string cheatEngineSdkVersion, + string localFeedDirectory) + { + string directory = Path.Combine(parentDirectory, "PackageAssetCarrier"); + System.IO.Directory.CreateDirectory(directory); + string projectPath = Path.Combine(directory, "PackageAssetCarrier.csproj"); + File.WriteAllText(projectPath, $""" + + + net10.0 + {PackageId} + {PackageVersion} + enable + + + + + + """); + File.WriteAllText(Path.Combine(directory, "PackageBoundary.cs"), """ + namespace PackageAssetCarrier; - public sealed class PackageBoundary - { - } - """); - File.WriteAllText(Path.Combine(directory, "NuGet.Config"), $""" - - - - - - - - - """); - return new ThrowawayPackageCarrier(directory, projectPath); - } + public sealed class PackageBoundary + { + } + """); + File.WriteAllText(Path.Combine(directory, "NuGet.Config"), $""" + + + + + + + + + """); + return new ThrowawayPackageCarrier(directory, projectPath); + } - /// Restores the carrier package from the fixture-local feed. - public Task RestoreAsync(TimeSpan timeout, string packagesDirectory) - { - return ProcessRunner.RunAsync("dotnet", - $"restore \"{ProjectPath}\" --configfile \"{Path.Combine(Directory, "NuGet.Config")}\" --packages \"{packagesDirectory}\" --nologo", - Directory, timeout); - } + /// Restores the carrier package from the fixture-local feed. + public Task RestoreAsync(TimeSpan timeout, string packagesDirectory) + { + return ProcessRunner.RunAsync("dotnet", + $"restore \"{ProjectPath}\" --configfile \"{Path.Combine(Directory, "NuGet.Config")}\" --packages \"{packagesDirectory}\" --nologo", + Directory, timeout); + } - /// Packs the already-restored carrier into . - public Task PackAsync(TimeSpan timeout, string feedDirectory) - { - return ProcessRunner.RunAsync("dotnet", - $"pack \"{ProjectPath}\" -c Release --no-restore -o \"{feedDirectory}\" --nologo", Directory, timeout); - } + /// Packs the already-restored carrier into . + public Task PackAsync(TimeSpan timeout, string feedDirectory) + { + return ProcessRunner.RunAsync("dotnet", + $"pack \"{ProjectPath}\" -c Release --no-restore -o \"{feedDirectory}\" --nologo", Directory, timeout); + } } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs index 46eaacba..26a59ede 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs @@ -7,15 +7,18 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// internal static class UmbrellaPackage { - /// The PackageId of src/CheatEngine.SDK/CheatEngine.SDK.csproj. - public const string Id = "CheatEngine.SDK"; + /// The PackageId of src/CheatEngine.SDK/CheatEngine.SDK.csproj. + public const string Id = "CheatEngine.SDK"; - /// Repository-relative path, with forward slashes, of the project that packs . - public const string ProjectPath = "src/CheatEngine.SDK/CheatEngine.SDK.csproj"; + /// Repository-relative path, with forward slashes, of the project that packs . + public const string ProjectPath = "src/CheatEngine.SDK/CheatEngine.SDK.csproj"; - /// - /// The folder name NuGet extracts into inside a global-packages folder - /// (<packages>/cheatengine.sdk/<version>): NuGet lower-cases the id there. - /// - public static string ExtractionFolderName { get; } = Id.ToLowerInvariant(); + /// + /// The folder name NuGet extracts into inside a global-packages folder + /// (<packages>/cheatengine.sdk/<version>): NuGet lower-cases the id there. + /// + public static string ExtractionFolderName + { + get; + } = Id.ToLowerInvariant(); } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs index eab23b34..030fa6ed 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs @@ -11,42 +11,42 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class BuildPropertyDefaultsTests(PackagedUmbrellaFixture fixture) { - [Fact] - public void AllowUnsafeBlocks_remains_false_for_a_consumer_that_does_not_set_it() - { - Assert.Equal("false", fixture.DefaultProperties["AllowUnsafeBlocks"], true); - } + [Fact] + public void AllowUnsafeBlocks_remains_false_for_a_consumer_that_does_not_set_it() + { + Assert.Equal("false", fixture.DefaultProperties["AllowUnsafeBlocks"], true); + } - [Fact] - public void EnableDynamicLoading_defaults_to_true_for_a_consumer_that_does_not_set_it() - { - Assert.Equal("true", fixture.DefaultProperties["EnableDynamicLoading"], true); - } + [Fact] + public void EnableDynamicLoading_defaults_to_true_for_a_consumer_that_does_not_set_it() + { + Assert.Equal("true", fixture.DefaultProperties["EnableDynamicLoading"], true); + } - [Fact] - public void CheatEngineSdkGenerateEntryPoint_defaults_to_true_for_a_consumer_that_does_not_set_it() - { - Assert.Equal("true", fixture.DefaultProperties["CheatEngineSdkGenerateEntryPoint"], true); - } + [Fact] + public void CheatEngineSdkGenerateEntryPoint_defaults_to_true_for_a_consumer_that_does_not_set_it() + { + Assert.Equal("true", fixture.DefaultProperties["CheatEngineSdkGenerateEntryPoint"], true); + } - [Fact] - public void A_consumer_that_explicitly_sets_AllowUnsafeBlocks_false_remains_false() - { - Assert.Equal("false", fixture.ExplicitUnsafeFalseProperties["AllowUnsafeBlocks"], true); - } + [Fact] + public void A_consumer_that_explicitly_sets_AllowUnsafeBlocks_false_remains_false() + { + Assert.Equal("false", fixture.ExplicitUnsafeFalseProperties["AllowUnsafeBlocks"], true); + } - [Fact] - public void LuaFunction_consumer_with_explicit_unsafe_opt_in_builds() - { - Assert.True(fixture.LuaFunctionOptInConsumerBuildSucceeded, - "The documented true opt-in did not allow a packed consumer to compile a Lua-function registration thunk."); - } + [Fact] + public void LuaFunction_consumer_with_explicit_unsafe_opt_in_builds() + { + Assert.True(fixture.LuaFunctionOptInConsumerBuildSucceeded, + "The documented true opt-in did not allow a packed consumer to compile a Lua-function registration thunk."); + } - [Fact] - public void LuaFunction_consumer_without_unsafe_opt_in_fails_with_CESDK2001() - { - Assert.False(fixture.LuaFunctionWithoutUnsafeConsumerBuildSucceeded, - "A packed consumer that exports a Lua function unexpectedly built without enabling unsafe compilation."); - Assert.Contains("CESDK2001", fixture.LuaFunctionWithoutUnsafeConsumerBuildOutput, StringComparison.Ordinal); - } + [Fact] + public void LuaFunction_consumer_without_unsafe_opt_in_fails_with_CESDK2001() + { + Assert.False(fixture.LuaFunctionWithoutUnsafeConsumerBuildSucceeded, + "A packed consumer that exports a Lua function unexpectedly built without enabling unsafe compilation."); + Assert.Contains("CESDK2001", fixture.LuaFunctionWithoutUnsafeConsumerBuildOutput, StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs index 70d26054..30ea56fd 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs @@ -9,31 +9,33 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class DeploymentLayoutTests(PackagedUmbrellaFixture fixture) { - private static readonly string[] ExpectedSdkAssemblies = - [ - "CheatEngine.SDK.Abi.dll", "CheatEngine.SDK.Annotations.dll", "CheatEngine.SDK.Engine.dll", - "CheatEngine.SDK.Hosting.dll", "CheatEngine.SDK.Lua.dll", "CheatEngine.SDK.Lua.Interop.dll", - ]; + private static readonly string[] ExpectedSdkAssemblies = + [ + "CheatEngine.SDK.Abi.dll", "CheatEngine.SDK.Annotations.dll", "CheatEngine.SDK.Engine.dll", + "CheatEngine.SDK.Hosting.dll", "CheatEngine.SDK.Lua.dll", "CheatEngine.SDK.Lua.Interop.dll" + ]; - [Fact] - public void Direct_consumer_build_produces_an_atomic_plugin_deployment_folder() - { - Assert.True(File.Exists(Path.Combine(fixture.DefaultDeploymentDirectory, "DefaultConsumer.dll"))); - Assert.True(File.Exists(fixture.DefaultRuntimeConfigPath)); - Assert.True(File.Exists(fixture.DefaultDepsJsonPath)); - Assert.True(File.Exists(fixture.DefaultNativeBridgePath)); + [Fact] + public void Direct_consumer_build_produces_an_atomic_plugin_deployment_folder() + { + Assert.True(File.Exists(Path.Combine(fixture.DefaultDeploymentDirectory, "DefaultConsumer.dll"))); + Assert.True(File.Exists(fixture.DefaultRuntimeConfigPath)); + Assert.True(File.Exists(fixture.DefaultDepsJsonPath)); + Assert.True(File.Exists(fixture.DefaultNativeBridgePath)); - foreach (var assemblyName in ExpectedSdkAssemblies) - Assert.True(File.Exists(Path.Combine(fixture.DefaultDeploymentDirectory, assemblyName)), - $"The SDK assembly '{assemblyName}' was not copied beside the plugin."); - } + foreach (string assemblyName in ExpectedSdkAssemblies) + { + Assert.True(File.Exists(Path.Combine(fixture.DefaultDeploymentDirectory, assemblyName)), + $"The SDK assembly '{assemblyName}' was not copied beside the plugin."); + } + } - [Fact] - public void Direct_consumer_clean_removes_the_native_bridge_before_the_rebuild_restores_it() - { - Assert.True(fixture.DefaultNativeBridgeWasRemovedByClean, - "dotnet clean left the direct-only native bridge in the consumer output instead of using normal MSBuild file tracking."); - Assert.True(File.Exists(fixture.DefaultNativeBridgePath), - "The clean/rebuild sequence did not restore the native bridge beside the direct consumer."); - } + [Fact] + public void Direct_consumer_clean_removes_the_native_bridge_before_the_rebuild_restores_it() + { + Assert.True(fixture.DefaultNativeBridgeWasRemovedByClean, + "dotnet clean left the direct-only native bridge in the consumer output instead of using normal MSBuild file tracking."); + Assert.True(File.Exists(fixture.DefaultNativeBridgePath), + "The clean/rebuild sequence did not restore the native bridge beside the direct consumer."); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs index 84d1dd31..65dda883 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs @@ -9,28 +9,28 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class DirectReferenceIsolationTests(PackagedUmbrellaFixture fixture) { - [Theory] - [InlineData("AllowUnsafeBlocks", "false")] - [InlineData("EnableDynamicLoading", "")] - [InlineData("CheatEngineSdkGenerateEntryPoint", "")] - public void Indirect_consumer_does_not_receive_the_package_build_property(string propertyName, string expectedValue) - { - Assert.Equal(expectedValue, fixture.IndirectProperties[propertyName]); - } + [Theory] + [InlineData("AllowUnsafeBlocks", "false")] + [InlineData("EnableDynamicLoading", "")] + [InlineData("CheatEngineSdkGenerateEntryPoint", "")] + public void Indirect_consumer_does_not_receive_the_package_build_property(string propertyName, string expectedValue) + { + Assert.Equal(expectedValue, fixture.IndirectProperties[propertyName]); + } - [Fact] - public void Indirect_consumer_does_not_receive_a_generated_bootstrap() - { - Assert.False(fixture.IndirectEntryPointTypeExists, - "CESDK.CESDK was generated in a project without a direct CheatEngine.SDK PackageReference."); - } + [Fact] + public void Indirect_consumer_does_not_receive_a_generated_bootstrap() + { + Assert.False(fixture.IndirectEntryPointTypeExists, + "CESDK.CESDK was generated in a project without a direct CheatEngine.SDK PackageReference."); + } - [Fact] - public void Indirect_consumer_does_not_receive_the_direct_only_native_bridge() - { - Assert.False(File.Exists(fixture.IndirectNativeBridgePath), - $"The direct-only bridge was copied to '{fixture.IndirectNativeBridgePath}'."); - Assert.False(File.Exists(fixture.IndirectPublishedNativeBridgePath), - $"The direct-only bridge was published to '{fixture.IndirectPublishedNativeBridgePath}'."); - } + [Fact] + public void Indirect_consumer_does_not_receive_the_direct_only_native_bridge() + { + Assert.False(File.Exists(fixture.IndirectNativeBridgePath), + $"The direct-only bridge was copied to '{fixture.IndirectNativeBridgePath}'."); + Assert.False(File.Exists(fixture.IndirectPublishedNativeBridgePath), + $"The direct-only bridge was published to '{fixture.IndirectPublishedNativeBridgePath}'."); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs index ceb90cd6..a7be5b37 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Loader; + using CheatEngine.SDK.Tests.Infrastructure; namespace CheatEngine.SDK.Tests.Packaging; @@ -16,124 +17,131 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class EntryPointTests(PackagedUmbrellaFixture fixture) { - [Fact] - public void Default_consumer_gets_the_generated_entry_point_type() - { - Assert.True(fixture.DefaultEntryPointTypeExists, - "CESDK.CESDK was not found in the default consumer's built assembly."); - } - - [Fact] - public void Default_consumer_entry_point_declares_CEPluginInitialize() - { - Assert.True(fixture.DefaultEntryPointMethodExists, - "CESDK.CESDK.CEPluginInitialize(object, object) was not found in the default consumer's built assembly."); - } - - [Fact] - public void CheatEngineSdkGenerateEntryPoint_false_accepts_the_manual_bootstrap() - { - Assert.True(fixture.EntryPointOffTypeExists, - "The manual CESDK.CESDK bootstrap was not found in the opted-out consumer."); - Assert.True(fixture.EntryPointOffMethodExists, - "The manual CESDK.CESDK bootstrap did not declare CEPluginInitialize(System.IntPtr, int)."); - } - - [Fact] - public void Default_consumer_gets_a_loadable_native_bridge() - { - Assert.True(File.Exists(fixture.DefaultNativeBridgePath), - $"The bridge was not copied to '{fixture.DefaultNativeBridgePath}'."); - - var module = NativeLibrary.Load(fixture.DefaultNativeBridgePath); - try - { - Assert.True(NativeLibrary.TryGetExport(module, "cheatengine_sdk_lua_protected", out _)); - var versionAddress = NativeLibrary.GetExport(module, "cheatengine_sdk_lua_bridge_abi_version"); - var version = Marshal.GetDelegateForFunctionPointer(versionAddress); - Assert.Equal(1u, version()); - var fingerprintAddress = NativeLibrary.GetExport(module, "cheatengine_sdk_lua_bridge_source_fingerprint"); - Assert.False(string.IsNullOrWhiteSpace(Marshal.PtrToStringAnsi(fingerprintAddress))); - } - finally - { - NativeLibrary.Free(module); - } - } - - [Fact] - public void Published_consumer_keeps_the_native_bridge() - { - Assert.True(File.Exists(fixture.DefaultPublishedNativeBridgePath), - $"The bridge was not published to '{fixture.DefaultPublishedNativeBridgePath}'."); - var publishDirectory = Path.GetDirectoryName(fixture.DefaultPublishedNativeBridgePath)!; - Assert.Single(Directory.GetFiles(publishDirectory, "cheatengine-sdk-lua-bridge.dll", - SearchOption.AllDirectories)); - } - - [Fact] - public void Packed_direct_consumer_executes_bootstrap_with_an_opaque_second_argument() - { - const int recordSize = 36; - const int opaqueArgument = 0x13579BDF; - const byte canary = 0xA5; - var consumerAssemblyPath = Path.Combine(fixture.DefaultDeploymentDirectory, "DefaultConsumer.dll"); - var hostingAssemblyPath = Path.Combine(fixture.DefaultDeploymentDirectory, "CheatEngine.SDK.Hosting.dll"); - - var context = new PluginAssemblyLoadContext(fixture.DefaultDeploymentDirectory); - try - { - var hostingAssembly = context.LoadFromAssemblyPath(hostingAssemblyPath); - var consumerAssembly = context.LoadFromAssemblyPath(consumerAssemblyPath); - var entryPointType = consumerAssembly.GetType(name: "CESDK.CESDK", throwOnError: true)!; - var initialize = entryPointType.GetMethod("CEPluginInitialize", BindingFlags.Public | BindingFlags.Static) - ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); - - var record = Marshal.AllocHGlobal(recordSize + sizeof(int)); - try - { - var initialBytes = new byte[recordSize + sizeof(int)]; - Array.Fill(initialBytes, canary); - Marshal.Copy(initialBytes, 0, record, initialBytes.Length); - - var result = initialize.Invoke(null, [record, opaqueArgument]); - Assert.Equal(1, Assert.IsType(result)); - - var pluginHost = hostingAssembly.GetType( - name: "CheatEngine.SDK.Hosting.Bootstrap.PluginHost", throwOnError: true)!; - var lastArgument = - pluginHost.GetProperty("LastInitRecordArgument", BindingFlags.Public | BindingFlags.Static) - ?? throw new MissingMemberException(pluginHost.FullName, "LastInitRecordArgument"); - Assert.Equal(opaqueArgument, Assert.IsType(lastArgument.GetValue(null))); - - var actualBytes = new byte[recordSize + sizeof(int)]; - Marshal.Copy(record, actualBytes, 0, actualBytes.Length); - Assert.NotEqual(0L, BitConverter.ToInt64(actualBytes, 0)); - for (var index = recordSize; index < actualBytes.Length; index++) - Assert.Equal(canary, actualBytes[index]); - } - finally - { - Marshal.FreeHGlobal(record); - } - } - finally - { - context.Unload(); - } - } - - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - private delegate uint BridgeVersion(); - - private sealed class PluginAssemblyLoadContext(string deploymentDirectory) : AssemblyLoadContext(isCollectible: true) - { - protected override Assembly? Load(AssemblyName assemblyName) - { - if (string.IsNullOrEmpty(assemblyName.Name)) return null; - - var candidatePath = Path.Combine(deploymentDirectory, assemblyName.Name + ".dll"); - return File.Exists(candidatePath) ? LoadFromAssemblyPath(candidatePath) : null; - } - } + [Fact] + public void Default_consumer_gets_the_generated_entry_point_type() + { + Assert.True(fixture.DefaultEntryPointTypeExists, + "CESDK.CESDK was not found in the default consumer's built assembly."); + } + + [Fact] + public void Default_consumer_entry_point_declares_CEPluginInitialize() + { + Assert.True(fixture.DefaultEntryPointMethodExists, + "CESDK.CESDK.CEPluginInitialize(object, object) was not found in the default consumer's built assembly."); + } + + [Fact] + public void CheatEngineSdkGenerateEntryPoint_false_accepts_the_manual_bootstrap() + { + Assert.True(fixture.EntryPointOffTypeExists, + "The manual CESDK.CESDK bootstrap was not found in the opted-out consumer."); + Assert.True(fixture.EntryPointOffMethodExists, + "The manual CESDK.CESDK bootstrap did not declare CEPluginInitialize(System.IntPtr, int)."); + } + + [Fact] + public void Default_consumer_gets_a_loadable_native_bridge() + { + Assert.True(File.Exists(fixture.DefaultNativeBridgePath), + $"The bridge was not copied to '{fixture.DefaultNativeBridgePath}'."); + + IntPtr module = NativeLibrary.Load(fixture.DefaultNativeBridgePath); + try + { + Assert.True(NativeLibrary.TryGetExport(module, "cheatengine_sdk_lua_protected", out _)); + IntPtr versionAddress = NativeLibrary.GetExport(module, "cheatengine_sdk_lua_bridge_abi_version"); + BridgeVersion version = Marshal.GetDelegateForFunctionPointer(versionAddress); + Assert.Equal(1u, version()); + IntPtr fingerprintAddress = + NativeLibrary.GetExport(module, "cheatengine_sdk_lua_bridge_source_fingerprint"); + Assert.False(string.IsNullOrWhiteSpace(Marshal.PtrToStringAnsi(fingerprintAddress))); + } + finally + { + NativeLibrary.Free(module); + } + } + + [Fact] + public void Published_consumer_keeps_the_native_bridge() + { + Assert.True(File.Exists(fixture.DefaultPublishedNativeBridgePath), + $"The bridge was not published to '{fixture.DefaultPublishedNativeBridgePath}'."); + string publishDirectory = Path.GetDirectoryName(fixture.DefaultPublishedNativeBridgePath)!; + Assert.Single(Directory.GetFiles(publishDirectory, "cheatengine-sdk-lua-bridge.dll", + SearchOption.AllDirectories)); + } + + [Fact] + public void Packed_direct_consumer_executes_bootstrap_with_an_opaque_second_argument() + { + const int recordSize = 36; + const int opaqueArgument = 0x13579BDF; + const byte canary = 0xA5; + string consumerAssemblyPath = Path.Combine(fixture.DefaultDeploymentDirectory, "DefaultConsumer.dll"); + string hostingAssemblyPath = Path.Combine(fixture.DefaultDeploymentDirectory, "CheatEngine.SDK.Hosting.dll"); + + PluginAssemblyLoadContext context = new(fixture.DefaultDeploymentDirectory); + try + { + Assembly hostingAssembly = context.LoadFromAssemblyPath(hostingAssemblyPath); + Assembly consumerAssembly = context.LoadFromAssemblyPath(consumerAssemblyPath); + Type entryPointType = consumerAssembly.GetType("CESDK.CESDK", true)!; + MethodInfo initialize = + entryPointType.GetMethod("CEPluginInitialize", BindingFlags.Public | BindingFlags.Static) + ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); + + IntPtr record = Marshal.AllocHGlobal(recordSize + sizeof(int)); + try + { + byte[] initialBytes = new byte[recordSize + sizeof(int)]; + Array.Fill(initialBytes, canary); + Marshal.Copy(initialBytes, 0, record, initialBytes.Length); + + object? result = initialize.Invoke(null, [record, opaqueArgument]); + Assert.Equal(1, Assert.IsType(result)); + + Type pluginHost = hostingAssembly.GetType( + "CheatEngine.SDK.Hosting.Bootstrap.PluginHost", true)!; + PropertyInfo lastArgument = + pluginHost.GetProperty("LastInitRecordArgument", BindingFlags.Public | BindingFlags.Static) + ?? throw new MissingMemberException(pluginHost.FullName, "LastInitRecordArgument"); + Assert.Equal(opaqueArgument, Assert.IsType(lastArgument.GetValue(null))); + + byte[] actualBytes = new byte[recordSize + sizeof(int)]; + Marshal.Copy(record, actualBytes, 0, actualBytes.Length); + Assert.NotEqual(0L, BitConverter.ToInt64(actualBytes, 0)); + for (int index = recordSize; index < actualBytes.Length; index++) + { + Assert.Equal(canary, actualBytes[index]); + } + } + finally + { + Marshal.FreeHGlobal(record); + } + } + finally + { + context.Unload(); + } + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate uint BridgeVersion(); + + private sealed class PluginAssemblyLoadContext(string deploymentDirectory) : AssemblyLoadContext(true) + { + protected override Assembly? Load(AssemblyName assemblyName) + { + if (string.IsNullOrEmpty(assemblyName.Name)) + { + return null; + } + + string candidatePath = Path.Combine(deploymentDirectory, assemblyName.Name + ".dll"); + return File.Exists(candidatePath) ? LoadFromAssemblyPath(candidatePath) : null; + } + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs index d91f663b..a309ee66 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; + using CheatEngine.SDK.Tests.Infrastructure; namespace CheatEngine.SDK.Tests.Packaging; @@ -10,24 +11,24 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class NativeBridgePackagingAuditTests(PackagedUmbrellaFixture fixture) { - private const string BridgeRelativePath = - "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; + private const string BridgeRelativePath = + "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; - [Fact] - public void Direct_consumer_build_and_publish_copy_the_exact_audited_bridge_asset() - { - var auditedHash = CalculateSha256(RepositoryLayout.PathOf(BridgeRelativePath)); + [Fact] + public void Direct_consumer_build_and_publish_copy_the_exact_audited_bridge_asset() + { + string auditedHash = CalculateSha256(RepositoryLayout.PathOf(BridgeRelativePath)); - Assert.True(File.Exists(fixture.DefaultNativeBridgePath), - "The direct consumer build did not receive the bridge."); - Assert.True(File.Exists(fixture.DefaultPublishedNativeBridgePath), - "The direct consumer publish output did not receive the bridge."); - Assert.Equal(auditedHash, CalculateSha256(fixture.DefaultNativeBridgePath)); - Assert.Equal(auditedHash, CalculateSha256(fixture.DefaultPublishedNativeBridgePath)); - } + Assert.True(File.Exists(fixture.DefaultNativeBridgePath), + "The direct consumer build did not receive the bridge."); + Assert.True(File.Exists(fixture.DefaultPublishedNativeBridgePath), + "The direct consumer publish output did not receive the bridge."); + Assert.Equal(auditedHash, CalculateSha256(fixture.DefaultNativeBridgePath)); + Assert.Equal(auditedHash, CalculateSha256(fixture.DefaultPublishedNativeBridgePath)); + } - private static string CalculateSha256(string path) - { - return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); - } + private static string CalculateSha256(string path) + { + return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs index 736671c8..a0921d2d 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs @@ -1,5 +1,6 @@ using System.Reflection.PortableExecutable; using System.Security.Cryptography; + using CheatEngine.SDK.Tests.Infrastructure; namespace CheatEngine.SDK.Tests.Packaging; @@ -11,147 +12,159 @@ namespace CheatEngine.SDK.Tests.Packaging; /// public sealed class NativeBridgePeAuditTests { - private const string BridgeRelativePath = - "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; - - private const string ManifestRelativePath = "native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json"; - private const string SourceRelativePath = "native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c"; - private const string BuildRelativePath = "native/cheatengine-sdk-lua-bridge/xmake.lua"; - - private static readonly string[] ExpectedExports = - [ - "cheatengine_sdk_lua_bridge_abi_version", - "cheatengine_sdk_lua_bridge_get_contract", - "cheatengine_sdk_lua_bridge_source_fingerprint", - "cheatengine_sdk_lua_protected", - ]; - - private static readonly string[] s_allowedImportModules = ["KERNEL32.dll"]; - - private static string BridgePath => RepositoryLayout.PathOf(BridgeRelativePath); - - [Fact] - public void Checked_in_bridge_is_an_amd64_PE32_plus_dll_with_the_exact_export_surface() - { - var image = ReadBridge(); - - Assert.Equal(PEMagic.PE32Plus, image.Magic); - Assert.Equal(Machine.Amd64, image.Machine); - Assert.True(image.IsDll); - AssertExactSet(ExpectedExports, GetExportNames(image)); - } - - [Fact] - public void Checked_in_bridge_imports_only_kernel32_without_delay_load() - { - var image = ReadBridge(); - - Assert.False(image.HasDelayImports, "The bridge must not carry a delay-load directory."); - - var imports = image.GetImports(); - Assert.Equal(s_allowedImportModules.Length, imports.Count); - var observedModules = new HashSet(StringComparer.OrdinalIgnoreCase); - for (var index = 0; index < imports.Count; index++) - { - var import = imports[index]; - Assert.True(observedModules.Add(import.ModuleName), - $"The import directory has duplicate module '{import.ModuleName}'."); - Assert.False(import.ModuleName.Contains("lua", StringComparison.OrdinalIgnoreCase), - $"The bridge must not import a Lua module ('{import.ModuleName}')."); - Assert.Contains(import.ModuleName, s_allowedImportModules, StringComparer.OrdinalIgnoreCase); - - for (var symbolIndex = 0; symbolIndex < import.Symbols.Count; symbolIndex++) - Assert.False(import.Symbols[symbolIndex].Contains("lua", StringComparison.OrdinalIgnoreCase), - $"The bridge must not import a Lua symbol ('{import.Symbols[symbolIndex]}')."); - } - - foreach (var moduleName in s_allowedImportModules) - Assert.Contains(moduleName, observedModules, StringComparer.OrdinalIgnoreCase); - } - - [Fact] - public void Audit_manifest_records_the_pinned_toolchain_flags_and_hashes() - { - using var manifest = ReadManifest(); - var root = manifest.RootElement; - - Assert.Equal(2, root.GetProperty("schemaVersion").GetInt32()); - Assert.Equal("xmake", root.GetProperty("toolchain").GetProperty("buildSystem").GetProperty("name").GetString()); - Assert.Equal("3.0.9", - root.GetProperty("toolchain").GetProperty("buildSystem").GetProperty("version").GetString()); - Assert.Equal("MSVC", root.GetProperty("toolchain").GetProperty("compiler").GetProperty("name").GetString()); - Assert.Equal("C11", root.GetProperty("toolchain").GetProperty("languageStandard").GetString()); - Assert.Equal("MT", root.GetProperty("toolchain").GetProperty("cRuntime").GetString()); - AssertJsonStringSet(root.GetProperty("toolchain").GetProperty("compilerFlags"), ["/MT", "allextra", "error"]); - AssertJsonStringSet(root.GetProperty("toolchain").GetProperty("linkerFlags"), ["/Brepro"]); - Assert.True(root.GetProperty("reproducibility").GetProperty("doubleBuildSha256Comparison").GetBoolean()); - - var nativeAsset = root.GetProperty("nativeAsset"); - Assert.Equal("PE32+", nativeAsset.GetProperty("pe").GetProperty("format").GetString()); - Assert.Equal("AMD64", nativeAsset.GetProperty("pe").GetProperty("machine").GetString()); - Assert.True(nativeAsset.GetProperty("pe").GetProperty("isDll").GetBoolean()); - AssertJsonStringSet(nativeAsset.GetProperty("exports"), ExpectedExports); - AssertJsonStringSet(nativeAsset.GetProperty("delayImports"), []); - AssertJsonStringSet(nativeAsset.GetProperty("imports"), s_allowedImportModules); - - var sourceHash = CalculateSha256(RepositoryLayout.PathOf(SourceRelativePath)); - var buildHash = CalculateSha256(RepositoryLayout.PathOf(BuildRelativePath)); - var expectedFingerprint = $"{sourceHash}:{buildHash}"; - Assert.Equal(sourceHash, - root.GetProperty("source").GetProperty("hashes").GetProperty("cheatengine_sdk_lua_bridge.c").GetString()); - Assert.Equal(buildHash, root.GetProperty("source").GetProperty("hashes").GetProperty("xmake.lua").GetString()); - Assert.Equal(expectedFingerprint, root.GetProperty("source").GetProperty("fingerprint").GetString()); - - var bridgeHash = CalculateSha256(BridgePath); - Assert.Equal(bridgeHash, nativeAsset.GetProperty("sha256").GetString()); - Assert.Equal(expectedFingerprint, - ReadBridge().ReadExportedAsciiZ("cheatengine_sdk_lua_bridge_source_fingerprint")); - } - - private static PortableExecutableInspector ReadBridge() - { - return PortableExecutableInspector.Read(BridgePath); - } - - private static JsonDocument ReadManifest() - { - return JsonDocument.Parse(File.ReadAllText(RepositoryLayout.PathOf(ManifestRelativePath))); - } - - private static List GetExportNames(PortableExecutableInspector image) - { - var exports = image.GetExports(); - var names = new List(exports.Count); - for (var index = 0; index < exports.Count; index++) - names.Add(exports[index].Name); - - return names; - } - - private static void AssertExactSet(string[] expected, List actual) - { - Assert.Equal(expected.Length, actual.Count); - var actualValues = new HashSet(actual, StringComparer.Ordinal); - Assert.Equal(actual.Count, actualValues.Count); - for (var index = 0; index < expected.Length; index++) - Assert.Contains(expected[index], actualValues, StringComparer.Ordinal); - } - - private static void AssertJsonStringSet(JsonElement array, string[] expected) - { - Assert.Equal(JsonValueKind.Array, array.ValueKind); - Assert.Equal(expected.Length, array.GetArrayLength()); - var values = new HashSet(StringComparer.Ordinal); - foreach (var entry in array.EnumerateArray()) - values.Add(entry.GetString() ?? string.Empty); - - Assert.Equal(expected.Length, values.Count); - for (var index = 0; index < expected.Length; index++) - Assert.Contains(expected[index], values, StringComparer.Ordinal); - } - - private static string CalculateSha256(string path) - { - return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); - } + private const string BridgeRelativePath = + "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; + + private const string ManifestRelativePath = "native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json"; + private const string SourceRelativePath = "native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c"; + private const string BuildRelativePath = "native/cheatengine-sdk-lua-bridge/xmake.lua"; + + private static readonly string[] ExpectedExports = + [ + "cheatengine_sdk_lua_bridge_abi_version", + "cheatengine_sdk_lua_bridge_get_contract", + "cheatengine_sdk_lua_bridge_source_fingerprint", + "cheatengine_sdk_lua_protected" + ]; + + private static readonly string[] s_allowedImportModules = ["KERNEL32.dll"]; + + private static string BridgePath => RepositoryLayout.PathOf(BridgeRelativePath); + + [Fact] + public void Checked_in_bridge_is_an_amd64_PE32_plus_dll_with_the_exact_export_surface() + { + PortableExecutableInspector image = ReadBridge(); + + Assert.Equal(PEMagic.PE32Plus, image.Magic); + Assert.Equal(Machine.Amd64, image.Machine); + Assert.True(image.IsDll); + AssertExactSet(ExpectedExports, GetExportNames(image)); + } + + [Fact] + public void Checked_in_bridge_imports_only_kernel32_without_delay_load() + { + PortableExecutableInspector image = ReadBridge(); + + Assert.False(image.HasDelayImports, "The bridge must not carry a delay-load directory."); + + IReadOnlyList imports = image.GetImports(); + Assert.Equal(s_allowedImportModules.Length, imports.Count); + HashSet observedModules = new(StringComparer.OrdinalIgnoreCase); + for (int index = 0; index < imports.Count; index++) + { + PortableExecutableImport import = imports[index]; + Assert.True(observedModules.Add(import.ModuleName), + $"The import directory has duplicate module '{import.ModuleName}'."); + Assert.False(import.ModuleName.Contains("lua", StringComparison.OrdinalIgnoreCase), + $"The bridge must not import a Lua module ('{import.ModuleName}')."); + Assert.Contains(import.ModuleName, s_allowedImportModules, StringComparer.OrdinalIgnoreCase); + + for (int symbolIndex = 0; symbolIndex < import.Symbols.Count; symbolIndex++) + { + Assert.False(import.Symbols[symbolIndex].Contains("lua", StringComparison.OrdinalIgnoreCase), + $"The bridge must not import a Lua symbol ('{import.Symbols[symbolIndex]}')."); + } + } + + foreach (string moduleName in s_allowedImportModules) + { + Assert.Contains(moduleName, observedModules, StringComparer.OrdinalIgnoreCase); + } + } + + [Fact] + public void Audit_manifest_records_the_pinned_toolchain_flags_and_hashes() + { + using JsonDocument manifest = ReadManifest(); + JsonElement root = manifest.RootElement; + + Assert.Equal(2, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("xmake", root.GetProperty("toolchain").GetProperty("buildSystem").GetProperty("name").GetString()); + Assert.Equal("3.0.9", + root.GetProperty("toolchain").GetProperty("buildSystem").GetProperty("version").GetString()); + Assert.Equal("MSVC", root.GetProperty("toolchain").GetProperty("compiler").GetProperty("name").GetString()); + Assert.Equal("C11", root.GetProperty("toolchain").GetProperty("languageStandard").GetString()); + Assert.Equal("MT", root.GetProperty("toolchain").GetProperty("cRuntime").GetString()); + AssertJsonStringSet(root.GetProperty("toolchain").GetProperty("compilerFlags"), ["/MT", "allextra", "error"]); + AssertJsonStringSet(root.GetProperty("toolchain").GetProperty("linkerFlags"), ["/Brepro"]); + Assert.True(root.GetProperty("reproducibility").GetProperty("doubleBuildSha256Comparison").GetBoolean()); + + JsonElement nativeAsset = root.GetProperty("nativeAsset"); + Assert.Equal("PE32+", nativeAsset.GetProperty("pe").GetProperty("format").GetString()); + Assert.Equal("AMD64", nativeAsset.GetProperty("pe").GetProperty("machine").GetString()); + Assert.True(nativeAsset.GetProperty("pe").GetProperty("isDll").GetBoolean()); + AssertJsonStringSet(nativeAsset.GetProperty("exports"), ExpectedExports); + AssertJsonStringSet(nativeAsset.GetProperty("delayImports"), []); + AssertJsonStringSet(nativeAsset.GetProperty("imports"), s_allowedImportModules); + + string sourceHash = CalculateSha256(RepositoryLayout.PathOf(SourceRelativePath)); + string buildHash = CalculateSha256(RepositoryLayout.PathOf(BuildRelativePath)); + string expectedFingerprint = $"{sourceHash}:{buildHash}"; + Assert.Equal(sourceHash, + root.GetProperty("source").GetProperty("hashes").GetProperty("cheatengine_sdk_lua_bridge.c").GetString()); + Assert.Equal(buildHash, root.GetProperty("source").GetProperty("hashes").GetProperty("xmake.lua").GetString()); + Assert.Equal(expectedFingerprint, root.GetProperty("source").GetProperty("fingerprint").GetString()); + + string bridgeHash = CalculateSha256(BridgePath); + Assert.Equal(bridgeHash, nativeAsset.GetProperty("sha256").GetString()); + Assert.Equal(expectedFingerprint, + ReadBridge().ReadExportedAsciiZ("cheatengine_sdk_lua_bridge_source_fingerprint")); + } + + private static PortableExecutableInspector ReadBridge() + { + return PortableExecutableInspector.Read(BridgePath); + } + + private static JsonDocument ReadManifest() + { + return JsonDocument.Parse(File.ReadAllText(RepositoryLayout.PathOf(ManifestRelativePath))); + } + + private static List GetExportNames(PortableExecutableInspector image) + { + IReadOnlyList exports = image.GetExports(); + List names = new(exports.Count); + for (int index = 0; index < exports.Count; index++) + { + names.Add(exports[index].Name); + } + + return names; + } + + private static void AssertExactSet(string[] expected, List actual) + { + Assert.Equal(expected.Length, actual.Count); + HashSet actualValues = new(actual, StringComparer.Ordinal); + Assert.Equal(actual.Count, actualValues.Count); + for (int index = 0; index < expected.Length; index++) + { + Assert.Contains(expected[index], actualValues, StringComparer.Ordinal); + } + } + + private static void AssertJsonStringSet(JsonElement array, string[] expected) + { + Assert.Equal(JsonValueKind.Array, array.ValueKind); + Assert.Equal(expected.Length, array.GetArrayLength()); + HashSet values = new(StringComparer.Ordinal); + foreach (JsonElement entry in array.EnumerateArray()) + { + values.Add(entry.GetString() ?? string.Empty); + } + + Assert.Equal(expected.Length, values.Count); + for (int index = 0; index < expected.Length; index++) + { + Assert.Contains(expected[index], values, StringComparer.Ordinal); + } + } + + private static string CalculateSha256(string path) + { + return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs index 349d7a28..2d069655 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs @@ -11,19 +11,19 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class NuspecDependencyTests(PackagedUmbrellaFixture fixture) { - [Fact] - public void Packed_nuspec_declares_no_CheatEngine_SDK_dependency() - { - Assert.DoesNotContain(fixture.NuspecDependencyIds, - id => id.StartsWith(UmbrellaPackage.Id, StringComparison.OrdinalIgnoreCase)); - } + [Fact] + public void Packed_nuspec_declares_no_CheatEngine_SDK_dependency() + { + Assert.DoesNotContain(fixture.NuspecDependencyIds, + id => id.StartsWith(UmbrellaPackage.Id, StringComparison.OrdinalIgnoreCase)); + } - [Fact] - public void Packed_nuspec_declares_no_dependency_at_all() - { - // Stronger than the check above: the six libs are the only thing that could ever appear here (the SDK - // components take no runtime NuGet dependency of their own, see eng/RoslynComponent.props), so the nuspec has - // an empty dependency group, not merely one without a CheatEngine.SDK.* entry. - Assert.Empty(fixture.NuspecDependencyIds); - } + [Fact] + public void Packed_nuspec_declares_no_dependency_at_all() + { + // Stronger than the check above: the six libs are the only thing that could ever appear here (the SDK + // components take no runtime NuGet dependency of their own, see eng/RoslynComponent.props), so the nuspec has + // an empty dependency group, not merely one without a CheatEngine.SDK.* entry. + Assert.Empty(fixture.NuspecDependencyIds); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs index e8e143c0..e86e6cc5 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs @@ -11,74 +11,74 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class PackageContentsTests(PackagedUmbrellaFixture fixture) { - private static readonly string[] ExpectedLibraries = - [ - "CheatEngine.SDK.dll", "CheatEngine.SDK.Abi.dll", "CheatEngine.SDK.Annotations.dll", - "CheatEngine.SDK.Engine.dll", "CheatEngine.SDK.Hosting.dll", "CheatEngine.SDK.Lua.dll", - "CheatEngine.SDK.Lua.Interop.dll" - ]; + private static readonly string[] ExpectedLibraries = + [ + "CheatEngine.SDK.dll", "CheatEngine.SDK.Abi.dll", "CheatEngine.SDK.Annotations.dll", + "CheatEngine.SDK.Engine.dll", "CheatEngine.SDK.Hosting.dll", "CheatEngine.SDK.Lua.dll", + "CheatEngine.SDK.Lua.Interop.dll" + ]; - private static readonly string[] ExpectedAnalyzers = - [ - "CheatEngine.SDK.Analyzers.dll", "CheatEngine.SDK.Analyzers.CodeFixes.dll", - "CheatEngine.SDK.SourceGenerators.EntryPoint.dll", "CheatEngine.SDK.SourceGenerators.LuaBindings.dll", - "CheatEngine.SDK.SourceGenerators.Shared.dll", - ]; + private static readonly string[] ExpectedAnalyzers = + [ + "CheatEngine.SDK.Analyzers.dll", "CheatEngine.SDK.Analyzers.CodeFixes.dll", + "CheatEngine.SDK.SourceGenerators.EntryPoint.dll", "CheatEngine.SDK.SourceGenerators.LuaBindings.dll", + "CheatEngine.SDK.SourceGenerators.Shared.dll" + ]; - public static TheoryData LibraryNames => [.. ExpectedLibraries]; + public static TheoryData LibraryNames => [.. ExpectedLibraries]; - public static TheoryData AnalyzerNames => [.. ExpectedAnalyzers]; + public static TheoryData AnalyzerNames => [.. ExpectedAnalyzers]; - [Theory] - [MemberData(nameof(LibraryNames))] - public void Every_embedded_library_is_under_lib_net10_0_with_its_xml_docs(string libraryName) - { - Assert.Contains($"lib/net10.0/{libraryName}", fixture.PackageEntries, StringComparer.Ordinal); - Assert.Contains($"lib/net10.0/{Path.GetFileNameWithoutExtension(libraryName)}.xml", fixture.PackageEntries, - StringComparer.Ordinal); - } + [Theory] + [MemberData(nameof(LibraryNames))] + public void Every_embedded_library_is_under_lib_net10_0_with_its_xml_docs(string libraryName) + { + Assert.Contains($"lib/net10.0/{libraryName}", fixture.PackageEntries, StringComparer.Ordinal); + Assert.Contains($"lib/net10.0/{Path.GetFileNameWithoutExtension(libraryName)}.xml", fixture.PackageEntries, + StringComparer.Ordinal); + } - [Theory] - [MemberData(nameof(AnalyzerNames))] - public void Every_shipping_component_is_under_analyzers_dotnet_cs(string analyzerName) - { - Assert.Contains($"analyzers/dotnet/cs/{analyzerName}", fixture.PackageEntries, StringComparer.Ordinal); - } + [Theory] + [MemberData(nameof(AnalyzerNames))] + public void Every_shipping_component_is_under_analyzers_dotnet_cs(string analyzerName) + { + Assert.Contains($"analyzers/dotnet/cs/{analyzerName}", fixture.PackageEntries, StringComparer.Ordinal); + } - [Fact] - public void EngineApi_generator_is_not_packed() - { - Assert.DoesNotContain(fixture.PackageEntries, - entry => entry.Contains("EngineApi", StringComparison.OrdinalIgnoreCase)); - } + [Fact] + public void EngineApi_generator_is_not_packed() + { + Assert.DoesNotContain(fixture.PackageEntries, + entry => entry.Contains("EngineApi", StringComparison.OrdinalIgnoreCase)); + } - [Fact] - public void Analyzers_directory_holds_the_active_components_and_their_shared_dependency() - { - string[] underAnalyzers = - [ - .. fixture.PackageEntries.Where(static e => e.StartsWith("analyzers/dotnet/cs/", StringComparison.Ordinal)) - ]; - Assert.Equivalent(ExpectedAnalyzers.Select(static name => $"analyzers/dotnet/cs/{name}"), underAnalyzers); - } + [Fact] + public void Analyzers_directory_holds_the_active_components_and_their_shared_dependency() + { + string[] underAnalyzers = + [ + .. fixture.PackageEntries.Where(static e => e.StartsWith("analyzers/dotnet/cs/", StringComparison.Ordinal)) + ]; + Assert.Equivalent(ExpectedAnalyzers.Select(static name => $"analyzers/dotnet/cs/{name}"), underAnalyzers); + } - [Fact] - public void Package_carries_direct_consumer_build_assets_only() - { - // NuGet imports build/.* by package id alone. Omission of buildTransitive is intentional: an indirect - // dependency must not activate the generator, alter compiler properties, or copy deployment files. - Assert.Contains($"build/{UmbrellaPackage.Id}.props", fixture.PackageEntries, StringComparer.Ordinal); - Assert.Contains($"build/{UmbrellaPackage.Id}.targets", fixture.PackageEntries, StringComparer.Ordinal); - Assert.Contains("build/native/cheatengine-sdk-lua-bridge.dll", fixture.PackageEntries, StringComparer.Ordinal); - Assert.DoesNotContain(fixture.PackageEntries, - static entry => entry.StartsWith("buildTransitive/", StringComparison.Ordinal)); - Assert.DoesNotContain("runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll", fixture.PackageEntries, - StringComparer.Ordinal); - } + [Fact] + public void Package_carries_direct_consumer_build_assets_only() + { + // NuGet imports build/.* by package id alone. Omission of buildTransitive is intentional: an indirect + // dependency must not activate the generator, alter compiler properties, or copy deployment files. + Assert.Contains($"build/{UmbrellaPackage.Id}.props", fixture.PackageEntries, StringComparer.Ordinal); + Assert.Contains($"build/{UmbrellaPackage.Id}.targets", fixture.PackageEntries, StringComparer.Ordinal); + Assert.Contains("build/native/cheatengine-sdk-lua-bridge.dll", fixture.PackageEntries, StringComparer.Ordinal); + Assert.DoesNotContain(fixture.PackageEntries, + static entry => entry.StartsWith("buildTransitive/", StringComparison.Ordinal)); + Assert.DoesNotContain("runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll", fixture.PackageEntries, + StringComparer.Ordinal); + } - [Fact] - public void Package_carries_its_readme() - { - Assert.Contains("README.md", fixture.PackageEntries, StringComparer.Ordinal); - } + [Fact] + public void Package_carries_its_readme() + { + Assert.Contains("README.md", fixture.PackageEntries, StringComparer.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs index 1a55b444..b3f9a541 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs @@ -9,93 +9,93 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class PackedConsumerBindingTests(PackagedUmbrellaFixture fixture) { - private static readonly string[] ExpectedLibNet10Assets = - [ - "lib/net10.0/CheatEngine.SDK.dll", - "lib/net10.0/CheatEngine.SDK.xml", - "lib/net10.0/CheatEngine.SDK.Abi.dll", - "lib/net10.0/CheatEngine.SDK.Abi.xml", - "lib/net10.0/CheatEngine.SDK.Annotations.dll", - "lib/net10.0/CheatEngine.SDK.Annotations.xml", - "lib/net10.0/CheatEngine.SDK.Engine.dll", - "lib/net10.0/CheatEngine.SDK.Engine.xml", - "lib/net10.0/CheatEngine.SDK.Hosting.dll", - "lib/net10.0/CheatEngine.SDK.Hosting.xml", - "lib/net10.0/CheatEngine.SDK.Lua.dll", - "lib/net10.0/CheatEngine.SDK.Lua.xml", - "lib/net10.0/CheatEngine.SDK.Lua.Interop.dll", - "lib/net10.0/CheatEngine.SDK.Lua.Interop.xml", - ]; + private static readonly string[] ExpectedLibNet10Assets = + [ + "lib/net10.0/CheatEngine.SDK.dll", + "lib/net10.0/CheatEngine.SDK.xml", + "lib/net10.0/CheatEngine.SDK.Abi.dll", + "lib/net10.0/CheatEngine.SDK.Abi.xml", + "lib/net10.0/CheatEngine.SDK.Annotations.dll", + "lib/net10.0/CheatEngine.SDK.Annotations.xml", + "lib/net10.0/CheatEngine.SDK.Engine.dll", + "lib/net10.0/CheatEngine.SDK.Engine.xml", + "lib/net10.0/CheatEngine.SDK.Hosting.dll", + "lib/net10.0/CheatEngine.SDK.Hosting.xml", + "lib/net10.0/CheatEngine.SDK.Lua.dll", + "lib/net10.0/CheatEngine.SDK.Lua.xml", + "lib/net10.0/CheatEngine.SDK.Lua.Interop.dll", + "lib/net10.0/CheatEngine.SDK.Lua.Interop.xml" + ]; - private static readonly string[] ExpectedBuildAssets = - [ - "build/CheatEngine.SDK.props", - "build/CheatEngine.SDK.targets", - "build/native/cheatengine-sdk-lua-bridge.dll", - ]; + private static readonly string[] ExpectedBuildAssets = + [ + "build/CheatEngine.SDK.props", + "build/CheatEngine.SDK.targets", + "build/native/cheatengine-sdk-lua-bridge.dll" + ]; - [Fact] - public void Packed_runtime_consumer_executes_generated_bindings_and_preserves_lua_failure_origin() - { - Assert.True(fixture.PackedRuntimeConsumerRunSucceeded, - $"The packed Lua runtime consumer did not exit successfully:{Environment.NewLine}" + - fixture.PackedRuntimeConsumerRunOutput); + [Fact] + public void Packed_runtime_consumer_executes_generated_bindings_and_preserves_lua_failure_origin() + { + Assert.True(fixture.PackedRuntimeConsumerRunSucceeded, + $"The packed Lua runtime consumer did not exit successfully:{Environment.NewLine}" + + fixture.PackedRuntimeConsumerRunOutput); - Assert.Contains("SDK-022-RUNTIME-GLOBAL-MARSHALLER", fixture.PackedRuntimeConsumerRunOutput, - StringComparison.Ordinal); - Assert.Contains("SDK-022-RUNTIME-CALLBACK-MARSHALLER", fixture.PackedRuntimeConsumerRunOutput, - StringComparison.Ordinal); - Assert.Contains("SDK-022-RUNTIME-COLLISION-LEASE", fixture.PackedRuntimeConsumerRunOutput, - StringComparison.Ordinal); - Assert.Contains("SDK-022-RUNTIME-LUA-RUNTIME-ERROR", fixture.PackedRuntimeConsumerRunOutput, - StringComparison.Ordinal); - Assert.Contains("SDK-022-RUNTIME-PROOF", fixture.PackedRuntimeConsumerRunOutput, StringComparison.Ordinal); - } + Assert.Contains("SDK-022-RUNTIME-GLOBAL-MARSHALLER", fixture.PackedRuntimeConsumerRunOutput, + StringComparison.Ordinal); + Assert.Contains("SDK-022-RUNTIME-CALLBACK-MARSHALLER", fixture.PackedRuntimeConsumerRunOutput, + StringComparison.Ordinal); + Assert.Contains("SDK-022-RUNTIME-COLLISION-LEASE", fixture.PackedRuntimeConsumerRunOutput, + StringComparison.Ordinal); + Assert.Contains("SDK-022-RUNTIME-LUA-RUNTIME-ERROR", fixture.PackedRuntimeConsumerRunOutput, + StringComparison.Ordinal); + Assert.Contains("SDK-022-RUNTIME-PROOF", fixture.PackedRuntimeConsumerRunOutput, StringComparison.Ordinal); + } - [Fact] - public void Duplicate_lua_function_consumer_is_rejected_with_CESDK2005() - { - Assert.False(fixture.DuplicateLuaFunctionConsumerBuildSucceeded, - "The package-only consumer with duplicate Lua function names unexpectedly compiled."); - Assert.Contains("CESDK2005", fixture.DuplicateLuaFunctionConsumerBuildOutput, StringComparison.Ordinal); - } + [Fact] + public void Duplicate_lua_function_consumer_is_rejected_with_CESDK2005() + { + Assert.False(fixture.DuplicateLuaFunctionConsumerBuildSucceeded, + "The package-only consumer with duplicate Lua function names unexpectedly compiled."); + Assert.Contains("CESDK2005", fixture.DuplicateLuaFunctionConsumerBuildOutput, StringComparison.Ordinal); + } - [Fact] - public void Packaged_AOT_consumer_publishes_and_runs_only_as_a_standalone_executable() - { - Assert.True(fixture.PackedAotConsumerPublishSucceeded, - $"The package-only AOT consumer did not publish successfully:{Environment.NewLine}" + - fixture.PackedAotConsumerPublishOutput); - Assert.True(fixture.PackedAotConsumerRunSucceeded, - $"The published package-only AOT consumer did not exit successfully:{Environment.NewLine}" + - fixture.PackedAotConsumerRunOutput); - Assert.Contains("SDK-022-AOT-STANDALONE", fixture.PackedAotConsumerRunOutput, StringComparison.Ordinal); - Assert.Contains("SDK-022-AOT-NO-CE-HOST", fixture.PackedAotConsumerRunOutput, StringComparison.Ordinal); - } + [Fact] + public void Packaged_AOT_consumer_publishes_and_runs_only_as_a_standalone_executable() + { + Assert.True(fixture.PackedAotConsumerPublishSucceeded, + $"The package-only AOT consumer did not publish successfully:{Environment.NewLine}" + + fixture.PackedAotConsumerPublishOutput); + Assert.True(fixture.PackedAotConsumerRunSucceeded, + $"The published package-only AOT consumer did not exit successfully:{Environment.NewLine}" + + fixture.PackedAotConsumerRunOutput); + Assert.Contains("SDK-022-AOT-STANDALONE", fixture.PackedAotConsumerRunOutput, StringComparison.Ordinal); + Assert.Contains("SDK-022-AOT-NO-CE-HOST", fixture.PackedAotConsumerRunOutput, StringComparison.Ordinal); + } - [Fact] - public void Legacy_Aob_consumer_still_compiles_against_the_packed_package() - { - Assert.True(fixture.LegacyAobConsumerBuildSucceeded, - "The packed default consumer did not compile the legacy AobScanner.TryScan overload probe."); - } + [Fact] + public void Legacy_Aob_consumer_still_compiles_against_the_packed_package() + { + Assert.True(fixture.LegacyAobConsumerBuildSucceeded, + "The packed default consumer did not compile the legacy AobScanner.TryScan overload probe."); + } - [Fact] - public void Package_boundary_has_exact_library_and_build_assets() - { - Assert.Equal( - ExpectedLibNet10Assets.OrderBy(static entry => entry, StringComparer.Ordinal), - fixture.PackageEntries - .Where(static entry => entry.StartsWith("lib/net10.0/", StringComparison.Ordinal)) - .OrderBy(static entry => entry, StringComparer.Ordinal)); - Assert.Equal( - ExpectedBuildAssets.OrderBy(static entry => entry, StringComparer.Ordinal), - fixture.PackageEntries - .Where(static entry => entry.StartsWith("build/", StringComparison.Ordinal)) - .OrderBy(static entry => entry, StringComparer.Ordinal)); - Assert.DoesNotContain(fixture.PackageEntries, - static entry => entry.Contains("lua53-64.dll", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(fixture.PackageEntries, - static entry => entry.StartsWith("runtimes/", StringComparison.Ordinal)); - } + [Fact] + public void Package_boundary_has_exact_library_and_build_assets() + { + Assert.Equal( + ExpectedLibNet10Assets.OrderBy(static entry => entry, StringComparer.Ordinal), + fixture.PackageEntries + .Where(static entry => entry.StartsWith("lib/net10.0/", StringComparison.Ordinal)) + .OrderBy(static entry => entry, StringComparer.Ordinal)); + Assert.Equal( + ExpectedBuildAssets.OrderBy(static entry => entry, StringComparer.Ordinal), + fixture.PackageEntries + .Where(static entry => entry.StartsWith("build/", StringComparison.Ordinal)) + .OrderBy(static entry => entry, StringComparer.Ordinal)); + Assert.DoesNotContain(fixture.PackageEntries, + static entry => entry.Contains("lua53-64.dll", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(fixture.PackageEntries, + static entry => entry.StartsWith("runtimes/", StringComparison.Ordinal)); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs index 7687c47c..b7efbe18 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs @@ -10,42 +10,42 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class PlatformTargetTests(PackagedUmbrellaFixture fixture) { - public static TheoryData SupportedPlatformTargets => - [ - ("Unset", ""), - ("AnyCPU", "AnyCPU"), - ("x64", "x64"), - ]; + public static TheoryData SupportedPlatformTargets => + [ + ("Unset", ""), + ("AnyCPU", "AnyCPU"), + ("x64", "x64") + ]; - public static TheoryData UnsupportedPlatformTargets => - [ - ("x86", "x86"), - ("ARM", "ARM"), - ("ARM64", "ARM64"), - ("Itanium", "Itanium"), - ("Unsupported", "Unsupported"), - ]; + public static TheoryData UnsupportedPlatformTargets => + [ + ("x86", "x86"), + ("ARM", "ARM"), + ("ARM64", "ARM64"), + ("Itanium", "Itanium"), + ("Unsupported", "Unsupported") + ]; - [Theory] - [MemberData(nameof(SupportedPlatformTargets))] - public void Direct_supported_platform_target_is_accepted_by_the_packaged_target(string platformTarget, - string expectedEffectiveValue) - { - Assert.Equal(expectedEffectiveValue, fixture.PlatformTargetConsumerEffectiveValues[platformTarget]); - Assert.True(fixture.PlatformTargetConsumerBuildSucceeded[platformTarget], - $"A direct CheatEngine.SDK package consumer with PlatformTarget={platformTarget} failed unexpectedly:{Environment.NewLine}" + - fixture.PlatformTargetConsumerBuildOutput[platformTarget]); - } + [Theory] + [MemberData(nameof(SupportedPlatformTargets))] + public void Direct_supported_platform_target_is_accepted_by_the_packaged_target(string platformTarget, + string expectedEffectiveValue) + { + Assert.Equal(expectedEffectiveValue, fixture.PlatformTargetConsumerEffectiveValues[platformTarget]); + Assert.True(fixture.PlatformTargetConsumerBuildSucceeded[platformTarget], + $"A direct CheatEngine.SDK package consumer with PlatformTarget={platformTarget} failed unexpectedly:{Environment.NewLine}" + + fixture.PlatformTargetConsumerBuildOutput[platformTarget]); + } - [Theory] - [MemberData(nameof(UnsupportedPlatformTargets))] - public void Direct_unsupported_platform_target_is_rejected_by_the_packaged_target(string platformTarget, - string expectedEffectiveValue) - { - Assert.Equal(expectedEffectiveValue, fixture.PlatformTargetConsumerEffectiveValues[platformTarget]); - Assert.False(fixture.PlatformTargetConsumerBuildSucceeded[platformTarget], - $"A direct CheatEngine.SDK package consumer with PlatformTarget={platformTarget} unexpectedly built successfully."); - Assert.Contains("CESDK9101", fixture.PlatformTargetConsumerBuildOutput[platformTarget], - StringComparison.Ordinal); - } + [Theory] + [MemberData(nameof(UnsupportedPlatformTargets))] + public void Direct_unsupported_platform_target_is_rejected_by_the_packaged_target(string platformTarget, + string expectedEffectiveValue) + { + Assert.Equal(expectedEffectiveValue, fixture.PlatformTargetConsumerEffectiveValues[platformTarget]); + Assert.False(fixture.PlatformTargetConsumerBuildSucceeded[platformTarget], + $"A direct CheatEngine.SDK package consumer with PlatformTarget={platformTarget} unexpectedly built successfully."); + Assert.Contains("CESDK9101", fixture.PlatformTargetConsumerBuildOutput[platformTarget], + StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs index 3b5c278c..13e7d62a 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs @@ -17,29 +17,29 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class RestoreIsolationTests(PackagedUmbrellaFixture fixture) { - [Fact] - public void Fixture_exposes_a_packages_directory_under_its_own_temp_root() - { - Assert.True( - Directory.Exists(fixture.PackagesDirectory), - $"Expected an isolated packages directory at '{fixture.PackagesDirectory}'."); - Assert.Contains("cheatengine-sdk-umbrella-tests-", fixture.PackagesDirectory, StringComparison.Ordinal); - } + [Fact] + public void Fixture_exposes_a_packages_directory_under_its_own_temp_root() + { + Assert.True( + Directory.Exists(fixture.PackagesDirectory), + $"Expected an isolated packages directory at '{fixture.PackagesDirectory}'."); + Assert.Contains("cheatengine-sdk-umbrella-tests-", fixture.PackagesDirectory, StringComparison.Ordinal); + } - [Fact] - public void Consumer_restore_extracts_the_packed_version_into_the_isolated_packages_directory() - { - // Proves the restore actually used --packages (not merely that the directory exists): NuGet only creates - // '/cheatengine.sdk/' (the package id, lower-cased) as a side effect of - // extracting the package there. If a future change dropped '--packages ""' from - // ThrowawayConsumer.RestoreAsync, this directory would stay empty while restore quietly fell back to the - // machine-wide global-packages folder instead. - var extractedPackageDirectory = - Path.Combine(fixture.PackagesDirectory, UmbrellaPackage.ExtractionFolderName, fixture.PackageVersion); - Assert.True( - Directory.Exists(extractedPackageDirectory), - $"Expected the consumer restore to extract '{UmbrellaPackage.ExtractionFolderName}/{fixture.PackageVersion}' " + - $"into the fixture's isolated packages directory ('{extractedPackageDirectory}'), not the machine-wide " + - "global-packages folder."); - } + [Fact] + public void Consumer_restore_extracts_the_packed_version_into_the_isolated_packages_directory() + { + // Proves the restore actually used --packages (not merely that the directory exists): NuGet only creates + // '/cheatengine.sdk/' (the package id, lower-cased) as a side effect of + // extracting the package there. If a future change dropped '--packages ""' from + // ThrowawayConsumer.RestoreAsync, this directory would stay empty while restore quietly fell back to the + // machine-wide global-packages folder instead. + string extractedPackageDirectory = + Path.Combine(fixture.PackagesDirectory, UmbrellaPackage.ExtractionFolderName, fixture.PackageVersion); + Assert.True( + Directory.Exists(extractedPackageDirectory), + $"Expected the consumer restore to extract '{UmbrellaPackage.ExtractionFolderName}/{fixture.PackageVersion}' " + + $"into the fixture's isolated packages directory ('{extractedPackageDirectory}'), not the machine-wide " + + "global-packages folder."); + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs index 20ea24e6..80ea7169 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs @@ -6,9 +6,9 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class TargetBoundAllocationConsumerTests(PackagedUmbrellaFixture fixture) { - [Fact] - public void Packed_consumer_compiles_an_independent_target_bound_allocation_backend() - { - Assert.True(File.Exists(Path.Combine(fixture.DefaultDeploymentDirectory, "DefaultConsumer.dll"))); - } + [Fact] + public void Packed_consumer_compiles_an_independent_target_bound_allocation_backend() + { + Assert.True(File.Exists(Path.Combine(fixture.DefaultDeploymentDirectory, "DefaultConsumer.dll"))); + } } diff --git a/tests/CheatEngine.SDK.Tests/README.md b/tests/CheatEngine.SDK.Tests/README.md index 48c2b594..78d40ae0 100644 --- a/tests/CheatEngine.SDK.Tests/README.md +++ b/tests/CheatEngine.SDK.Tests/README.md @@ -17,7 +17,8 @@ A project reference proves that the source compiles, not that the installed pack 1. One collection fixture (`PackagedUmbrellaFixture`) packs `src/CheatEngine.SDK/CheatEngine.SDK.csproj` in Release into a temporary local feed. The package id, that project path and the lower-cased id NuGet uses as the extraction folder name live in one place, `UmbrellaPackage`. -2. It restores the consumers below and builds them in Release. Every consumer shares the fixture-local package directory, +2. It restores the consumers below and builds them in Release. Every consumer shares the fixture-local package + directory, which is isolated from other fixture runs. 3. The generated `NuGet.Config` maps the exact `CheatEngine.SDK` package id to the freshly packed local feed, never an earlier NuGet extraction or another package source. Nuget.org remains available for other package ids. The packed Lua @@ -25,21 +26,21 @@ A project reference proves that the source compiles, not that the installed pack 4. A separate duplicate-name diagnostic source is built only to verify its intended compiler diagnostic. 5. The tests read the `.nupkg` and, per consumer, what the table lists. -| Consumer | What it sets | What the tests read | -|-----------------------------------------------------------|-------------------------------------------------------------|-----------------------------------------------------------------------------------| -| `DefaultConsumer` | Nothing, so it takes every package default | Build properties, entry point, atomic build/publish deployment folder | -| `ExplicitUnsafeFalseConsumer` | `AllowUnsafeBlocks=false` | `AllowUnsafeBlocks` | -| `LuaFunctionOptInConsumer` | `[LuaFunction]` + `AllowUnsafeBlocks=true` | The documented explicit unsafe opt-in compiles the generated registration thunk | -| `LuaFunctionWithoutUnsafeConsumer` | `[LuaFunction]`, no unsafe opt-in | The package analyzer rejects the project with `CESDK2001` | -| Packed Lua runtime consumer | Executable, restored only from the fresh local feed | Runs generated bindings against the checked-in offline Lua 5.3 fixture: custom and generic marshalling, the generated callback/lease-collision contract, and the factual origin of a failing `LuaStatus` | -| Duplicate-name diagnostic source | Two otherwise valid Lua exports with one name | The package analyzer rejects the source with `CESDK2005` | -| Package-only AOT publication consumer | Temporary executable published trimmed with Native AOT | The published standalone executable runs; this is not evidence that Cheat Engine can load, host, or unload an AOT plugin | -| `EntryPointOffConsumer` | `CheatEngineSdkGenerateEntryPoint=false` + manual bootstrap | The author-owned entry point | -| `IndirectConsumer` | Only a reference to a temporary relay pkg | Direct-only build properties, bootstrap and native bridge stay absent | -| `UnsetPlatformTargetConsumer` | `PlatformTarget` empty | The direct package target accepts the host-selected x64 architecture | -| `AnyCpuPlatformTargetConsumer` | `PlatformTarget=AnyCPU` | The direct package target accepts a managed library loadable in the x64 CE host | -| `X64PlatformTargetConsumer` | `PlatformTarget=x64` | The direct package target accepts the explicit supported architecture | -| `X86/Arm/Arm64/Itanium/UnsupportedPlatformTargetConsumer` | Explicit unsupported target | The direct package target rejects every unsupported architecture with `CESDK9101` | +| Consumer | What it sets | What the tests read | +|-----------------------------------------------------------|-------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `DefaultConsumer` | Nothing, so it takes every package default | Build properties, entry point, atomic build/publish deployment folder | +| `ExplicitUnsafeFalseConsumer` | `AllowUnsafeBlocks=false` | `AllowUnsafeBlocks` | +| `LuaFunctionOptInConsumer` | `[LuaFunction]` + `AllowUnsafeBlocks=true` | The documented explicit unsafe opt-in compiles the generated registration thunk | +| `LuaFunctionWithoutUnsafeConsumer` | `[LuaFunction]`, no unsafe opt-in | The package analyzer rejects the project with `CESDK2001` | +| Packed Lua runtime consumer | Executable, restored only from the fresh local feed | Runs generated bindings against the checked-in offline Lua 5.3 fixture: custom and generic marshalling, the generated callback/lease-collision contract, and the factual origin of a failing `LuaStatus` | +| Duplicate-name diagnostic source | Two otherwise valid Lua exports with one name | The package analyzer rejects the source with `CESDK2005` | +| Package-only AOT publication consumer | Temporary executable published trimmed with Native AOT | The published standalone executable runs; this is not evidence that Cheat Engine can load, host, or unload an AOT plugin | +| `EntryPointOffConsumer` | `CheatEngineSdkGenerateEntryPoint=false` + manual bootstrap | The author-owned entry point | +| `IndirectConsumer` | Only a reference to a temporary relay pkg | Direct-only build properties, bootstrap and native bridge stay absent | +| `UnsetPlatformTargetConsumer` | `PlatformTarget` empty | The direct package target accepts the host-selected x64 architecture | +| `AnyCpuPlatformTargetConsumer` | `PlatformTarget=AnyCPU` | The direct package target accepts a managed library loadable in the x64 CE host | +| `X64PlatformTargetConsumer` | `PlatformTarget=x64` | The direct package target accepts the explicit supported architecture | +| `X86/Arm/Arm64/Itanium/UnsupportedPlatformTargetConsumer` | Explicit unsupported target | The direct package target rejects every unsupported architecture with `CESDK9101` | The build consumers are `net10.0` class libraries with one valid plugin class, in a temporary directory outside the repository. The packed Lua runtime consumer and package-only AOT publication consumer are temporary executables. The @@ -85,7 +86,8 @@ dotnet test --project tests/CheatEngine.SDK.Tests does not receive the direct package defaults. - A temporary real relay package cannot propagate the defaults, generator or native bridge to its own consumer (`DirectReferenceIsolationTests`). -- The packed Lua runtime consumer restores the exact `CheatEngine.SDK` id from this run's local feed into package storage +- The packed Lua runtime consumer restores the exact `CheatEngine.SDK` id from this run's local feed into package + storage shared by that fixture's consumers; nuget.org remains available only for other ids. It then executes against the checked-in offline Lua 5.3 fixture. It checks generated custom and generic binding marshalling, the generated callback/lease-collision contract, and that a failed generated call preserves the factual LuaStatus diff --git a/tests/native-abi-fixture/README.md b/tests/native-abi-fixture/README.md index ccbe1bd7..3d839c6b 100644 --- a/tests/native-abi-fixture/README.md +++ b/tests/native-abi-fixture/README.md @@ -7,8 +7,10 @@ classic plugin exports, loads that DLL back through `GetProcAddress`, and emits ## Evidence boundary The source is deliberately pinned to the upstream revision -[`ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`](https://github.com/cheat-engine/cheat-engine/tree/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37): -[`Cheat Engine/plugin/cepluginsdk.h`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin/cepluginsdk.h). +[ +`ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`](https://github.com/cheat-engine/cheat-engine/tree/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37): +[ +`Cheat Engine/plugin/cepluginsdk.h`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin/cepluginsdk.h). `ce77_plugin_abi_contract.h` transcribes only the measured declaration subset, with source line ranges in its header; it does not vendor the upstream header or any Cheat Engine binary.