diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4c5c635 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,310 @@ +name: Build + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + macos: + name: macOS Release + runs-on: macos-14 + permissions: + contents: write + env: + # Whether a Developer ID signing identity is available in this run. + # We set it from the presence of the certificate secret so PR builds + # from forks (which don't see secrets) still produce an unsigned app + # instead of failing the whole pipeline. + HAVE_SIGNING: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 != '' }} + HAVE_NOTARY: ${{ secrets.MACOS_NOTARIZATION_APPLE_ID != '' && secrets.MACOS_NOTARIZATION_TEAM_ID != '' && secrets.MACOS_NOTARIZATION_PASSWORD != '' }} + steps: + - uses: actions/checkout@v4 + + - name: Xcode version + run: xcodebuild -version + + - name: Import signing certificate + if: env.HAVE_SIGNING == 'true' + env: + CERT_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PASSWORD="$(uuidgen)" + CERT_PATH="$RUNNER_TEMP/cert.p12" + + echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" \ + -P "$CERT_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | xargs) + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + rm -f "$CERT_PATH" + + echo "--- All codesigning identities in the imported keychain ---" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true + echo "--- All certificates in the imported keychain ---" + security find-certificate -a "$KEYCHAIN_PATH" | grep -E '"labl"|"subj"' || true + echo "-----------------------------------------------------------" + + IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | \ + awk -F'"' '/Developer ID Application/ { print $2; exit }') + if [ -z "$IDENTITY" ]; then + echo "::error::Developer ID Application identity (with matching private key) not found in imported keychain." + echo "::error::The .p12 most likely contained only the certificate, or the cert is not a Developer ID Application type." + echo "::error::Re-export from Keychain Access selecting BOTH the certificate AND the private key underneath it (Export 2 items)." + exit 1 + fi + # Team ID lives in the identity's Common Name between the final parens, + # e.g. "Developer ID Application: Azimuth Systems LLC (D96ZZ6AWJZ)". + # The xcodeproj files carry the upstream maintainer's DEVELOPMENT_TEAM, + # so we must override it with the team that owns the signing cert — + # otherwise codesign errors with "No certificate for team X matching …". + TEAM_ID=$(echo "$IDENTITY" | sed -n 's/.*(\([^)]*\)).*/\1/p') + if [ -z "$TEAM_ID" ]; then + echo "::error::Could not parse Team ID out of identity: $IDENTITY" + exit 1 + fi + echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "SIGN_TEAM_ID=$TEAM_ID" >> "$GITHUB_ENV" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + echo "Imported signing identity: $IDENTITY (team $TEAM_ID)" + + - name: Test SwiftTA-Core + # Run the Swift Package tests before the Xcode builds so writer / + # round-trip regressions surface immediately instead of being + # masked by a downstream build that happens to still compile. + run: | + cd SwiftTA-Core + swift test + + - name: Resolve Swift packages + run: | + xcodebuild -workspace SwiftTA.xcworkspace \ + -scheme TAassets \ + -resolvePackageDependencies + + - name: Build TAassets (Release) + run: | + if [ "$HAVE_SIGNING" = "true" ]; then + xcodebuild \ + -workspace SwiftTA.xcworkspace \ + -scheme TAassets \ + -destination 'platform=macOS' \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + DEVELOPMENT_TEAM="$SIGN_TEAM_ID" \ + CODE_SIGN_ENTITLEMENTS="$GITHUB_WORKSPACE/ci/release.entitlements" \ + ENABLE_HARDENED_RUNTIME=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ + build + else + xcodebuild \ + -workspace SwiftTA.xcworkspace \ + -scheme TAassets \ + -destination 'platform=macOS' \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build + fi + + - name: Build HPIView (Release) + run: | + if [ "$HAVE_SIGNING" = "true" ]; then + xcodebuild \ + -workspace SwiftTA.xcworkspace \ + -scheme HPIView \ + -destination 'platform=macOS' \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + DEVELOPMENT_TEAM="$SIGN_TEAM_ID" \ + CODE_SIGN_ENTITLEMENTS="$GITHUB_WORKSPACE/ci/release.entitlements" \ + ENABLE_HARDENED_RUNTIME=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ + build + else + xcodebuild \ + -workspace SwiftTA.xcworkspace \ + -scheme HPIView \ + -destination 'platform=macOS' \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build + fi + + - name: Build AEX-MapEditor (Release) + run: | + if [ "$HAVE_SIGNING" = "true" ]; then + xcodebuild \ + -workspace SwiftTA.xcworkspace \ + -scheme AEX-MapEditor \ + -destination 'platform=macOS' \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + DEVELOPMENT_TEAM="$SIGN_TEAM_ID" \ + CODE_SIGN_ENTITLEMENTS="$GITHUB_WORKSPACE/ci/release.entitlements" \ + ENABLE_HARDENED_RUNTIME=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ + build + else + xcodebuild \ + -workspace SwiftTA.xcworkspace \ + -scheme AEX-MapEditor \ + -destination 'platform=macOS' \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build + fi + + - name: Notarize and staple + if: env.HAVE_SIGNING == 'true' && env.HAVE_NOTARY == 'true' + env: + NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARIZATION_APPLE_ID }} + NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARIZATION_TEAM_ID }} + NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }} + run: | + set -euo pipefail + PRODUCTS=build/DerivedData/Build/Products/Release + + for APP in TAassets HPIView AEX-MapEditor; do + APP_PATH="$PRODUCTS/$APP.app" + ZIP_FOR_NOTARY="$RUNNER_TEMP/$APP-notary.zip" + + echo "::group::Notarize $APP" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_FOR_NOTARY" + + # Submit with JSON output so we can read the status even when + # it's Invalid (notarytool returns 0 in that case — it only + # non-zeros on transport failures). + SUBMIT_JSON=$(xcrun notarytool submit "$ZIP_FOR_NOTARY" \ + --apple-id "$NOTARY_APPLE_ID" \ + --team-id "$NOTARY_TEAM_ID" \ + --password "$NOTARY_PASSWORD" \ + --wait \ + --output-format json) + echo "$SUBMIT_JSON" + + SUBMISSION_ID=$(echo "$SUBMIT_JSON" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -n1) + STATUS=$(echo "$SUBMIT_JSON" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n1) + + # Always pull the log — on Accepted it's empty/informational, on + # Invalid it tells us exactly which binary Apple rejected and why. + echo "--- notarytool log for $APP ($SUBMISSION_ID) ---" + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$NOTARY_APPLE_ID" \ + --team-id "$NOTARY_TEAM_ID" \ + --password "$NOTARY_PASSWORD" \ + 2>&1 || true + echo "--- end notarytool log ---" + + if [ "$STATUS" != "Accepted" ]; then + echo "::error::Notarization for $APP returned status: $STATUS (submission $SUBMISSION_ID)" + exit 1 + fi + + xcrun stapler staple "$APP_PATH" + xcrun stapler validate "$APP_PATH" + rm -f "$ZIP_FOR_NOTARY" + echo "::endgroup::" + done + + - name: Package .app bundles + run: | + mkdir -p dist + PRODUCTS=build/DerivedData/Build/Products/Release + # ditto preserves resource forks / extended attributes on the .app + # bundle better than zip -r and matches what notarization expects. + ditto -c -k --sequesterRsrc --keepParent "$PRODUCTS/TAassets.app" dist/TAassets-macOS.zip + ditto -c -k --sequesterRsrc --keepParent "$PRODUCTS/HPIView.app" dist/HPIView-macOS.zip + ditto -c -k --sequesterRsrc --keepParent "$PRODUCTS/AEX-MapEditor.app" dist/AEX-MapEditor-macOS.zip + ls -lh dist + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: SwiftTA-macOS-${{ github.sha }} + path: dist/*.zip + if-no-files-found: error + retention-days: 30 + + # Rolling "latest" prerelease — refreshed on every main push so anyone + # can grab a current build from the Releases page without needing a + # GitHub login or waiting for an explicit tag. Versioned tags below + # still produce their own permanent entries. + - name: Update latest prerelease + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + SHORT=$(git rev-parse --short HEAD) + if [ "$HAVE_SIGNING" = "true" ] && [ "$HAVE_NOTARY" = "true" ]; then + SIGNING_NOTE="✅ Signed and notarized by Apple — double-click to run." + elif [ "$HAVE_SIGNING" = "true" ]; then + SIGNING_NOTE="⚠️ Signed but not notarized — right-click → Open on first launch." + else + SIGNING_NOTE="⚠️ Unsigned — right-click → Open on first launch." + fi + NOTES="Rolling build from main.\n\nCommit: ${{ github.sha }}\nBuilt: $(date -u +%Y-%m-%dT%H:%M:%SZ)\n\n$SIGNING_NOTE" + gh release delete latest --yes --cleanup-tag 2>/dev/null || true + gh release create latest \ + dist/TAassets-macOS.zip \ + dist/HPIView-macOS.zip \ + dist/AEX-MapEditor-macOS.zip \ + --target "${{ github.sha }}" \ + --title "Latest main ($SHORT)" \ + --notes "$(printf '%b' "$NOTES")" \ + --prerelease + + - name: Publish versioned release (tags) + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + gh release create "$TAG" \ + dist/TAassets-macOS.zip \ + dist/HPIView-macOS.zip \ + dist/AEX-MapEditor-macOS.zip \ + --title "$TAG" \ + --generate-notes + + - name: Clean up keychain + if: always() && env.HAVE_SIGNING == 'true' + run: | + if [ -n "${KEYCHAIN_PATH:-}" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi diff --git a/.gitignore b/.gitignore index 7a11acd..628b776 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ### Misc Files Directory ### /Files +/build-logs ### Objective-C ### # Xcode diff --git a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..883a1ff --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj @@ -0,0 +1,345 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + AE0000010000000000000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000020000000000000001 /* AppDelegate.swift */; }; + AE0000030000000000000001 /* EditableMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000040000000000000001 /* EditableMap.swift */; }; + AE0000050000000000000001 /* HeightBrushCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000060000000000000001 /* HeightBrushCommand.swift */; }; + AE0000070000000000000001 /* MapCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000080000000000000001 /* MapCanvasView.swift */; }; + AE0000090000000000000001 /* MapEditorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE00000A0000000000000001 /* MapEditorWindowController.swift */; }; + AE0000220000000000000001 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000230000000000000001 /* main.swift */; }; + AE0000240000000000000001 /* ArchiveMapPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000250000000000000001 /* ArchiveMapPicker.swift */; }; + AE0000260000000000000001 /* FeatureCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000270000000000000001 /* FeatureCommand.swift */; }; + AE0000280000000000000001 /* MapRasterizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000290000000000000001 /* MapRasterizer.swift */; }; + AE00002A0000000000000001 /* TileCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE00002B0000000000000001 /* TileCommand.swift */; }; + AE00002C0000000000000001 /* PALETTE.PAL in Resources */ = {isa = PBXBuildFile; fileRef = AE00002D0000000000000001 /* PALETTE.PAL */; }; + AE00000B0000000000000001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE00000C0000000000000001 /* Assets.xcassets */; }; + AE00000F0000000000000001 /* SwiftTA-Core in Frameworks */ = {isa = PBXBuildFile; productRef = AE0000100000000000000001 /* SwiftTA-Core */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AE0000020000000000000001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AE0000040000000000000001 /* EditableMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableMap.swift; sourceTree = ""; }; + AE0000060000000000000001 /* HeightBrushCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeightBrushCommand.swift; sourceTree = ""; }; + AE0000080000000000000001 /* MapCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapCanvasView.swift; sourceTree = ""; }; + AE00000A0000000000000001 /* MapEditorWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapEditorWindowController.swift; sourceTree = ""; }; + AE0000230000000000000001 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + AE0000250000000000000001 /* ArchiveMapPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMapPicker.swift; sourceTree = ""; }; + AE0000270000000000000001 /* FeatureCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureCommand.swift; sourceTree = ""; }; + AE0000290000000000000001 /* MapRasterizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRasterizer.swift; sourceTree = ""; }; + AE00002B0000000000000001 /* TileCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileCommand.swift; sourceTree = ""; }; + AE00002D0000000000000001 /* PALETTE.PAL */ = {isa = PBXFileReference; lastKnownFileType = file; path = PALETTE.PAL; sourceTree = ""; }; + AE00000C0000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AE0000110000000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AE0000120000000000000001 /* AEX-MapEditor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AEX-MapEditor.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AE0000130000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AE00000F0000000000000001 /* SwiftTA-Core in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AE0000140000000000000001 = { + isa = PBXGroup; + children = ( + AE0000150000000000000001 /* AEX-MapEditor */, + AE0000160000000000000001 /* Products */, + ); + sourceTree = ""; + }; + AE0000150000000000000001 /* AEX-MapEditor */ = { + isa = PBXGroup; + children = ( + AE0000230000000000000001 /* main.swift */, + AE0000020000000000000001 /* AppDelegate.swift */, + AE0000250000000000000001 /* ArchiveMapPicker.swift */, + AE0000040000000000000001 /* EditableMap.swift */, + AE0000270000000000000001 /* FeatureCommand.swift */, + AE0000060000000000000001 /* HeightBrushCommand.swift */, + AE0000080000000000000001 /* MapCanvasView.swift */, + AE00000A0000000000000001 /* MapEditorWindowController.swift */, + AE0000290000000000000001 /* MapRasterizer.swift */, + AE00002B0000000000000001 /* TileCommand.swift */, + AE00002D0000000000000001 /* PALETTE.PAL */, + AE00000C0000000000000001 /* Assets.xcassets */, + AE0000110000000000000001 /* Info.plist */, + ); + path = "AEX-MapEditor"; + sourceTree = ""; + }; + AE0000160000000000000001 /* Products */ = { + isa = PBXGroup; + children = ( + AE0000120000000000000001 /* AEX-MapEditor.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AE0000180000000000000001 /* AEX-MapEditor */ = { + isa = PBXNativeTarget; + buildConfigurationList = AE0000190000000000000001 /* Build configuration list for PBXNativeTarget "AEX-MapEditor" */; + buildPhases = ( + AE00001A0000000000000001 /* Sources */, + AE0000130000000000000001 /* Frameworks */, + AE00001B0000000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "AEX-MapEditor"; + packageProductDependencies = ( + AE0000100000000000000001 /* SwiftTA-Core */, + ); + productName = "AEX-MapEditor"; + productReference = AE0000120000000000000001 /* AEX-MapEditor.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AE00001C0000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1520; + LastUpgradeCheck = 1520; + ORGANIZATIONNAME = "Azimuth Systems"; + TargetAttributes = { + AE0000180000000000000001 = { + CreatedOnToolsVersion = 15.0; + LastSwiftMigration = 1520; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = AE00001D0000000000000001 /* Build configuration list for PBXProject "AEX-MapEditor" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AE0000140000000000000001; + productRefGroup = AE0000160000000000000001 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AE0000180000000000000001 /* AEX-MapEditor */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AE00001B0000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AE00000B0000000000000001 /* Assets.xcassets in Resources */, + AE00002C0000000000000001 /* PALETTE.PAL in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AE00001A0000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AE0000220000000000000001 /* main.swift in Sources */, + AE0000010000000000000001 /* AppDelegate.swift in Sources */, + AE0000240000000000000001 /* ArchiveMapPicker.swift in Sources */, + AE0000030000000000000001 /* EditableMap.swift in Sources */, + AE0000260000000000000001 /* FeatureCommand.swift in Sources */, + AE0000280000000000000001 /* MapRasterizer.swift in Sources */, + AE00002A0000000000000001 /* TileCommand.swift in Sources */, + AE0000050000000000000001 /* HeightBrushCommand.swift in Sources */, + AE0000070000000000000001 /* MapCanvasView.swift in Sources */, + AE0000090000000000000001 /* MapEditorWindowController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AE00001E0000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.13; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AE00001F0000000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.13; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + AE0000200000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = "AEX-MapEditor/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "net.azimuthsystems.AEX-MapEditor"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AE0000210000000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = "AEX-MapEditor/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "net.azimuthsystems.AEX-MapEditor"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AE00001D0000000000000001 /* Build configuration list for PBXProject "AEX-MapEditor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE00001E0000000000000001 /* Debug */, + AE00001F0000000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AE0000190000000000000001 /* Build configuration list for PBXNativeTarget "AEX-MapEditor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE0000200000000000000001 /* Debug */, + AE0000210000000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + AE0000100000000000000001 /* SwiftTA-Core */ = { + isa = XCSwiftPackageProductDependency; + productName = "SwiftTA-Core"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = AE00001C0000000000000001 /* Project object */; +} diff --git a/SwiftTA macOS/SwiftTA macOS.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme b/AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme similarity index 73% rename from SwiftTA macOS/SwiftTA macOS.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme rename to AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme index 1845a2b..2f5aa97 100644 --- a/SwiftTA macOS/SwiftTA macOS.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme @@ -14,10 +14,10 @@ buildForAnalyzing = "YES"> + BlueprintIdentifier = "AE0000180000000000000001" + BuildableName = "AEX-MapEditor.app" + BlueprintName = "AEX-MapEditor" + ReferencedContainer = "container:AEX-MapEditor.xcodeproj"> @@ -30,10 +30,10 @@ + BlueprintIdentifier = "AE0000180000000000000001" + BuildableName = "AEX-MapEditor.app" + BlueprintName = "AEX-MapEditor" + ReferencedContainer = "container:AEX-MapEditor.xcodeproj"> @@ -53,10 +53,10 @@ runnableDebuggingMode = "0"> + BlueprintIdentifier = "AE0000180000000000000001" + BuildableName = "AEX-MapEditor.app" + BlueprintName = "AEX-MapEditor" + ReferencedContainer = "container:AEX-MapEditor.xcodeproj"> @@ -70,10 +70,10 @@ runnableDebuggingMode = "0"> + BlueprintIdentifier = "AE0000180000000000000001" + BuildableName = "AEX-MapEditor.app" + BlueprintName = "AEX-MapEditor" + ReferencedContainer = "container:AEX-MapEditor.xcodeproj"> diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift new file mode 100644 index 0000000..85a8555 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -0,0 +1,225 @@ +// +// AppDelegate.swift +// AEX-MapEditor +// +// Minimal AppKit bootstrapping. The editor is a single-window, non- +// document-based app: File → Open picks a .tnt, we spawn one window. +// Multiple open maps live as multiple windows, each with its own +// MapEditorWindowController and its own EditableMap. +// + +import Cocoa +import SwiftTA_Core + + +class AppDelegate: NSObject, NSApplicationDelegate { + + private var windowControllers: [MapEditorWindowController] = [] + + func applicationWillFinishLaunching(_ notification: Notification) { + // applicationWillFinishLaunching is the canonical spot to install a + // programmatic menu bar — AppKit reads NSApp.mainMenu once during + // the rest of startup, so setting it here is guaranteed to be + // picked up before the menu bar renders. + NSApp.setActivationPolicy(.regular) + installMainMenu() + NSLog("AEX-MapEditor: installed mainMenu with \(NSApp.mainMenu?.items.count ?? 0) top-level items") + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.activate(ignoringOtherApps: true) + } + + /// Installs a minimal menu bar programmatically rather than via a + /// MainMenu.xib. Keeps the app self-contained and avoids the + /// maintenance burden of a NIB for the six items we actually use. + private func installMainMenu() { + let mainMenu = NSMenu() + + // App menu (Apple-style per-app menu: About, Hide, Quit). + // macOS takes the first top-level item's title from the process + // name regardless of what we set, but every other top-level item + // inherits its label from NSMenuItem.title — so the File / Edit / + // Window items below each need their own explicit title. + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + appMenu.addItem(withTitle: "About AEX Map Editor", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(withTitle: "Hide AEX Map Editor", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let hideOthers = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(withTitle: "Quit AEX Map Editor", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + + // File menu. + let fileMenuItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") + mainMenu.addItem(fileMenuItem) + let fileMenu = NSMenu(title: "File") + fileMenuItem.submenu = fileMenu + fileMenu.addItem(withTitle: "Open…", action: #selector(openDocument(_:)), keyEquivalent: "o") + fileMenu.addItem(NSMenuItem.separator()) + fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + fileMenu.addItem(withTitle: "Save", action: #selector(saveDocument(_:)), keyEquivalent: "s") + let saveAs = fileMenu.addItem(withTitle: "Save As…", action: #selector(saveDocumentAs(_:)), keyEquivalent: "s") + saveAs.keyEquivalentModifierMask = [.command, .shift] + + // Edit menu (undo + redo; rest can go on later). + let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") + mainMenu.addItem(editMenuItem) + let editMenu = NSMenu(title: "Edit") + editMenuItem.submenu = editMenu + editMenu.addItem(withTitle: "Undo", action: #selector(undo(_:)), keyEquivalent: "z") + let redoItem = editMenu.addItem(withTitle: "Redo", action: #selector(redo(_:)), keyEquivalent: "z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + + // Window menu. + let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") + mainMenu.addItem(windowMenuItem) + let windowMenu = NSMenu(title: "Window") + windowMenuItem.submenu = windowMenu + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(NSMenuItem.separator()) + windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + NSApp.windowsMenu = windowMenu + + NSApp.mainMenu = mainMenu + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + // Returning true unconditionally makes the app quit the moment + // any window closes — including the File → Open panel when the + // user cancels it, which leaves nothing else on screen. Only + // terminate once a map window has actually been opened and all + // map windows are now closed. A freshly-launched app with no map + // opened yet (or one whose user cancelled the open panel) stays + // running and reachable from the menu bar / Dock. + return everOpenedAMap && windowControllers.isEmpty + } + + /// Flipped to true the first time `openMap(at:)` successfully shows a + /// window. Never flips back, so the last-map-closed shutdown rule + /// kicks in from then on. + private var everOpenedAMap = false + + @IBAction func openDocument(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowedFileTypes = ["tnt", "hpi", "ufo", "ccx", "gp3", "gpf"] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.message = "Pick a loose .tnt, or an archive (.hpi / .ufo / .ccx / .gp3 / .gpf) to browse the maps inside." + + panel.begin { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.openFromFilePicker(url) + } + } + + private func openFromFilePicker(_ url: URL) { + let ext = url.pathExtension.lowercased() + if ext == "tnt" { + openMap(at: url) + } else { + openMapFromArchive(url) + } + } + + private func openMapFromArchive(_ archiveURL: URL) { + let maps: [ArchiveMapPicker.MapEntry] + do { + maps = try ArchiveMapPicker.listMaps(in: archiveURL) + } catch { + presentError(error, contextMessage: "Couldn't read \(archiveURL.lastPathComponent)") + return + } + + if maps.isEmpty { + presentError(ArchiveMapPicker.PickerError.noMapsInArchive(archiveURL), + contextMessage: "No maps found") + return + } + + guard let choice = ArchiveMapPicker.presentPicker(for: maps, archiveName: archiveURL.lastPathComponent) else { + return + } + + let extractedURL: URL + do { + extractedURL = try ArchiveMapPicker.extract(choice, from: archiveURL) + } catch { + presentError(error, contextMessage: "Couldn't extract \(choice.name) from \(archiveURL.lastPathComponent)") + return + } + + openMap(at: extractedURL) + } + + func openMap(at url: URL) { + do { + let controller = try MapEditorWindowController(mapURL: url) + windowControllers.append(controller) + controller.showWindow(nil) + everOpenedAMap = true + NSDocumentController.shared.noteNewRecentDocumentURL(url) + + // Drop the controller from our retain set when its window closes + // so a long-running session doesn't leak every opened map. + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: controller.window, + queue: .main + ) { [weak self, weak controller] _ in + guard let self, let controller else { return } + self.windowControllers.removeAll { $0 === controller } + } + } catch { + presentError(error, contextMessage: "Couldn't open \(url.lastPathComponent)") + } + } + + func application(_ application: NSApplication, open urls: [URL]) { + for url in urls { + openMap(at: url) + } + } + + // MARK: - File menu + + @IBAction func saveDocument(_ sender: Any?) { + frontmostController()?.saveMap() + } + + @IBAction func saveDocumentAs(_ sender: Any?) { + frontmostController()?.saveMapAs() + } + + @IBAction func undo(_ sender: Any?) { + frontmostController()?.undoLastEdit() + } + + @IBAction func redo(_ sender: Any?) { + frontmostController()?.redoLastEdit() + } + + private func frontmostController() -> MapEditorWindowController? { + if let window = NSApp.keyWindow, + let controller = window.windowController as? MapEditorWindowController { + return controller + } + return windowControllers.last + } + + // MARK: - Error presentation + + private func presentError(_ error: Error, contextMessage: String) { + let alert = NSAlert() + alert.messageText = contextMessage + alert.informativeText = (error as NSError).localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/AEX-MapEditor/AEX-MapEditor/ArchiveMapPicker.swift b/AEX-MapEditor/AEX-MapEditor/ArchiveMapPicker.swift new file mode 100644 index 0000000..f69b4b4 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/ArchiveMapPicker.swift @@ -0,0 +1,163 @@ +// +// ArchiveMapPicker.swift +// AEX-MapEditor +// +// TA ships maps inside .hpi / .ufo / .ccx / .gp3 / .gpf archives, so +// the editor needs to: open an archive, enumerate the map files it +// contains, let the user pick one, and extract the .tnt (plus .ota +// sidecar if present) to a writable staging directory so the rest of +// the editor can treat it as a normal loose map. Future phases can add +// an "Export to…" flow that writes to a user-chosen folder; for MVP we +// keep extracts in Application Support and let the user Save As out of +// the editor when they want them somewhere else. +// + +import Cocoa +import SwiftTA_Core + + +enum ArchiveMapPicker { + + struct MapEntry { + /// Base name without extension — used as the map's display name. + var name: String + /// The file metadata for the .tnt entry in the archive. + var tnt: HpiItem.File + /// The companion .ota entry if present. + var ota: HpiItem.File? + } + + enum PickerError: LocalizedError { + case noMapsInArchive(URL) + case extractFailed(file: String, underlying: Error) + case couldNotCreateStagingDirectory(underlying: Error) + + var errorDescription: String? { + switch self { + case .noMapsInArchive(let url): + return "\(url.lastPathComponent) doesn't contain any map files." + case .extractFailed(let file, let underlying): + return "Failed to extract \(file) — \(underlying.localizedDescription)" + case .couldNotCreateStagingDirectory(let underlying): + return "Couldn't create a staging directory for extracted maps — \(underlying.localizedDescription)" + } + } + } + + /// Reads the archive, lists every .tnt entry, pairs each with its + /// same-basename .ota sidecar when present, and returns the result + /// sorted by map name. + static func listMaps(in archiveURL: URL) throws -> [MapEntry] { + let root = try HpiItem.loadFromArchive(contentsOf: archiveURL) + + var tntsByBase: [String: HpiItem.File] = [:] + var otasByBase: [String: HpiItem.File] = [:] + + walk(directory: root) { file in + let lowered = file.name.lowercased() + let base = (file.name as NSString).deletingPathExtension.lowercased() + if lowered.hasSuffix(".tnt") { + // First wins on collisions. Archive writers typically keep + // one definitive entry per name; if two ever collide, the + // user sees whichever appears first in the walk. + if tntsByBase[base] == nil { tntsByBase[base] = file } + } else if lowered.hasSuffix(".ota") { + if otasByBase[base] == nil { otasByBase[base] = file } + } + } + + var entries: [MapEntry] = [] + entries.reserveCapacity(tntsByBase.count) + for (base, tnt) in tntsByBase { + let displayName = (tnt.name as NSString).deletingPathExtension + entries.append(MapEntry(name: displayName, tnt: tnt, ota: otasByBase[base])) + } + entries.sort { $0.name.lowercased() < $1.name.lowercased() } + return entries + } + + /// Extracts the given map (tnt + ota when available) from the + /// archive into the staging directory and returns the URL of the + /// on-disk .tnt the editor should open next. + static func extract(_ entry: MapEntry, from archiveURL: URL) throws -> URL { + let stagingDir = try ensureStagingDirectory(for: archiveURL) + + let tntBytes: Data + do { + tntBytes = try HpiItem.extract(file: entry.tnt, fromHPI: archiveURL) + } catch { + throw PickerError.extractFailed(file: entry.tnt.name, underlying: error) + } + + let tntURL = stagingDir.appendingPathComponent(entry.name + ".tnt") + try tntBytes.write(to: tntURL, options: [.atomic]) + + if let ota = entry.ota { + do { + let otaBytes = try HpiItem.extract(file: ota, fromHPI: archiveURL) + let otaURL = stagingDir.appendingPathComponent(entry.name + ".ota") + try otaBytes.write(to: otaURL, options: [.atomic]) + } catch { + // A bad OTA shouldn't block the TNT from opening — log + // and continue. The editor will just treat the map as + // having no metadata sidecar. + NSLog("AEX-MapEditor: OTA extract failed for \(entry.name): \(error.localizedDescription)") + } + } + + return tntURL + } + + /// Modal picker dialog: presents a popup of every map in the + /// archive and returns the selection, or nil on cancel. + static func presentPicker(for maps: [MapEntry], archiveName: String) -> MapEntry? { + guard !maps.isEmpty else { return nil } + + let alert = NSAlert() + alert.messageText = "Pick a map to edit" + alert.informativeText = "\(archiveName) contains \(maps.count) map\(maps.count == 1 ? "" : "s")." + + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false) + for map in maps { + popup.addItem(withTitle: map.name) + } + alert.accessoryView = popup + + alert.addButton(withTitle: "Open") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return nil } + let index = popup.indexOfSelectedItem + guard maps.indices.contains(index) else { return nil } + return maps[index] + } + + // MARK: - Internals + + private static func walk(directory: HpiItem.Directory, visit: (HpiItem.File) -> Void) { + for item in directory.items { + switch item { + case .file(let file): visit(file) + case .directory(let sub): walk(directory: sub, visit: visit) + } + } + } + + private static func ensureStagingDirectory(for archiveURL: URL) throws -> URL { + let fm = FileManager.default + let support = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let archiveStem = archiveURL.deletingPathExtension().lastPathComponent + let stagingDir = support + .appendingPathComponent("AEX-MapEditor", isDirectory: true) + .appendingPathComponent("Extracted", isDirectory: true) + .appendingPathComponent(archiveStem, isDirectory: true) + + do { + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true, attributes: nil) + } catch { + throw PickerError.couldNotCreateStagingDirectory(underlying: error) + } + return stagingDir + } +} diff --git a/SwiftTA macOS/SwiftTA macOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/AEX-MapEditor/AEX-MapEditor/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from SwiftTA macOS/SwiftTA macOS/Assets.xcassets/AppIcon.appiconset/Contents.json rename to AEX-MapEditor/AEX-MapEditor/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/AEX-MapEditor/AEX-MapEditor/EditableMap.swift b/AEX-MapEditor/AEX-MapEditor/EditableMap.swift new file mode 100644 index 0000000..f80cdcd --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/EditableMap.swift @@ -0,0 +1,163 @@ +// +// EditableMap.swift +// AEX-MapEditor +// +// In-memory mutable wrapper around a TaMapModel. The UI layer holds +// one of these per open document, applies commands to it, and serializes +// back to disk via TaMapModel.writeTnt() on save. +// + +import Foundation +import SwiftTA_Core + + +final class EditableMap { + + /// The original URL the map was loaded from. Subsequent saves go here + /// unless the user explicitly chooses Save As. + var fileURL: URL + + /// Current in-memory state of the map, including any pending edits. + var model: TaMapModel + + /// Optional companion OTA file parsed as a TDF object graph. When + /// present, Save writes this back alongside the .tnt. Currently only + /// loaded for round-trip preservation; Phase 5 adds a form editor. + var ota: [String: TdfParser.Object]? + var otaURL: URL? + + /// Palette used to render tile graphics. For MVP we always use the + /// bundled PALETTE.PAL (the vanilla TA palette); when the editor + /// learns to load per-planet palettes they'll override this field. + var palette: Palette + + /// Becomes true the moment the first edit lands; back to false after a + /// successful save. The UI mirrors this in the window title. + private(set) var isModified: Bool = false + + init(loadingFrom url: URL) throws { + self.fileURL = url + + let bytes = try Data(contentsOf: url) + let reader = MemoryFileHandle(data: bytes, name: url.lastPathComponent) + let model = try MapModel(contentsOf: reader) + switch model { + case .ta(let ta): + self.model = ta + case .tak: + throw EditableMapError.takNotYetSupported + } + + self.palette = EditableMap.loadBundledPalette() + + // Look for a same-basename .ota sibling. Lowercased extension check + // so macOS case-preservation quirks don't skip matching sidecars. + let candidateOTA = url.deletingPathExtension().appendingPathExtension("ota") + if FileManager.default.fileExists(atPath: candidateOTA.path), + let otaBytes = try? Data(contentsOf: candidateOTA) { + self.ota = TdfParser.extractAll(from: otaBytes) + self.otaURL = candidateOTA + } + } + + func markModified() { + isModified = true + } + + func saveToCurrentLocation() throws { + try save(to: fileURL, otaTo: otaURL) + } + + func save(to tntURL: URL, otaTo explicitOtaURL: URL?) throws { + let tntBytes = try model.writeTnt() + try writeAtomic(tntBytes, to: tntURL, createBackup: true) + + if let ota = ota { + let otaTarget = explicitOtaURL ?? tntURL.deletingPathExtension().appendingPathExtension("ota") + let otaText = ota.serializeAsTdf() + try writeAtomic(Data(otaText.utf8), to: otaTarget, createBackup: true) + self.otaURL = otaTarget + } + + self.fileURL = tntURL + self.isModified = false + } + + /// Loads the bundled TA palette. PALETTE.PAL is 1 KB of 256 RGBA + /// entries shipped as an app resource; if the file is missing (for + /// instance while iterating in a dev build that hasn't added it yet) + /// we return an all-white fallback so callers don't crash. + private static func loadBundledPalette() -> Palette { + guard let url = Bundle.main.url(forResource: "PALETTE", withExtension: "PAL"), + let palette = try? Palette(palContentsOf: url) else { + return Palette() + } + return palette + } + + /// Write-with-backup: on first write to a location that already has a + /// file, rename the original to `.bak` before overwriting. Never + /// overwrites an existing `.bak` to avoid clobbering a user's existing + /// backup history. + private func writeAtomic(_ data: Data, to url: URL, createBackup: Bool) throws { + let fm = FileManager.default + if createBackup && fm.fileExists(atPath: url.path) { + let backup = url.appendingPathExtension("bak") + if !fm.fileExists(atPath: backup.path) { + try fm.copyItem(at: url, to: backup) + } + } + try data.write(to: url, options: [.atomic]) + } +} + + +enum EditableMapError: Error, LocalizedError { + case takNotYetSupported + + var errorDescription: String? { + switch self { + case .takNotYetSupported: + return "Total Annihilation: Kingdoms (.tnt v2) maps aren't editable yet — only the TA format is supported in this build." + } + } +} + + +// MARK: - Minimal in-memory FileReadHandle + +/// Disk-backed reads go through FileHandle, which doesn't have a ready- +/// made FileReadHandle conformance we can reach from outside the core +/// package. An in-memory adapter is simpler and lets us load once, pass +/// the bytes around, and avoid holding an OS file handle open for the +/// editor's lifetime. +private final class MemoryFileHandle: FileReadHandle { + private let data: Data + private var cursor: Int = 0 + let fileName: String + + init(data: Data, name: String) { + self.data = data + self.fileName = name + } + + var fileSize: Int { data.count } + var fileOffset: Int { cursor } + + func seek(toFileOffset offset: Int) { + cursor = max(0, min(offset, data.count)) + } + + func readData(ofLength length: Int) -> Data { + let end = min(cursor + length, data.count) + let slice = data.subdata(in: cursor.. Data { + let slice = data.subdata(in: cursor.. HeightBrushCommand? { + guard !accumulatedChanges.isEmpty else { return nil } + return HeightBrushCommand( + changes: accumulatedChanges.values.sorted { $0.cellIndex < $1.cellIndex } + ) + } +} + + +private func clamp(_ value: T, min lo: T, max hi: T) -> T { + return max(lo, min(hi, value)) +} diff --git a/SwiftTA macOS/SwiftTA macOS/Info.plist b/AEX-MapEditor/AEX-MapEditor/Info.plist similarity index 56% rename from SwiftTA macOS/SwiftTA macOS/Info.plist rename to AEX-MapEditor/AEX-MapEditor/Info.plist index 24c73d5..d355f99 100644 --- a/SwiftTA macOS/SwiftTA macOS/Info.plist +++ b/AEX-MapEditor/AEX-MapEditor/Info.plist @@ -3,7 +3,7 @@ CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + en CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconFile @@ -13,20 +13,39 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + AEX-MapEditor + CFBundleDisplayName + AEX Map Editor CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + 0.1.0 CFBundleVersion 1 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright - Copyright © 2018 Logan Jones. All rights reserved. - NSMainStoryboardFile - Main + Copyright © Azimuth Systems NSPrincipalClass NSApplication + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + tnt + + CFBundleTypeName + Total Annihilation Map + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + com.cavedog.tnt + + + diff --git a/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift new file mode 100644 index 0000000..85582d2 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift @@ -0,0 +1,379 @@ +// +// MapCanvasView.swift +// AEX-MapEditor +// +// Core Graphics canvas for the Phase 2 MVP. Renders the height-map as +// a grayscale raster, draws a brush footprint overlay while the cursor +// is hovering, and routes mouse drags into the active HeightBrushStroke. +// +// The MVP deliberately uses CG, not Metal — a 256×256-cell map is only +// 65 536 grayscale pixels; painting on the main thread fits budget. +// When the editor needs tile-level texture authoring (Phase 4) we swap +// in the Metal renderer from TAassets. Keeping this simple keeps the +// bring-up honest. +// + +import Cocoa + + +protocol MapCanvasViewDelegate: AnyObject { + /// Called when a brush stroke finishes. The delegate is responsible + /// for wrapping the command into the undo manager. + func canvasDidFinishStroke(_ command: MapCommand) + /// Called every frame the stroke mutates the model, so the window + /// controller can refresh its title bar / dirty marker. + func canvasDidModifyMap() + /// When the Features tool is active and the user clicks a cell, + /// the delegate decides which feature index (if any) should be + /// assigned — typically reads the current picker selection. A + /// return of nil means "no change" (e.g. user hasn't picked a + /// feature yet, or we're in erase mode); a non-nil .some(nil) + /// means "remove any feature here". + func canvasWantsFeatureAssignment(forCell index: Int) -> Int?? +} + + +enum MapCanvasTool { + case heights + case features + case tiles +} + + +final class MapCanvasView: NSView { + + weak var delegate: MapCanvasViewDelegate? + + /// The map being edited. The canvas reads `map.model.heightMap.samples` + /// directly on each redraw and writes through it during brush strokes, + /// so setting a new map or committing a command both require + /// `needsDisplay = true` to repaint. + var map: EditableMap? { + didSet { + tileRasterCache = nil + needsDisplay = true + } + } + + /// Index into `map.model.tileSet` the user has selected as the + /// "paint" tile in Tiles mode. Defaulted to 0 when a map loads; + /// updated via `selectedTileIndex`. + var selectedTileIndex: Int = 0 + + /// Brush configuration, surfaced to the window's tool palette. + var brushRadius: Int = 3 + var brushStrength: Int = 16 + /// When true, the next height stroke lowers instead of raising. + var eraseMode: Bool = false + + /// Which tool's interactions take effect on mouse events. + var activeTool: MapCanvasTool = .heights { + didSet { needsDisplay = true } + } + + // MARK: - Internal state + + private var activeStroke: HeightBrushStroke? + private var hoverCell: (col: Int, row: Int)? + /// Cached tile raster so we only re-rasterize when tiles or the + /// loaded map actually change. Set to nil to force a rebuild. + private var tileRasterCache: CGImage? + + /// Call when tile data changes (paint, undo, redo, save, etc.) so + /// the next draw rebuilds the cached raster. + func invalidateTileRaster() { + tileRasterCache = nil + needsDisplay = true + } + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + let tracking = NSTrackingArea( + rect: bounds, + options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(tracking) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func draw(_ dirtyRect: NSRect) { + guard let map = map, let ctx = NSGraphicsContext.current?.cgContext else { return } + switch activeTool { + case .tiles: + drawTileRaster(of: map, in: ctx) + default: + drawHeightRaster(of: map, in: ctx) + } + drawFeatureOverlay(of: map, in: ctx) + drawBrushOverlay(in: ctx) + } + + private func drawTileRaster(of map: EditableMap, in ctx: CGContext) { + if tileRasterCache == nil { + tileRasterCache = MapRasterizer.render(map.model, using: map.palette) + } + guard let raster = tileRasterCache else { return } + + ctx.interpolationQuality = .none + ctx.saveGState() + ctx.translateBy(x: 0, y: bounds.height) + ctx.scaleBy(x: 1, y: -1) + ctx.draw(raster, in: CGRect(origin: .zero, size: bounds.size)) + ctx.restoreGState() + } + + private func drawFeatureOverlay(of map: EditableMap, in ctx: CGContext) { + guard let cellSize = cellSize() else { return } + let mapSize = map.model.mapSize + let featureMap = map.model.featureMap + guard featureMap.count == mapSize.area else { return } + + // Solid fill so the feature squares pop against the grayscale + // heightmap regardless of elevation. Alpha keeps the underlying + // height visible so users can still judge steepness through the + // overlay. + ctx.setFillColor(NSColor(calibratedRed: 1.0, green: 0.6, blue: 0.1, alpha: 0.55).cgColor) + for i in 0.. 0 else { return } + let samples = map.model.heightMap.samples + + // Build a width×height grayscale image out of the raw samples, + // then let CG scale it to the view. cellPixelSize = bounds / mapSize, + // but we don't need to know it — CGImage scaling handles it. + var pixels = [UInt8](repeating: 0, count: mapSize.area) + for i in 0...size <= indices.count else { return } + + let previous = indices.withUnsafeBytes { raw -> UInt16 in + raw.bindMemory(to: UInt16.self)[linear] + } + let next = UInt16(clamping: selectedTileIndex) + guard previous != next else { return } + + let command = TilePaintCommand( + tileColumn: tile.col, + tileRow: tile.row, + tileIndexMapColumns: tileIndexCols, + previous: previous, + next: next + ) + command.apply(to: map) + invalidateTileRaster() + delegate?.canvasDidFinishStroke(command) + delegate?.canvasDidModifyMap() + } + + /// The tile-index grid is (mapSize.width / 2) × (mapSize.height / 2). + /// Screen-to-tile-cell is the same cellSize() math scaled down by 2. + private func tileMapCellUnder(_ point: CGPoint) -> (col: Int, row: Int)? { + guard let map = map else { return nil } + guard let cellSize = cellSize() else { return nil } + let tileWidth = cellSize.width * 2 + let tileHeight = cellSize.height * 2 + let col = Int(floor(point.x / tileWidth)) + let row = Int(floor(point.y / tileHeight)) + let tileCols = map.model.mapSize.width / 2 + let tileRows = map.model.mapSize.height / 2 + guard col >= 0, col < tileCols, row >= 0, row < tileRows else { return nil } + return (col, row) + } + + override func mouseDragged(with event: NSEvent) { + guard activeTool == .heights else { return } + applyStamp(at: event.locationInWindow) + } + + override func mouseUp(with event: NSEvent) { + guard activeTool == .heights else { return } + applyStamp(at: event.locationInWindow) + if let command = activeStroke?.finish() { + delegate?.canvasDidFinishStroke(command) + } + activeStroke = nil + } + + private func handleFeatureClick(at windowPoint: CGPoint, remove: Bool) { + guard let map = map else { return } + let point = convert(windowPoint, from: nil) + guard let cell = cellUnder(point) else { return } + let cellIndex = cell.row * map.model.mapSize.width + cell.col + + if remove { + let previous = map.model.featureMap[cellIndex] + guard previous != nil else { return } + let command = FeatureAssignCommand(cellIndex: cellIndex, previous: previous, next: nil) + command.apply(to: map) + delegate?.canvasDidFinishStroke(command) + delegate?.canvasDidModifyMap() + needsDisplay = true + return + } + + // Placement: ask the delegate what to assign. A return of .some(nil) + // means "erase"; .some(.some(idx)) means assign that index; nil means + // do nothing (e.g. no feature selected yet). + guard let decision = delegate?.canvasWantsFeatureAssignment(forCell: cellIndex) else { return } + let previous = map.model.featureMap[cellIndex] + let command = FeatureAssignCommand(cellIndex: cellIndex, previous: previous, next: decision) + command.apply(to: map) + delegate?.canvasDidFinishStroke(command) + delegate?.canvasDidModifyMap() + needsDisplay = true + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + hoverCell = cellUnder(point) + needsDisplay = true + } + + override func mouseExited(with event: NSEvent) { + hoverCell = nil + needsDisplay = true + } + + override func rightMouseDown(with event: NSEvent) { + switch activeTool { + case .heights: + // Right-click toggles erase mode for the next height stroke. + eraseMode.toggle() + needsDisplay = true + case .features: + // Right-click erases the feature at the clicked cell, + // regardless of the current picker selection. + handleFeatureClick(at: event.locationInWindow, remove: true) + case .tiles: + // No "erase" for tiles — every cell must carry a valid tile + // index. Right-click is a no-op in this mode for now. + break + } + } + + private func applyStamp(at windowPoint: CGPoint) { + guard let map = map, let stroke = activeStroke else { return } + let point = convert(windowPoint, from: nil) + guard let cell = cellUnder(point) else { return } + + stroke.stamp(on: map, col: cell.col, row: cell.row) + delegate?.canvasDidModifyMap() + needsDisplay = true + } + + private func cellUnder(_ point: CGPoint) -> (col: Int, row: Int)? { + guard let map = map, let cellSize = cellSize() else { return nil } + let col = Int(floor(point.x / cellSize.width)) + let row = Int(floor(point.y / cellSize.height)) + let mapSize = map.model.mapSize + guard col >= 0, col < mapSize.width, row >= 0, row < mapSize.height else { return nil } + return (col, row) + } + + private func cellSize() -> CGSize? { + guard let map = map else { return nil } + let mapSize = map.model.mapSize + guard mapSize.width > 0, mapSize.height > 0 else { return nil } + return CGSize( + width: bounds.width / CGFloat(mapSize.width), + height: bounds.height / CGFloat(mapSize.height) + ) + } +} diff --git a/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift new file mode 100644 index 0000000..d83aee4 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift @@ -0,0 +1,438 @@ +// +// MapEditorWindowController.swift +// AEX-MapEditor +// +// Wires up one window per open map. The window holds a side panel for +// the tool palette + brush config and a central MapCanvasView for the +// actual painting surface. Undo / redo live on an NSUndoManager local +// to this window so each open map has its own undo history. +// + +import Cocoa +import SwiftTA_Core + + +final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate { + + private let map: EditableMap + private let canvas: MapCanvasView + private let toolSegmented: NSSegmentedControl + private let brushRadiusSlider: NSSlider + private let brushStrengthSlider: NSSlider + private let radiusLabel: NSTextField + private let strengthLabel: NSTextField + private let modeSegmented: NSSegmentedControl + private let featurePopup: NSPopUpButton + private let addFeatureButton: NSButton + private let tilePopup: NSPopUpButton + private let tilePreview: NSImageView + private let heightsGroup: NSStackView + private let featuresGroup: NSStackView + private let tilesGroup: NSStackView + private let mapInfoLabel: NSTextField + private let undoManagerLocal = UndoManager() + + init(mapURL: URL) throws { + let map = try EditableMap(loadingFrom: mapURL) + self.map = map + + let windowFrame = NSRect(x: 0, y: 0, width: 1000, height: 720) + let window = NSWindow( + contentRect: windowFrame, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = mapURL.lastPathComponent + window.setFrameAutosaveName("AEX-MapEditor.window") + + // Tool palette on the left. + let paletteWidth: CGFloat = 220 + let palette = NSView(frame: NSRect(x: 0, y: 0, width: paletteWidth, height: windowFrame.height)) + palette.autoresizingMask = [.height] + + let mapInfoLabel = NSTextField(labelWithString: "") + mapInfoLabel.font = NSFont.systemFont(ofSize: 11) + mapInfoLabel.textColor = .secondaryLabelColor + mapInfoLabel.lineBreakMode = .byWordWrapping + mapInfoLabel.maximumNumberOfLines = 3 + + let toolSegmented = NSSegmentedControl(labels: ["Heights", "Features", "Tiles"], trackingMode: .selectOne, target: nil, action: nil) + toolSegmented.selectedSegment = 0 + toolSegmented.controlSize = .regular + + let modeSegmented = NSSegmentedControl(labels: ["Raise", "Lower"], trackingMode: .selectOne, target: nil, action: nil) + modeSegmented.selectedSegment = 0 + modeSegmented.controlSize = .regular + + let radiusLabel = NSTextField(labelWithString: "Radius: 3") + let brushRadiusSlider = NSSlider(value: 3, minValue: 0, maxValue: 32, target: nil, action: nil) + brushRadiusSlider.isContinuous = true + + let strengthLabel = NSTextField(labelWithString: "Strength: 16") + let brushStrengthSlider = NSSlider(value: 16, minValue: 1, maxValue: 127, target: nil, action: nil) + brushStrengthSlider.isContinuous = true + + let featurePopup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 200, height: 24), pullsDown: false) + for featureId in map.model.features { + featurePopup.addItem(withTitle: featureId.name) + } + if map.model.features.isEmpty { + featurePopup.addItem(withTitle: "(no features in map)") + featurePopup.isEnabled = false + } + + let addFeatureButton = NSButton(title: "Add feature type…", target: nil, action: nil) + addFeatureButton.bezelStyle = .rounded + addFeatureButton.controlSize = .regular + + let featureHint = NSTextField(wrappingLabelWithString: "Left-click a cell to place the selected feature. Right-click to remove any feature at the clicked cell.") + featureHint.font = NSFont.systemFont(ofSize: 10) + featureHint.textColor = .secondaryLabelColor + + let tilePopup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 200, height: 24), pullsDown: false) + MapEditorWindowController.populateTilePopup(tilePopup, map: map) + + let tilePreview = NSImageView(frame: NSRect(x: 0, y: 0, width: 96, height: 96)) + tilePreview.imageScaling = .scaleProportionallyUpOrDown + tilePreview.image = MapRasterizer.renderTile(index: 0, in: map.model, using: map.palette) + + let tileHint = NSTextField(wrappingLabelWithString: "Pick a tile in the dropdown, then click on the map to paint that tile at the clicked 32×32 cell.") + tileHint.font = NSFont.systemFont(ofSize: 10) + tileHint.textColor = .secondaryLabelColor + + self.mapInfoLabel = mapInfoLabel + self.toolSegmented = toolSegmented + self.modeSegmented = modeSegmented + self.brushRadiusSlider = brushRadiusSlider + self.brushStrengthSlider = brushStrengthSlider + self.radiusLabel = radiusLabel + self.strengthLabel = strengthLabel + self.featurePopup = featurePopup + self.addFeatureButton = addFeatureButton + self.tilePopup = tilePopup + self.tilePreview = tilePreview + + let heightsGroup = NSStackView(views: [modeSegmented, radiusLabel, brushRadiusSlider, strengthLabel, brushStrengthSlider]) + heightsGroup.orientation = .vertical + heightsGroup.alignment = .leading + heightsGroup.spacing = 8 + + let featuresGroup = NSStackView(views: [featurePopup, addFeatureButton, featureHint]) + featuresGroup.orientation = .vertical + featuresGroup.alignment = .leading + featuresGroup.spacing = 8 + featuresGroup.isHidden = true + + let tilesGroup = NSStackView(views: [tilePopup, tilePreview, tileHint]) + tilesGroup.orientation = .vertical + tilesGroup.alignment = .leading + tilesGroup.spacing = 8 + tilesGroup.isHidden = true + + self.heightsGroup = heightsGroup + self.featuresGroup = featuresGroup + self.tilesGroup = tilesGroup + + canvas = MapCanvasView(frame: NSRect(x: paletteWidth, y: 0, width: windowFrame.width - paletteWidth, height: windowFrame.height)) + canvas.autoresizingMask = [.width, .height] + canvas.map = map + + super.init(window: window) + + // Lay out the palette contents now that self exists and can target actions. + toolSegmented.target = self + toolSegmented.action = #selector(toolChanged(_:)) + modeSegmented.target = self + modeSegmented.action = #selector(modeChanged(_:)) + brushRadiusSlider.target = self + brushRadiusSlider.action = #selector(radiusChanged(_:)) + brushStrengthSlider.target = self + brushStrengthSlider.action = #selector(strengthChanged(_:)) + addFeatureButton.target = self + addFeatureButton.action = #selector(addFeatureTypePrompt(_:)) + tilePopup.target = self + tilePopup.action = #selector(tileSelectionChanged(_:)) + + let stack = NSStackView(views: [mapInfoLabel, toolSegmented, heightsGroup, featuresGroup, tilesGroup]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + stack.translatesAutoresizingMaskIntoConstraints = false + palette.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: palette.topAnchor), + stack.leadingAnchor.constraint(equalTo: palette.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: palette.trailingAnchor), + toolSegmented.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + brushRadiusSlider.widthAnchor.constraint(equalTo: heightsGroup.widthAnchor), + brushStrengthSlider.widthAnchor.constraint(equalTo: heightsGroup.widthAnchor), + heightsGroup.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + featuresGroup.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + featurePopup.widthAnchor.constraint(equalTo: featuresGroup.widthAnchor), + tilesGroup.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + tilePopup.widthAnchor.constraint(equalTo: tilesGroup.widthAnchor), + tilePreview.widthAnchor.constraint(equalToConstant: 96), + tilePreview.heightAnchor.constraint(equalToConstant: 96), + ]) + + let container = NSView(frame: windowFrame) + container.autoresizingMask = [.width, .height] + container.addSubview(palette) + container.addSubview(canvas) + + // Thin vertical separator between palette and canvas. + let divider = NSBox(frame: NSRect(x: paletteWidth - 1, y: 0, width: 1, height: windowFrame.height)) + divider.boxType = .separator + divider.autoresizingMask = [.height] + container.addSubview(divider) + + window.contentView = container + window.contentMinSize = NSSize(width: 600, height: 400) + + canvas.delegate = self + updateMapInfoLabel() + refreshTitle() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var windowNibName: NSNib.Name? { nil } + + override func windowDidLoad() { + super.windowDidLoad() + window?.windowController = self + } + + override var window: NSWindow? { + get { super.window } + set { + super.window = newValue + newValue?.windowController = self + } + } + + // MARK: - Tool palette actions + + @objc private func toolChanged(_ sender: NSSegmentedControl) { + let tool: MapCanvasTool + switch sender.selectedSegment { + case 1: tool = .features + case 2: tool = .tiles + default: tool = .heights + } + canvas.activeTool = tool + heightsGroup.isHidden = tool != .heights + featuresGroup.isHidden = tool != .features + tilesGroup.isHidden = tool != .tiles + } + + @objc private func tileSelectionChanged(_ sender: NSPopUpButton) { + canvas.selectedTileIndex = sender.indexOfSelectedItem + tilePreview.image = MapRasterizer.renderTile(index: canvas.selectedTileIndex, in: map.model, using: map.palette) + } + + /// Fills a popup with tile entries: "Tile N" titles, sorted numerically. + /// Keeping this in its own static so initializer code can call it + /// before `self` is fully constructed. + private static func populateTilePopup(_ popup: NSPopUpButton, map: EditableMap) { + popup.removeAllItems() + let count = map.model.tileSet.count + guard count > 0 else { + popup.addItem(withTitle: "(no tiles in map)") + popup.isEnabled = false + return + } + popup.isEnabled = true + for i in 0.. Int?? { + // The popup holds an entry per features[] slot; selecting index N + // means "place features[N]". If the user hasn't added any feature + // types yet, there's nothing to place and the click is a no-op. + guard !map.model.features.isEmpty, featurePopup.isEnabled else { return nil } + let selected = featurePopup.indexOfSelectedItem + guard selected >= 0, selected < map.model.features.count else { return nil } + return .some(selected) + } + + /// Runs `command.apply(on:)` AND registers the undo for it. Used by + /// the Add Feature Type path which isn't called from a stroke end. + private func registerNewUndoableCommand(_ command: MapCommand) { + command.apply(to: map) + registerUndoForAlreadyAppliedCommand(command) + canvas.needsDisplay = true + refreshTitle() + } + + private func registerUndoForAlreadyAppliedCommand(_ command: MapCommand) { + undoManagerLocal.registerUndo(withTarget: self) { target in + command.revert(on: target.map) + target.canvas.invalidateTileRaster() + target.rebuildFeaturePopup() + target.refreshTitle() + target.registerRedo(command) + } + } + + private func registerRedo(_ command: MapCommand) { + undoManagerLocal.registerUndo(withTarget: self) { target in + command.apply(to: target.map) + target.canvas.invalidateTileRaster() + target.rebuildFeaturePopup() + target.refreshTitle() + target.registerUndoForAlreadyAppliedCommand(command) + } + } + + // MARK: - UI plumbing + + private func rebuildFeaturePopup(selecting index: Int? = nil) { + let previousIndex = featurePopup.indexOfSelectedItem + featurePopup.removeAllItems() + for featureId in map.model.features { + featurePopup.addItem(withTitle: featureId.name) + } + if map.model.features.isEmpty { + featurePopup.addItem(withTitle: "(no features in map)") + featurePopup.isEnabled = false + } else { + featurePopup.isEnabled = true + let target = index ?? previousIndex + if target >= 0 && target < map.model.features.count { + featurePopup.selectItem(at: target) + } + } + } + + private func updateMapInfoLabel() { + let size = map.model.mapSize + mapInfoLabel.stringValue = "\(map.fileURL.lastPathComponent)\n\(size.width)×\(size.height) cells · sea level \(map.model.seaLevel)" + } + + private func refreshTitle() { + let base = map.fileURL.lastPathComponent + window?.title = map.isModified ? "• " + base : base + window?.representedURL = map.fileURL + window?.isDocumentEdited = map.isModified + } + + private func presentError(_ error: Error, contextMessage: String) { + let alert = NSAlert() + alert.messageText = contextMessage + alert.informativeText = (error as NSError).localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.beginSheetModal(for: window!) { _ in } + } +} diff --git a/AEX-MapEditor/AEX-MapEditor/MapRasterizer.swift b/AEX-MapEditor/AEX-MapEditor/MapRasterizer.swift new file mode 100644 index 0000000..92676bb --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/MapRasterizer.swift @@ -0,0 +1,145 @@ +// +// MapRasterizer.swift +// AEX-MapEditor +// +// Assembles a full-map RGBA raster from a TaMapModel's tileset, tile +// index map, and palette. Used by MapCanvasView when the user has +// the Tiles view active so the canvas shows the actual painted map +// instead of the grayscale heights. +// +// The Phase 4 implementation runs on the main thread via Core +// Graphics. A 64×64 map yields a 1024×1024 raster — half a second at +// most to assemble, and the cached image is re-used until the user +// paints a tile. If/when the editor scales up to huge maps or +// real-time repaints, this is the natural place to swap in the +// Metal tile renderer from TAassets. +// + +import Cocoa +import SwiftTA_Core + + +enum MapRasterizer { + + /// Returns a CGImage whose dimensions are (mapSize.width * 16) + /// × (mapSize.height * 16) — the same world-pixel resolution Cavedog + /// uses. Each 32×32 tile from `tileSet` is blitted into the target + /// at the position indicated by `tileIndexMap`. + static func render(_ model: TaMapModel, using palette: Palette) -> CGImage? { + let mapSize = model.mapSize + let tileSize = model.tileSet.tileSize + guard tileSize.width == 32, tileSize.height == 32 else { return nil } + + // Resolution: each height cell is 16×16, each tile spans 2×2 + // height cells. So image width = (mapSize.width / 2) * 32 = + // mapSize.width * 16. Same for height. + let rasterWidth = mapSize.width * 16 + let rasterHeight = mapSize.height * 16 + guard rasterWidth > 0, rasterHeight > 0 else { return nil } + + let bytesPerRow = rasterWidth * 4 + var pixels = [UInt8](repeating: 0, count: rasterHeight * bytesPerRow) + + // Snapshot palette colors once so the inner loop stays tight. + var paletteRGBA = [UInt32](repeating: 0, count: 256) + for i in 0..<256 { + let c = palette[i] + // Pack as little-endian ABGR so the 8-bit byte layout + // RGBA (premultipliedLast) matches. + paletteRGBA[i] = + (UInt32(c.alpha) << 24) | + (UInt32(c.blue) << 16) | + (UInt32(c.green) << 8) | + UInt32(c.red) + } + + let tileIndexData = model.tileIndexMap.indices + let tileIndexCols = mapSize.width / 2 + let tileIndexRows = mapSize.height / 2 + let tileBytes = model.tileSet.tiles + let tilePixelCount = tileSize.area + let tileCount = model.tileSet.count + + tileIndexData.withUnsafeBytes { (tileIndexRaw: UnsafeRawBufferPointer) in + let tileIndices = tileIndexRaw.bindMemory(to: UInt16.self) + tileBytes.withUnsafeBytes { (tileRaw: UnsafeRawBufferPointer) in + let tilePalettes = tileRaw.bindMemory(to: UInt8.self) + + pixels.withUnsafeMutableBufferPointer { out in + let outBytes = out.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: rasterWidth * rasterHeight) { $0 } + for tr in 0.. NSImage? { + let tileSize = model.tileSet.tileSize + guard tileSize.width == 32, tileSize.height == 32 else { return nil } + guard model.tileSet[safe: index] != nil else { return nil } + let tileData = model.tileSet[index] + + var pixels = [UInt8](repeating: 0, count: tileSize.area * 4) + tileData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + let bytes = raw.bindMemory(to: UInt8.self) + for i in 0...size + guard byteOffset + MemoryLayout.size <= data.count else { return } + data.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt16.self) + p[linear] = value + } + } +} diff --git a/AEX-MapEditor/AEX-MapEditor/main.swift b/AEX-MapEditor/AEX-MapEditor/main.swift new file mode 100644 index 0000000..7a78bd1 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/main.swift @@ -0,0 +1,19 @@ +// +// main.swift +// AEX-MapEditor +// +// Explicit AppKit bootstrap. Using @main on an NSApplicationDelegate +// class relies on NSApplicationMain, which reads NSMainNibFile from +// Info.plist to locate the delegate — and this app deliberately ships +// without a MainMenu.xib. Wiring the delegate up here ensures the +// applicationWill/DidFinishLaunching callbacks actually fire so the +// programmatic menu bar gets installed. +// + +import Cocoa + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() diff --git a/HPIView/HPIView.xcodeproj/project.pbxproj b/HPIView/HPIView.xcodeproj/project.pbxproj index 86dddf5..a443d16 100644 --- a/HPIView/HPIView.xcodeproj/project.pbxproj +++ b/HPIView/HPIView.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ F015C79E20AE3D9600873642 /* unit-view-grid.glsl.frag in Resources */ = {isa = PBXBuildFile; fileRef = F015C79C20AE3D9500873642 /* unit-view-grid.glsl.frag */; }; F015C7A020AE421000873642 /* ModelViewRenderer+OpenglLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F015C79F20AE421000873642 /* ModelViewRenderer+OpenglLegacy.swift */; }; F08A641120EE86CD001E5982 /* ModelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08A641020EE86CD001E5982 /* ModelView.swift */; }; + FACE0004000002000000FACE /* PieceHierarchyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACE0003000002000000FACE /* PieceHierarchyView.swift */; }; F08C9A8721126BE200DC18EE /* TntViewRenderer+MetalSingleQuad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08C9A8621126BE200DC18EE /* TntViewRenderer+MetalSingleQuad.swift */; }; F08C9A8921126C6200DC18EE /* TntViewRenderer+MetalStaticGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08C9A8821126C6200DC18EE /* TntViewRenderer+MetalStaticGrid.swift */; }; F08C9A8B21126D2900DC18EE /* TntViewRenderer+MetalDynamicTiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08C9A8A21126D2900DC18EE /* TntViewRenderer+MetalDynamicTiles.swift */; }; @@ -67,6 +68,7 @@ F015C79C20AE3D9500873642 /* unit-view-grid.glsl.frag */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.glsl; name = "unit-view-grid.glsl.frag"; path = "../../TAassets/TAassets/unit-view-grid.glsl.frag"; sourceTree = ""; }; F015C79F20AE421000873642 /* ModelViewRenderer+OpenglLegacy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ModelViewRenderer+OpenglLegacy.swift"; sourceTree = ""; }; F08A641020EE86CD001E5982 /* ModelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelView.swift; sourceTree = ""; }; + FACE0003000002000000FACE /* PieceHierarchyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PieceHierarchyView.swift; sourceTree = ""; }; F08C9A8621126BE200DC18EE /* TntViewRenderer+MetalSingleQuad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TntViewRenderer+MetalSingleQuad.swift"; sourceTree = ""; }; F08C9A8821126C6200DC18EE /* TntViewRenderer+MetalStaticGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TntViewRenderer+MetalStaticGrid.swift"; sourceTree = ""; }; F08C9A8A21126D2900DC18EE /* TntViewRenderer+MetalDynamicTiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TntViewRenderer+MetalDynamicTiles.swift"; sourceTree = ""; }; @@ -123,6 +125,7 @@ B5E26F311ED9EDD7006C329B /* GafView.swift */, B50F6A4F1D87A54B0016C15B /* HpiDocument.swift */, F08A641020EE86CD001E5982 /* ModelView.swift */, + FACE0003000002000000FACE /* PieceHierarchyView.swift */, F0C74B6E20D06F2A00F52B01 /* ModelView+Metal.swift */, B553EE351DDFE2270033C70D /* ModelView+Opengl.swift */, F0C74B7020D0744000F52B01 /* ModelViewRenderer+Metal.swift */, @@ -262,6 +265,7 @@ F0AEB0E22098FB590087B36B /* QuickLookView.swift in Sources */, B5EDC8DF24A96D4E00313D5F /* Utility+OpenGL.swift in Sources */, F08A641120EE86CD001E5982 /* ModelView.swift in Sources */, + FACE0004000002000000FACE /* PieceHierarchyView.swift in Sources */, F0CD87D120FFE7B30012B1C8 /* TntView+Metal.swift in Sources */, B57C32B520702B4700B24C99 /* PaletteView.swift in Sources */, F08C9A8B21126D2900DC18EE /* TntViewRenderer+MetalDynamicTiles.swift in Sources */, @@ -341,7 +345,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -395,7 +399,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/HPIView/HPIView/HpiDocument.swift b/HPIView/HPIView/HpiDocument.swift index 26750cf..96df76d 100644 --- a/HPIView/HPIView/HpiDocument.swift +++ b/HPIView/HPIView/HpiDocument.swift @@ -338,7 +338,13 @@ extension HpiBrowserViewController { else if file.hasExtension("3do") { let model = try UnitModel(contentsOf: fileHandle) let controller = bindContentViewController(as: ModelViewController.self) - try controller.load(model) + let baseName = (file.info.name as NSString).deletingPathExtension + let script: UnitScript? = { + guard let handle = try? hpiDocument.filesystem.openFile(at: "scripts/" + baseName + ".COB") + else { return nil } + return try? UnitScript(contentsOf: handle) + }() + try controller.load(model, script: script) } else if file.hasExtension("cob") { let script = try UnitScript(contentsOf: fileHandle) @@ -425,11 +431,26 @@ extension HpiBrowserViewController { } @IBAction func extractAll(sender: Any?) { - + + guard let window = hpiDocument.windowForSheet + else { Swift.print("Document has no windowForSheet."); return } + + let items = hpiDocument.filesystem.root.items.map { HpiItem($0) } + guard items.count > 0 + else { Swift.print("Archive is empty; nothing to extract."); return } + let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true - + panel.canCreateDirectories = true + panel.beginSheetModal(for: window) { + switch $0 { + case .OK: + if let url = panel.url { self.extractItems(items, to: url) } + default: + () + } + } } func extractItems(_ items: [HpiItem], to rootDirectory: URL) { @@ -546,48 +567,49 @@ class HpiItemPreviewController: NSViewController, HpiItemPreviewDisplay { override init(frame frameRect: NSRect) { let titleLabel = NSTextField(labelWithString: "Title") - titleLabel.font = NSFont.systemFont(ofSize: 18) + titleLabel.font = NSFont.systemFont(ofSize: 13) titleLabel.textColor = NSColor.labelColor let sizeLabel = NSTextField(labelWithString: "Empty") - sizeLabel.font = NSFont.systemFont(ofSize: 12) + sizeLabel.font = NSFont.systemFont(ofSize: 11) sizeLabel.textColor = NSColor.secondaryLabelColor let contentBox = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: 32)) - + self.titleLabel = titleLabel self.sizeLabel = sizeLabel self.emptyContentView = contentBox super.init(frame: frameRect) - + addSubview(contentBox) addSubview(titleLabel) addSubview(sizeLabel) - + contentBox.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false sizeLabel.translatesAutoresizingMaskIntoConstraints = false - + addContentViewConstraints(contentBox) NSLayoutConstraint.activate([ - titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), - sizeLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), - sizeLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 0), + titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: sizeLabel.leadingAnchor, constant: -8), + titleLabel.centerYAnchor.constraint(equalTo: sizeLabel.centerYAnchor), + sizeLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + sizeLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -6), ]) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func addContentViewConstraints(_ contentBox: NSView) { NSLayoutConstraint.activate([ contentBox.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), contentBox.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), contentBox.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - contentBox.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.61803398875), - titleLabel.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 8), + contentBox.bottomAnchor.constraint(equalTo: titleLabel.topAnchor, constant: -6), ]) } - + } override func loadView() { diff --git a/HPIView/HPIView/ModelView.swift b/HPIView/HPIView/ModelView.swift index 1961235..18f0bd5 100644 --- a/HPIView/HPIView/ModelView.swift +++ b/HPIView/HPIView/ModelView.swift @@ -9,48 +9,106 @@ import AppKit import SwiftTA_Core -class ModelViewController: NSViewController { - +class ModelViewController: NSViewController, PieceHierarchyViewDelegate { + private(set) var viewState = ModelViewState() private var modelLoader: ModelViewLoader! - + private let pieceView = PieceHierarchyView(frame: .zero) + private let splitView = NSSplitView() + + func pieceHierarchyView(_ view: PieceHierarchyView, didSelectPieceAt index: UnitModel.Pieces.Index?) { + viewState.highlightedPieceIndex = index.map(Int32.init) ?? -1 + } + override func loadView() { - let defaultFrame = NSRect(x: 0, y: 0, width: 640, height: 480) - + let defaultFrame = NSRect(x: 0, y: 0, width: 800, height: 480) + + let renderView: NSView if let modelView: NSView & ModelViewLoader = nil ?? MetalModelView(modelViewFrame: defaultFrame, stateProvider: self) ?? OpenglModelView(modelViewFrame: defaultFrame, stateProvider: self) { - view = modelView + renderView = modelView modelLoader = modelView } else { - view = NSView(frame: defaultFrame) + renderView = NSView(frame: defaultFrame) modelLoader = DummyModelViewLoader() } + + splitView.dividerStyle = .thin + splitView.isVertical = false + splitView.autoresizingMask = [.width, .height] + splitView.frame = defaultFrame + splitView.addArrangedSubview(renderView) + splitView.addArrangedSubview(pieceView) + splitView.setHoldingPriority(NSLayoutConstraint.Priority(260), forSubviewAt: 1) + view = splitView + pieceView.selectionDelegate = self } - - func load(_ model: UnitModel) throws { + + override func viewDidAppear() { + super.viewDidAppear() + if splitView.arrangedSubviews.count >= 2 { + let total = splitView.bounds.height + if total > 0 { + splitView.setPosition(total * 0.6, ofDividerAt: 0) + } + } + } + + func load(_ model: UnitModel, script: UnitScript? = nil) throws { + viewState.highlightedPieceIndex = -1 + viewState.zoom = 1.0 + viewState.rotateX = 0 + viewState.rotateY = 0 + viewState.rotateZ = 160 + let extent = model.maxWorldExtent + viewState.autoFitSceneWidth = max(ModelViewState.baseSceneWidth, extent * 2.3) try modelLoader.load(model) + pieceView.apply(model: model, script: script) + recomputeSceneSize() } - + } extension ModelViewController: ModelViewStateProvider { - + func viewportChanged(to size: CGSize) { viewState.viewportSize = size viewState.aspectRatio = Float(viewState.viewportSize.height) / Float(viewState.viewportSize.width) - let w = Float(160)//Float( (unit.info.footprint.width + 8) * ModelViewState.gridSize ) + recomputeSceneSize() + } + + private func recomputeSceneSize() { + let base = viewState.autoFitSceneWidth > 0 ? viewState.autoFitSceneWidth : ModelViewState.baseSceneWidth + let w = base / viewState.zoom viewState.sceneSize = (width: w, height: w * viewState.aspectRatio) } - + override func mouseDragged(with event: NSEvent) { if event.modifierFlags.contains(.shift) { viewState.rotateX += GLfloat(event.deltaX) } else if event.modifierFlags.contains(.option) { viewState.rotateY += GLfloat(event.deltaX) } else { viewState.rotateZ += GLfloat(event.deltaX) } } - + + override func scrollWheel(with event: NSEvent) { + let delta = Float(event.scrollingDeltaY) + guard delta != 0 else { return } + let factor = exp(delta * 0.02) + let newZoom = max(0.1, min(32.0, viewState.zoom * factor)) + guard newZoom != viewState.zoom else { return } + viewState.zoom = newZoom + recomputeSceneSize() + } + + override func magnify(with event: NSEvent) { + let factor = Float(1.0 + event.magnification) + let newZoom = max(0.1, min(32.0, viewState.zoom * factor)) + viewState.zoom = newZoom + recomputeSceneSize() + } + override func keyDown(with event: NSEvent) { switch event.characters { case .some("w"): @@ -59,39 +117,54 @@ extension ModelViewController: ModelViewStateProvider { if let mode = ModelViewState.DrawMode(rawValue: i+1) { drawMode = mode } else { drawMode = .solid } viewState.drawMode = drawMode -// case .some("t"): -// viewState.textured = !viewState.textured case .some("l"): viewState.lighted = !viewState.lighted + case .some("="), .some("+"): + viewState.zoom = min(32.0, viewState.zoom * 1.25) + recomputeSceneSize() + case .some("-"), .some("_"): + viewState.zoom = max(0.1, viewState.zoom / 1.25) + recomputeSceneSize() + case .some("0"): + viewState.zoom = 1.0 + viewState.rotateX = 0 + viewState.rotateY = 0 + viewState.rotateZ = 160 + recomputeSceneSize() default: () } } - + } struct ModelViewState { - + var viewportSize = CGSize() var aspectRatio: Float = 1 var sceneSize: (width: Float, height: Float) = (0,0) - + static let gridSize = 16 - + static let baseSceneWidth: Float = 320 + var drawMode = DrawMode.outlined var textured = false var lighted = true - + var rotateZ: GLfloat = 160 var rotateX: GLfloat = 0 var rotateY: GLfloat = 0 - + + var zoom: Float = 1.0 + var autoFitSceneWidth: Float = 0 + var highlightedPieceIndex: Int32 = -1 + enum DrawMode: Int { case solid case wireframe case outlined } - + } protocol ModelViewLoader { diff --git a/HPIView/HPIView/ModelViewRenderer+Metal.swift b/HPIView/HPIView/ModelViewRenderer+Metal.swift index c28cf87..92b8f83 100644 --- a/HPIView/HPIView/ModelViewRenderer+Metal.swift +++ b/HPIView/HPIView/ModelViewRenderer+Metal.swift @@ -59,7 +59,12 @@ extension BasicMetalModelViewRenderer: MetalModelViewRenderer { let modelMatrix = matrix_float4x4.identity let projection = matrix_float4x4.ortho(0, viewState.sceneSize.width, viewState.sceneSize.height, 0, -1024, 256) let sceneCentering = matrix_float4x4.translation(viewState.sceneSize.width / 2, viewState.sceneSize.height / 2, 0) - let sceneView = matrix_float4x4.rotate(sceneCentering * matrix_float4x4.taPerspective, radians: -viewState.rotateZ * (Float.pi / 180.0), axis: vector_float3(0, 0, 1)) + let perspective = matrix_float4x4.rotate(matrix_float4x4.taPerspective, + radians: viewState.rotateX * (Float.pi / 180.0), + axis: vector_float3(1, 0, 0)) + let sceneView = matrix_float4x4.rotate(sceneCentering * perspective, + radians: -viewState.rotateZ * (Float.pi / 180.0), + axis: vector_float3(0, 0, 1)) let gridView = matrix_float4x4.translate(sceneView, Float(-grid.size.width / 2), Float(-grid.size.height / 2), 0) let normal = matrix_float3x3(topLeftOf: sceneView).inverse.transpose @@ -72,6 +77,7 @@ extension BasicMetalModelViewRenderer: MetalModelViewRenderer { uniforms.pointee.lightPosition = vector_float3(50, 50, 100) uniforms.pointee.viewPosition = vector_float3(viewState.sceneSize.width / 2, viewState.sceneSize.height / 2, 0) uniforms.pointee.objectColor = vector_float4(0.95, 0.85, 0.80, 1) + uniforms.pointee.highlightedPieceIndex = Int32(viewState.highlightedPieceIndex) if viewState.drawMode == .wireframe || viewState.drawMode == .outlined { let wireUniformsR = UnsafeMutableRawPointer(uniformBuffer.contents() + wireUniformOffset) @@ -168,12 +174,13 @@ private extension BasicMetalModelViewRenderer { class func buildModelVertexDescriptor() -> MTLVertexDescriptor { let configurator = MetalVertexDescriptorConfigurator() typealias Vertex = ModelMetalRenderer_ModelVertex - + configurator.setAttribute(.position, format: .float3, keyPath: \Vertex.position, bufferIndex: .modelVertices) configurator.setAttribute(.normal, format: .float3, keyPath: \Vertex.normal, bufferIndex: .modelVertices) configurator.setAttribute(.texcoord, format: .float2, keyPath: \Vertex.texCoord, bufferIndex: .modelVertices) + configurator.setAttribute(.pieceIndex, format: .int, keyPath: \Vertex.pieceIndex, bufferIndex: .modelVertices) configurator.setLayout(.modelVertices, stride: MemoryLayout.stride, stepRate: 1, stepFunction: .perVertex) - + return configurator.vertexDescriptor } @@ -237,9 +244,14 @@ private class MetalModel { buffer.label = "UnitModel" var p = UnsafeMutableRawPointer(buffer.contents()).bindMemory(to: ModelMetalRenderer_ModelVertex.self, capacity: vertexCount) - MetalModel.collectVertexAttributes(pieceIndex: model.root, model: model, vertexBuffer: &p) + let rootsToVisit: [UnitModel.Pieces.Index] = model.roots.isEmpty ? [model.root] : model.roots + for rootIndex in rootsToVisit { + MetalModel.collectVertexAttributes(pieceIndex: rootIndex, model: model, vertexBuffer: &p) + } p = (UnsafeMutableRawPointer(buffer.contents()) + vertexSize).bindMemory(to: ModelMetalRenderer_ModelVertex.self, capacity: outlineCount) - MetalModel.collectOutlineVertexAttributes(pieceIndex: model.root, model: model, vertexBuffer: &p) + for rootIndex in rootsToVisit { + MetalModel.collectOutlineVertexAttributes(pieceIndex: rootIndex, model: model, vertexBuffer: &p) + } self.buffer = buffer self.vertexCount = vertexCount @@ -389,15 +401,19 @@ private extension MetalModel { _ texCoord3: vector_float2, _ vertex3: vector_float3, _ normal: vector_float3, _ pieceIndex: Int) { + let pIndex = Int32(pieceIndex) vertexBuffer[0].position = vertex1 vertexBuffer[0].texCoord = texCoord1 vertexBuffer[0].normal = normal + vertexBuffer[0].pieceIndex = pIndex vertexBuffer[1].position = vertex2 vertexBuffer[1].texCoord = texCoord2 vertexBuffer[1].normal = normal + vertexBuffer[1].pieceIndex = pIndex vertexBuffer[2].position = vertex3 vertexBuffer[2].texCoord = texCoord3 vertexBuffer[2].normal = normal + vertexBuffer[2].pieceIndex = pIndex vertexBuffer += 3 } @@ -406,12 +422,13 @@ private extension MetalModel { _ vertex2: vector_float3, _ normal: vector_float3, _ pieceIndex: Int) { + let pIndex = Int32(pieceIndex) vertexBuffer[0].position = vertex1 -// vertexBuffer[0].texCoord = texCoord1 vertexBuffer[0].normal = normal + vertexBuffer[0].pieceIndex = pIndex vertexBuffer[1].position = vertex2 -// vertexBuffer[1].texCoord = texCoord2 vertexBuffer[1].normal = normal + vertexBuffer[1].pieceIndex = pIndex vertexBuffer += 2 } diff --git a/HPIView/HPIView/ModelViewRenderer+MetalShaderTypes.h b/HPIView/HPIView/ModelViewRenderer+MetalShaderTypes.h index 054979b..6be3f3d 100644 --- a/HPIView/HPIView/ModelViewRenderer+MetalShaderTypes.h +++ b/HPIView/HPIView/ModelViewRenderer+MetalShaderTypes.h @@ -32,9 +32,10 @@ typedef NS_ENUM(NSInteger, ModelMetalRenderer_BufferIndex) typedef NS_ENUM(NSInteger, ModelMetalRenderer_ModelVertexAttribute) { - ModelMetalRenderer_ModelVertexAttributePosition = 0, - ModelMetalRenderer_ModelVertexAttributeNormal = 1, - ModelMetalRenderer_ModelVertexAttributeTexcoord = 2, + ModelMetalRenderer_ModelVertexAttributePosition = 0, + ModelMetalRenderer_ModelVertexAttributeNormal = 1, + ModelMetalRenderer_ModelVertexAttributeTexcoord = 2, + ModelMetalRenderer_ModelVertexAttributePieceIndex = 3, }; typedef NS_ENUM(NSInteger, ModelMetalRenderer_GridVertexAttribute) @@ -54,6 +55,7 @@ typedef struct vector_float3 position ATTR(ModelMetalRenderer_ModelVertexAttributePosition); vector_float3 normal ATTR(ModelMetalRenderer_ModelVertexAttributeNormal); vector_float2 texCoord ATTR(ModelMetalRenderer_ModelVertexAttributeTexcoord); + int pieceIndex ATTR(ModelMetalRenderer_ModelVertexAttributePieceIndex); } ModelMetalRenderer_ModelVertex; typedef struct @@ -70,6 +72,7 @@ typedef struct vector_float4 objectColor; vector_float3 lightPosition; vector_float3 viewPosition; + int highlightedPieceIndex; } ModelMetalRenderer_ModelUniforms; typedef struct diff --git a/HPIView/HPIView/ModelViewRenderer+MetalShaders.metal b/HPIView/HPIView/ModelViewRenderer+MetalShaders.metal index 962f7a1..02fc2f2 100644 --- a/HPIView/HPIView/ModelViewRenderer+MetalShaders.metal +++ b/HPIView/HPIView/ModelViewRenderer+MetalShaders.metal @@ -24,6 +24,7 @@ typedef struct float3 positionM; float3 normal; float2 texCoord; + int pieceIndex [[flat]]; } FragmentIn; vertex FragmentIn vertexShader(ModelMetalRenderer_ModelVertex in [[stage_in]], @@ -36,6 +37,7 @@ vertex FragmentIn vertexShader(ModelMetalRenderer_ModelVertex in [[stage_in]], out.positionM = float3(position); out.normal = uniforms.normalMatrix * in.normal; out.texCoord = in.texCoord; + out.pieceIndex = in.pieceIndex; return out; } @@ -82,6 +84,9 @@ fragment float4 fragmentShader(FragmentIn in [[stage_in]], // else { out_color = lightContribution * uniforms.objectColor; // } + if (uniforms.highlightedPieceIndex >= 0 && in.pieceIndex == uniforms.highlightedPieceIndex) { + out_color.rgb = mix(out_color.rgb, float3(1.0, 0.85, 0.15), 0.55); + } return out_color; } diff --git a/HPIView/HPIView/PieceHierarchyView.swift b/HPIView/HPIView/PieceHierarchyView.swift new file mode 100644 index 0000000..88eb506 --- /dev/null +++ b/HPIView/HPIView/PieceHierarchyView.swift @@ -0,0 +1,174 @@ +// +// PieceHierarchyView.swift +// HPIView +// + +import AppKit +import SwiftTA_Core + +protocol PieceHierarchyViewDelegate: AnyObject { + func pieceHierarchyView(_ view: PieceHierarchyView, didSelectPieceAt index: UnitModel.Pieces.Index?) +} + +final class PieceHierarchyView: NSView { + + weak var selectionDelegate: PieceHierarchyViewDelegate? + + private let outline = NSOutlineView() + private let scrollView = NSScrollView() + private let nameColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + private let detailColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("detail")) + private let scriptsColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("scripts")) + private var nodes: [Node] = [] + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + nameColumn.title = "Piece" + nameColumn.minWidth = 120 + nameColumn.width = 200 + detailColumn.title = "Prims / Verts / Children" + detailColumn.minWidth = 140 + detailColumn.width = 160 + scriptsColumn.title = "Script Refs" + scriptsColumn.minWidth = 140 + scriptsColumn.width = 260 + outline.addTableColumn(nameColumn) + outline.addTableColumn(detailColumn) + outline.addTableColumn(scriptsColumn) + outline.outlineTableColumn = nameColumn + outline.rowSizeStyle = .small + outline.usesAlternatingRowBackgroundColors = true + outline.headerView = NSTableHeaderView() + outline.dataSource = self + outline.delegate = self + outline.autoresizesOutlineColumn = false + outline.allowsEmptySelection = true + outline.allowsMultipleSelection = false + + scrollView.documentView = outline + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.borderType = .bezelBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + func apply(model: UnitModel, script: UnitScript? = nil) { + let refsByScriptIndex = script?.pieceReferences() ?? [:] + var refsByModelIndex: [UnitModel.Pieces.Index: String] = [:] + if let script = script { + for (scriptIdx, refs) in refsByScriptIndex { + guard script.pieces.indices.contains(scriptIdx) else { continue } + let name = script.pieces[scriptIdx].lowercased() + guard let modelIdx = model.nameLookup[name] else { continue } + let byModule = Dictionary(grouping: refs, by: \.moduleName) + .map { moduleName, calls -> String in + let ops = Set(calls.map { String(describing: $0.opcode) }).sorted().joined(separator: ",") + return "\(moduleName)[\(ops)]" + } + .sorted() + refsByModelIndex[modelIdx] = byModule.joined(separator: " ") + } + } + let rootsToVisit: [UnitModel.Pieces.Index] = model.roots.isEmpty ? [model.root] : model.roots + nodes = rootsToVisit.map { Node(index: $0, model: model, refs: refsByModelIndex) } + outline.reloadData() + outline.expandItem(nil, expandChildren: true) + } + + func clear() { + nodes = [] + outline.reloadData() + } + + fileprivate final class Node { + let index: UnitModel.Pieces.Index + let name: String + let detail: String + let scripts: String + let children: [Node] + + init(index: UnitModel.Pieces.Index, model: UnitModel, refs: [UnitModel.Pieces.Index: String]) { + self.index = index + let piece = model.pieces[index] + self.name = piece.name.isEmpty ? "(unnamed)" : piece.name + let vertexCount = piece.primitives.reduce(0) { $0 + model.primitives[$1].indices.count } + self.detail = "\(piece.primitives.count) / \(vertexCount) / \(piece.children.count)" + self.scripts = refs[index] ?? "" + self.children = piece.children.map { Node(index: $0, model: model, refs: refs) } + } + } +} + +extension PieceHierarchyView: NSOutlineViewDataSource { + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if let node = item as? Node { return node.children.count } + return nodes.count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if let node = item as? Node { return node.children[index] } + return nodes[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + (item as? Node)?.children.isEmpty == false + } +} + +extension PieceHierarchyView: NSOutlineViewDelegate { + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? Node, let column = tableColumn else { return nil } + let identifier = NSUserInterfaceItemIdentifier("PieceCell.\(column.identifier.rawValue)") + let cell: NSTableCellView + if let existing = outlineView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView { + cell = existing + } else { + cell = NSTableCellView() + cell.identifier = identifier + let textField = NSTextField(labelWithString: "") + textField.translatesAutoresizingMaskIntoConstraints = false + textField.lineBreakMode = .byTruncatingTail + textField.font = NSFont.systemFont(ofSize: 11) + cell.addSubview(textField) + cell.textField = textField + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 2), + textField.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -2), + textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + } + let value: String + switch column { + case nameColumn: value = node.name + case detailColumn: value = node.detail + case scriptsColumn: value = node.scripts + default: value = "" + } + cell.textField?.stringValue = value + cell.textField?.toolTip = column === scriptsColumn && !node.scripts.isEmpty ? node.scripts : nil + return cell + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + let selected = outline.item(atRow: outline.selectedRow) as? Node + selectionDelegate?.pieceHierarchyView(self, didSelectPieceAt: selected?.index) + } +} diff --git a/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift b/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift index 831e22d..071b6db 100644 --- a/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift +++ b/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift @@ -12,7 +12,11 @@ import simd import SwiftTA_Core private let screenTileSize = 512 -private let maximumDisplaySize = Size2(width: 4096, height: 4096) +// Budget for on-screen map surface (not the map's native size). A larger value +// costs VRAM: each slice is 512×512 BGRA8 = 1 MB, so 16×16 slices = 256 MB. +// 8192² covers most 4K-class Retina displays at 1× zoom without falling back +// to the slice-0 placeholder. Larger maps scroll through this window. +private let maximumDisplaySize = Size2(width: 8192, height: 8192) private let maximumGridSize = maximumDisplaySize / screenTileSize private let maxBuffersInFlight = 3 @@ -153,6 +157,7 @@ extension DynamicTileMetalTntViewRenderer { let uniforms = uniformBuffer.next().contents.bindMemory(to: Uniforms.self, capacity: 1) uniforms.pointee.mvpMatrix = projectionMatrix * viewMatrix * modelMatrix + uniforms.pointee.mapSize = vector_float2(Float(map.resolution.width), Float(map.resolution.height)) if visibleTileGrid != lastTileGrid { let last = lastTileGrid @@ -473,7 +478,14 @@ private func prefillGridVertices(_ vertexBuffer: MTLBuffer, _ vertexCount: Int, } private func computeTileGrid(for rect: Rect4f, boundedBy bounds: Rect4) -> Rect4 { - return rect.computeGrid(division: GameFloat(screenTileSize)).clamp(within: bounds) + let raw = rect.computeGrid(division: GameFloat(screenTileSize)).clamp(within: bounds) + // Never ask the tile pool for more slices than it has; the screenTiles + // texture-2d-array is sized to maximumGridSize.area and the index/slice + // buffers are sized to that too, so any overflow writes past the buffer. + let clampedWidth = min(raw.size.width, maximumGridSize.width) + let clampedHeight = min(raw.size.height, maximumGridSize.height) + return Rect4(origin: raw.origin, + size: Size2(width: clampedWidth, height: clampedHeight)) } private extension Rect4 where Element: BinaryFloatingPoint { diff --git a/HPIView/HPIView/TntViewRenderer+MetalShaderTypes.h b/HPIView/HPIView/TntViewRenderer+MetalShaderTypes.h index d081641..bba0fef 100644 --- a/HPIView/HPIView/TntViewRenderer+MetalShaderTypes.h +++ b/HPIView/HPIView/TntViewRenderer+MetalShaderTypes.h @@ -66,6 +66,8 @@ typedef struct typedef struct { matrix_float4x4 mvpMatrix; + vector_float2 mapSize; + vector_float2 _pad; } MetalTntViewRenderer_MapUniforms; #pragma pack(pop) diff --git a/HPIView/HPIView/TntViewRenderer+MetalShaders.metal b/HPIView/HPIView/TntViewRenderer+MetalShaders.metal index 9b8a3eb..87cbc21 100644 --- a/HPIView/HPIView/TntViewRenderer+MetalShaders.metal +++ b/HPIView/HPIView/TntViewRenderer+MetalShaders.metal @@ -37,9 +37,18 @@ fragment float4 mapQuadFragmentShader(QuadFragmentIn in [[stage_in]], constant MetalTntViewRenderer_MapUniforms & uniforms [[ buffer(MetalTntViewRenderer_BufferIndexUniforms) ]], texture2d colorMap [[ texture(MetalTntViewRenderer_TextureIndexColor) ]]) { + // Discard fragments that fall outside the actual map texture so the + // clear color shows past the map edge instead of the sampler smearing + // the last row/column of pixels. + if (in.texCoord.x < 0.0 || in.texCoord.x > 1.0 || + in.texCoord.y < 0.0 || in.texCoord.y > 1.0) { + discard_fragment(); + } constexpr sampler colorSampler(mip_filter::nearest, mag_filter::nearest, - min_filter::nearest); + min_filter::nearest, + s_address::clamp_to_zero, + t_address::clamp_to_zero); half4 colorSample = colorMap.sample(colorSampler, in.texCoord.xy); return float4(colorSample); @@ -51,6 +60,7 @@ typedef struct { float4 position [[position]]; float2 texCoord; + float2 worldPosition; int slice; } TileFragmentIn; @@ -62,6 +72,7 @@ vertex TileFragmentIn mapTileVertexShader(MetalTntViewRenderer_MapTileVertex in TileFragmentIn out; out.position = uniforms.mvpMatrix * float4(in.position, 1.0); out.texCoord = in.texCoord; + out.worldPosition = in.position.xy; out.slice = slice[vid]; return out; } @@ -70,10 +81,21 @@ fragment float4 mapTileFragmentShader(TileFragmentIn in [[stage_in]], constant MetalTntViewRenderer_MapUniforms & uniforms [[ buffer(MetalTntViewRenderer_BufferIndexUniforms) ]], texture2d_array colorMap [[ texture(MetalTntViewRenderer_TextureIndexColor) ]]) { + // Discard pixels outside the map's actual pixel area so partial-edge + // tiles don't expose uninitialized slice memory or repeat the last + // real column of terrain. + if (uniforms.mapSize.x > 0.0 && uniforms.mapSize.y > 0.0) { + if (in.worldPosition.x >= uniforms.mapSize.x || + in.worldPosition.y >= uniforms.mapSize.y) { + discard_fragment(); + } + } constexpr sampler colorSampler(mip_filter::nearest, mag_filter::nearest, - min_filter::nearest); + min_filter::nearest, + s_address::clamp_to_zero, + t_address::clamp_to_zero); half4 colorSample = colorMap.sample(colorSampler, in.texCoord.xy, in.slice); - + return float4(colorSample); } diff --git a/HPIView/HPIView/TntViewRenderer+MetalSingleQuad.swift b/HPIView/HPIView/TntViewRenderer+MetalSingleQuad.swift index 1996ccb..7062bf9 100644 --- a/HPIView/HPIView/TntViewRenderer+MetalSingleQuad.swift +++ b/HPIView/HPIView/TntViewRenderer+MetalSingleQuad.swift @@ -133,6 +133,7 @@ extension SingleTextureMetalTntViewRenderer { let uniforms = uniformBuffer.next().contents.bindMemory(to: Uniforms.self, capacity: 1) uniforms.pointee.mvpMatrix = projectionMatrix * viewMatrix * modelMatrix + uniforms.pointee.mapSize = vector_float2(Float(texture.width), Float(texture.height)) let vx = viewportSize.x let vy = viewportSize.y diff --git a/HPIView/HPIView/TntViewRenderer+MetalStaticGrid.swift b/HPIView/HPIView/TntViewRenderer+MetalStaticGrid.swift index 3ddde07..f03ef73 100644 --- a/HPIView/HPIView/TntViewRenderer+MetalStaticGrid.swift +++ b/HPIView/HPIView/TntViewRenderer+MetalStaticGrid.swift @@ -109,6 +109,7 @@ extension StaticTextureSetMetalTntViewRenderer { let uniforms = uniformBuffer.contents().bindMemory(to: Uniforms.self, capacity: 1) uniforms.pointee.mvpMatrix = projectionMatrix * viewMatrix * modelMatrix + uniforms.pointee.mapSize = vector_float2(0, 0) } func drawFrame(with renderEncoder: MTLRenderCommandEncoder) { diff --git a/README.md b/README.md index 026c456..25be9f0 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,140 @@ -# SwiftTA +# SwiftTA — Asset Inspectors for Total Annihilation -I like [Swift](https://swift.org); but I'd like to get to know it better outside of my day job: writing iOS apps and libraries. So I've decided to retrace the steps of an old project I worked on ages ago: [writing a clone of Total Annihilation](https://github.com/loganjones/nTA-Total-Annihilation-Clone). +Two macOS apps for exploring Total Annihilation's game data. Point them at a folder of TA archives and browse every unit, map, weapon, and file inside: -Currently, there is a simple game client (macOS, iOS, Linux) that loads up a hardcoded map and displays a single unit. See the [Build](#build) section for information on building and running the client. +- **TAassets** — unified asset browser with live 3D model previews, COB script playback, a piece hierarchy inspector, map height / passability overlays, and mod support. +- **HPIView** — a tree explorer for individual `.hpi` / `.ufo` / `.ccx` / `.gp3` / `.gpf` archives with per-file preview and bulk extraction. +- **AEX-MapEditor** *(early access)* — a bare-bones heightmap editor for loose `.tnt` files. Current build supports height raise/lower brushing with undo/redo and saves back to the original format. Tile painting, feature placement, and OTA metadata editing are on the roadmap. -![Screenshot](SwiftTA.jpg "SwiftTA Screenshot") +Both apps run natively on Apple silicon (and Intel Macs) on macOS 10.13+, read every TA-family archive format, handle TAESC-style mods, and do not require a copy of Xcode to use. -Additionally, there are a couple of macOS applications, [TAassets](#taassets) and [HPIView](#hpiview), that browse TA archive files (HPI & UFO files) and shows a preview of its contents. +## Download -## Build +Grab the latest build from the [Releases page](https://github.com/csilvertooth/SwiftTA/releases): -#### macOS & iOS +- **`TAassets-macOS.zip`** — the full asset browser +- **`HPIView-macOS.zip`** — archive viewer only +- **`AEX-MapEditor-macOS.zip`** — early-access heightmap editor -Use the SwiftTA workspace (SwiftTA.xcworkspace) to build the macOS and/or the iOS game client. +The `Latest main` prerelease is refreshed on every push to `main`. Versioned releases (e.g. `v0.1.0`) are posted when they're cut. -#### Linux +### First launch + +The apps are **ad-hoc signed** (no paid Apple Developer certificate), so Gatekeeper will block the first launch. To open them: + +1. Unzip the download and move the `.app` into `/Applications` or `~/Applications`. +2. In Finder, **right-click → Open** (or Control-click → Open). +3. Confirm the "unidentified developer" prompt once. macOS remembers the choice. + +After that, launch them like any other app. + +## What files do I need? + +You need a copy of the **original Total Annihilation** game files. The apps don't ship with any game content — they just read whatever TA archives you point them at. + +A working TA files directory typically contains: + +| File(s) | Source | Role | +|---|---|---| +| `ccdata.ccx`, `ccmaps.ccx`, `ccmiss.ccx` | Cavedog CD-ROM or digital copy | Core game data (units, tiles, scripts) | +| `rev31.gp3` | TA patch 3.1 | Retail unit/engine patch | +| `btdata.ccx` *(optional)* | Battle Tactics expansion | Expansion units | +| `cc*.hpi`, `ta_features_2013.ccx` *(optional)* | Community | Additional features, maps, and the definitive feature pack | +| `mods//` *(optional)* | Mod author | Drop a mod folder here — more on mods below | + +Any folder containing these files will work — the apps don't require a specific install location. A common layout: -The Linux build was developed using the official Swift 4.2 binaries for Ubuntu 16.04 from Swift.org. Additionally, the following packages are necessary to build: ``` -clang libicu-dev libcurl3 libglfw3-dev libglfw3 libpng-dev +~/tafiles/ + ccdata.ccx + ccmaps.ccx + ccmiss.ccx + rev31.gp3 + TA_Features_2013.ccx + mods/ + taesc/ + TAESC.gp3 + T2ESC.ufo + ... ``` -To build the game target, use a terminal to run `swift build` from the `SwiftTA/SwiftTA Linux` directory. To run the game, use `swift run`. +If `TA_Features_2013.ccx` is missing, maps still render but some features (trees, rocks, wrecks) will not. TAassets logs a clear warning pointing at the missing feature pack. -#### Windows +## Using TAassets -😅 ... yeah, about that. I haven't been able to get a build of the Swift compiler working on my Windows machine. It would be much easier if there were official builds available from Swift.org or even from Microsoft; but that is not a reality yet; maybe after Swift 5 and the ABI work? Another complication would be the lack of a C++ interface. +1. Launch `TAassets.app`. +2. `File → Open…` and pick your TA files folder (e.g. `~/tafiles`). +3. The sidebar has four browsers: **Units**, **Weapons**, **Maps**, **Files**. -## Game Assets +### Units browser -Running the current game client requires that the Total Annihilation game files be accessible in your current user's Documents directory. More specifically, the game is hardcoded to look in `~/Documents/Total Annihilation` for any .hpi files (or .ufo, .ccx, etc). This is certainly a hack and will be addressed in the future. Note: a symbolic link to another directory is acceptable; though the link must be named `Total Annihilation`. +- Filter the list with the search field at the top. +- Click a unit → 3D model renders on the right, textured and lit. +- **Camera**: drag to rotate heading, shift-drag to pitch, scroll / pinch to zoom, `=` / `-` / `0` to zoom-in / zoom-out / reset. +- **Piece hierarchy pane** on the right shows every 3DO piece with its primitive / vertex / child counts and every COB module that manipulates it. Click a piece to tint it gold in the 3D view. +- **COB playback** at the bottom: Pause, Step, 0× – 4× speed slider. +- **"Run script…" menu** fires any module in the unit's COB (`Create`, `Activate`, `QueryPrimary`, `StartMoving`, `StopMoving`, etc.). +- Press **`d`** while the 3D view is focused to dump every piece's current offset / turn / move / world position to the console — useful for diagnosing IK. +- On load, the viewer freezes background threads after `Create` returns so a walker unit holds its IK pose rather than running a forever-gait over an empty scene. Fire `StartMoving` manually if you want to see the gait. -#### iOS +### Maps browser -On iOS, this is difficult due to the lack of direct filesystem access. The easiest way to get the files into the right place is to run the game app once; and then use iTunes to copy the `Total Annihilation` directory over to the app's container. Find [device] -> File Sharing -> SwiftTA and just drag-and-drop the entire folder. +- Filter the list and click any map. +- The header strip carries map info (planet, player count, wind, tidal, gravity). +- Numbered markers show OTA start positions. +- **Overlay toggle** (None / Heights / Passability): + - **Heights** tints each 16×16 cell from deep-blue (below sea level) through greens and yellows to white on high peaks. + - **Passability** shows cells colored by slope — red where the max elevation delta to any neighbor exceeds the slope threshold, blue under sea level, orange where a feature occupies the cell, green-to-yellow for passable terrain. A slider lets you tune the threshold to match different movement classes. -#### Linux +### Weapons browser -To run the game, use `swift run` from the `SwiftTA/SwiftTA Linux` directory (this will also build the project if it hasn't been built already). +- Walks every `weapon*/` directory and parses every `.tdf` recursively. Every weapon block from every mod's weapon tables is listed. +- Click a weapon to see its key, source file, type, range, damage table, and raw properties. -Note: You will need an OpenGL 3.0 capable graphics driver to run the game. For development, I've been using the default driver in a VMWare Fusion install. +### Files browser -## TAassets +- The full merged virtual filesystem — every archive's contents layered into one tree, exactly how TA itself sees the files. +- Useful when you want to find where a specific file lives across multiple archives. -![Screenshot](TAassets.gif "TAassets Screenshot") +### Using mods -A macOS application that browses all of the assets contained in the TA archive files (HPI & UFO files) of a TA install directory. With this you can see the "virtual" file-sytem hierarchy that TA uses to load its assets. Additionally, you can browse specific categories (like units) to see a more complete representation (model + textures + animations). +TAassets automatically discovers mod folders under `/mods/`: -You will need a Mac (natch) and a Total Annihilation installation somewhere on your browsable file-system. TAassets will read the files just as TA would; so any downloadable unit (a UFO) or other third-party material should "just work". +- A dynamic **Mods** menu appears in the menu bar listing every available mod. +- Selecting a mod rebuilds the merged filesystem with that mod layered on top of the vanilla base. +- You can also open a mod folder directly (e.g. `~/tafiles/mods/taesc`) — TAassets will auto-pair it with the vanilla base it lives next to. +- TAESC-style mods with nested `unitsE/`, `weaponE/`, and `unitpicE/` directories are discovered recursively. + +## Using HPIView + +1. Launch `HPIView.app`. +2. `File → Open…` and pick any `.hpi`, `.ufo`, `.ccx`, `.gp3`, or `.gpf` archive. +3. The left pane shows the archive's directory tree. The right pane previews whichever file you click. +4. Drill into `objects3d/` and click a `.3DO` to see the piece hierarchy plus references from the unit's COB script. Resize the split divider to adjust the outline width. +5. **Extract from the File menu**: a single file, the current selection, or the entire archive to a chosen folder. + +## Build from source + +If you'd rather build the apps yourself: + +``` +git clone https://github.com/csilvertooth/SwiftTA.git +cd SwiftTA +xcodebuild -workspace SwiftTA.xcworkspace -scheme TAassets \ + -destination 'platform=macOS' \ + -configuration Release \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO build +``` -## HPIView +Replace `-scheme TAassets` with `-scheme HPIView` for the other app. Built bundles land under `build/DerivedData/Build/Products/Release/`. -![Screenshot](HpiView.jpg "HpiView Screenshot") +You'll need Xcode 26+ on macOS 26+ (older combos should also work but aren't tested). -A macOS application that browses the TA archive files (HPI & UFO files) and shows a preview of its contents. This is similar to an old Windows program (which I believe had the same name). +## About this fork -You will need a Mac and an HPI file or two. You can find these in Total Annihilation's main install directory. Any downloadable unit (a UFO) will work as well. As a bonus, you can also browse Total Annihilation: Kingdoms HPI files. +This repository is a fork of the original [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) focused on modernizing the TAassets / HPIView tooling. See [docs/FORK_NOTES.md](docs/FORK_NOTES.md) for the summary of additions, and [notes/SwiftTA_Apple_Silicon_Bootstrap.md](notes/SwiftTA_Apple_Silicon_Bootstrap.md) for the file-by-file technical write-up. The original upstream README (Swift 4.2 / Ubuntu 16.04 era game-client instructions) is preserved at [docs/ORIGINAL_README.md](docs/ORIGINAL_README.md). -## Next Steps +## Credits -Continuous iteration on the game client. Real unit loading. A full object system. UI interaction. So much to do. +- [Logan Jones](https://github.com/loganjones) — original SwiftTA project, HPIView, TAassets. +- Cavedog Entertainment — Total Annihilation (1997). diff --git a/SwiftTA Linux/Cgl/Package.swift b/SwiftTA Linux/Cgl/Package.swift deleted file mode 100644 index cbc7601..0000000 --- a/SwiftTA Linux/Cgl/Package.swift +++ /dev/null @@ -1,10 +0,0 @@ -// swift-tools-version:4.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Cgl", - pkgConfig: "gl", - providers: [ ] -) diff --git a/SwiftTA Linux/Cgl/README.md b/SwiftTA Linux/Cgl/README.md deleted file mode 100644 index 05c89bd..0000000 --- a/SwiftTA Linux/Cgl/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Cgl - -A description of this package. diff --git a/SwiftTA Linux/Cgl/module.modulemap b/SwiftTA Linux/Cgl/module.modulemap deleted file mode 100644 index 949548f..0000000 --- a/SwiftTA Linux/Cgl/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module Cgl [system] { - header "shim.h" - link "GL" - export * -} diff --git a/SwiftTA Linux/Cgl/shim.h b/SwiftTA Linux/Cgl/shim.h deleted file mode 100644 index 446ea78..0000000 --- a/SwiftTA Linux/Cgl/shim.h +++ /dev/null @@ -1,4 +0,0 @@ -#include -#define GL_GLEXT_PROTOTYPES 1 -#include - diff --git a/SwiftTA Linux/Cglfw/Package.swift b/SwiftTA Linux/Cglfw/Package.swift deleted file mode 100644 index 85d1d99..0000000 --- a/SwiftTA Linux/Cglfw/Package.swift +++ /dev/null @@ -1,14 +0,0 @@ -// swift-tools-version:4.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Cglfw", - pkgConfig: "glfw3", - providers: [ - .apt(["libglfw3"]), - .apt(["libglfw3-dev"]) - ] -) - diff --git a/SwiftTA Linux/Cglfw/README.md b/SwiftTA Linux/Cglfw/README.md deleted file mode 100644 index db0f783..0000000 --- a/SwiftTA Linux/Cglfw/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Cglfw - -A description of this package. diff --git a/SwiftTA Linux/Cglfw/module.modulemap b/SwiftTA Linux/Cglfw/module.modulemap deleted file mode 100644 index 2d28e26..0000000 --- a/SwiftTA Linux/Cglfw/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module Cglfw [system] { - header "shim.h" - link "glfw" - export * -} diff --git a/SwiftTA Linux/Cglfw/shim.h b/SwiftTA Linux/Cglfw/shim.h deleted file mode 100644 index c509ab3..0000000 --- a/SwiftTA Linux/Cglfw/shim.h +++ /dev/null @@ -1,3 +0,0 @@ -#include -#include - diff --git a/SwiftTA Linux/Ctypes/Package.swift b/SwiftTA Linux/Ctypes/Package.swift deleted file mode 100644 index 6c25285..0000000 --- a/SwiftTA Linux/Ctypes/Package.swift +++ /dev/null @@ -1,10 +0,0 @@ -// swift-tools-version:4.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Ctypes", - pkgConfig: "types", - providers: [ ] -) diff --git a/SwiftTA Linux/Ctypes/README.md b/SwiftTA Linux/Ctypes/README.md deleted file mode 100644 index 05c89bd..0000000 --- a/SwiftTA Linux/Ctypes/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Cgl - -A description of this package. diff --git a/SwiftTA Linux/Ctypes/module.modulemap b/SwiftTA Linux/Ctypes/module.modulemap deleted file mode 100644 index 8ddfe63..0000000 --- a/SwiftTA Linux/Ctypes/module.modulemap +++ /dev/null @@ -1,4 +0,0 @@ -module Ctypes [system] { - header "shim.h" - export * -} diff --git a/SwiftTA Linux/Ctypes/shim.h b/SwiftTA Linux/Ctypes/shim.h deleted file mode 100644 index b8def7f..0000000 --- a/SwiftTA Linux/Ctypes/shim.h +++ /dev/null @@ -1,8 +0,0 @@ -#include -#include "../../Common/pcx.h" -#include "../../Common/ta_3DO.h" -#include "../../Common/ta_COB.h" -#include "../../Common/ta_GAF.h" -#include "../../Common/ta_HPI.h" -#include "../../Common/ta_TNT.h" - diff --git a/SwiftTA Linux/Czlib/Package.swift b/SwiftTA Linux/Czlib/Package.swift deleted file mode 100644 index 82266bb..0000000 --- a/SwiftTA Linux/Czlib/Package.swift +++ /dev/null @@ -1,12 +0,0 @@ -// swift-tools-version:4.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Czlib", - pkgConfig: "zlib", - providers: [ - .apt(["zlib1g-dev"]), - ] -) diff --git a/SwiftTA Linux/Czlib/README.md b/SwiftTA Linux/Czlib/README.md deleted file mode 100644 index 05c89bd..0000000 --- a/SwiftTA Linux/Czlib/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Cgl - -A description of this package. diff --git a/SwiftTA Linux/Czlib/module.modulemap b/SwiftTA Linux/Czlib/module.modulemap deleted file mode 100644 index cba0657..0000000 --- a/SwiftTA Linux/Czlib/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module Czlib [system] { - header "shim.h" - link "z" - export * -} diff --git a/SwiftTA Linux/Czlib/shim.h b/SwiftTA Linux/Czlib/shim.h deleted file mode 100644 index 5044c38..0000000 --- a/SwiftTA Linux/Czlib/shim.h +++ /dev/null @@ -1,3 +0,0 @@ -#include -#include - diff --git a/SwiftTA Linux/SwiftTA/Package.resolved b/SwiftTA Linux/SwiftTA/Package.resolved deleted file mode 100644 index 5cefbe8..0000000 --- a/SwiftTA Linux/SwiftTA/Package.resolved +++ /dev/null @@ -1,8 +0,0 @@ -{ - "object": { - "pins": [ - - ] - }, - "version": 1 -} diff --git a/SwiftTA Linux/SwiftTA/Package.swift b/SwiftTA Linux/SwiftTA/Package.swift deleted file mode 100644 index c4ec868..0000000 --- a/SwiftTA Linux/SwiftTA/Package.swift +++ /dev/null @@ -1,31 +0,0 @@ -// swift-tools-version:4.2 -import PackageDescription - -let package = Package( - name: "SwiftTA", - dependencies: [ - .package(path: "../Cgl"), - .package(path: "../Cglfw"), - .package(path: "../Czlib"), - .package(path: "../Ctypes"), - ], - targets: [ - .target( - name: "SwiftTA", - path: ".", - exclude: [ - "../../Common/Geometry+simd.swift", - "../../Common/MetalFeatureDrawable.swift", - "../../Common/MetalOneTextureTntDrawable.swift", - "../../Common/MetalRenderer.swift", - "../../Common/MetalTiledTntDrawable.swift", - "../../Common/MetalUnitDrawable.swift", - "../../Common/OpenglCore3Renderer+Cocoa.swift", - "../../Common/Utility+Metal.swift", - "../../Common/UnitScript+CobDecompile.swift", - ], - sources: ["main.swift", "../../Common"] - ) - ] -) - diff --git a/SwiftTA Linux/SwiftTA/main.swift b/SwiftTA Linux/SwiftTA/main.swift deleted file mode 100644 index 05c18f0..0000000 --- a/SwiftTA Linux/SwiftTA/main.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// main.swift -// SwiftTA -// -// Created by Logan Jones on 9/21/18. -// Copyright © 2018 Logan Jones. All rights reserved. -// - -import Foundation -import Cglfw - - -class GameBox { - var renderer: RunLoopGameRenderer - var manager: GameManager - - init(_ renderer: RunLoopGameRenderer, _ manager: GameManager) { - self.renderer = renderer - self.manager = manager - } -} - -struct FrameRate { - var tRot0 = -1.0 - var tRate0 = -1.0 - var frames = 0 - - init() { } - - mutating func sample(_ t: Double) -> Double { - - if tRot0 < 0.0 { - tRot0 = t - } - - let dt = t - tRot0 - tRot0 = t - - frames += 1 - - if tRate0 < 0.0 { - tRate0 = t - } - if t - tRate0 >= 5 { - let seconds = t - tRate0 - let fps = GLfloat(frames) / GLfloat(seconds) - print("\(frames) frames in \(seconds) seconds = \(fps) FPS") - tRate0 = t - frames = 0 - } - - return dt - } - -} - -func glfwSetGameContext(_ game: GameBox, for window: OpaquePointer?) { - glfwSetWindowUserPointer(window, Unmanaged.passUnretained(game).toOpaque()) -} - -func glfwGetGameContext(for window: OpaquePointer?) -> GameBox { - guard let p = glfwGetWindowUserPointer(window) else { - fatalError("No game context set for window!?") - } - return Unmanaged.fromOpaque(p).takeUnretainedValue() -} - - -/* new window size or exposure */ -func reshape(window: OpaquePointer?, to viewportSize: Size2) -{ - let game = glfwGetGameContext(for: window) - - game.renderer.viewState.viewport.size = Size2f(viewportSize) - - glViewport(0, 0, GLsizei(viewportSize.width), GLsizei(viewportSize.height)) -} - -func keyboardKey(event: (key: Int32, scancode: Int32, action: Int32, mods: Int32), in window: OpaquePointer?) { - let game = glfwGetGameContext(for: window) - - switch (event.action, event.key) { - - case (GLFW_PRESS, GLFW_KEY_LEFT): fallthrough - case (GLFW_REPEAT, GLFW_KEY_LEFT): - game.renderer.viewState.viewport.origin.x -= 8.0 - - case (GLFW_PRESS, GLFW_KEY_RIGHT): fallthrough - case (GLFW_REPEAT, GLFW_KEY_RIGHT): - game.renderer.viewState.viewport.origin.x += 8.0 - - case (GLFW_PRESS, GLFW_KEY_UP): fallthrough - case (GLFW_REPEAT, GLFW_KEY_UP): - game.renderer.viewState.viewport.origin.y -= 8.0 - - case (GLFW_PRESS, GLFW_KEY_DOWN): fallthrough - case (GLFW_REPEAT, GLFW_KEY_DOWN): - game.renderer.viewState.viewport.origin.y += 8.0 - - case (GLFW_PRESS, GLFW_KEY_ESCAPE): - glfwSetWindowShouldClose(window, GL_TRUE) - - default: - () - } -} - - -func main() { - - glfwSetErrorCallback() { (error, description) in - fputs(description, stderr) - } - - if glfwInit() == 0 { - exit(EXIT_FAILURE) - } - - glfwWindowHint(GLFW_SAMPLES, 4) - glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3) - glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3) - glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE) - glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE) - - let initialWindowSize = Size2(1024, 768) - guard let window = glfwCreateWindow( - Int32(initialWindowSize.width), - Int32(initialWindowSize.height), - "SwiftTA", nil, nil) - else { - glfwTerminate() - exit(EXIT_FAILURE) - } - - glfwMakeContextCurrent(window) - glfwSwapInterval(1) - - let game: GameBox - do { - let documents = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Documents", isDirectory: true) - let gameState = try GameState(testLoadFromDocumentsDirectory: documents) - - let initialViewState = gameState.generateInitialViewState(viewportSize: initialWindowSize) - - guard let renderer = OpenglCore3Renderer(loadedState: gameState, viewState: initialViewState) - else { - throw RuntimeError("Failed to initialize renderer.") - } - renderer.load(state: gameState) - - let manager = GameManager(state: gameState, renderer: renderer) - - game = GameBox(renderer, manager) - } - catch { - print("Failed to load GameState: \(error)") - glfwTerminate() - exit(EXIT_FAILURE) - } - - glfwSetGameContext(game, for: window) - - glfwSetKeyCallback(window) { - (win, key, scancode, action, mods) in - keyboardKey(event: (key, scancode, action, mods), in: win) - } - glfwSetWindowSizeCallback(window) { - (win, width, height) in - reshape(window: win, to: Size2(Int(width), Int(height))) - } - - reshape(window: window, to: initialWindowSize) - var frameRate = FrameRate() - - game.manager.start() - while glfwWindowShouldClose(window) == 0 { - - let dt = frameRate.sample(getCurrentTime()) - - game.renderer.drawFrame() - - glfwSwapBuffers(window) - glfwPollEvents() - } - - game.manager.stop() - glfwDestroyWindow(window) - glfwTerminate() - exit(EXIT_SUCCESS) -} - - -main() diff --git a/SwiftTA iOS/SwiftTA iOS.xcodeproj/project.pbxproj b/SwiftTA iOS/SwiftTA iOS.xcodeproj/project.pbxproj deleted file mode 100644 index b4be5e2..0000000 --- a/SwiftTA iOS/SwiftTA iOS.xcodeproj/project.pbxproj +++ /dev/null @@ -1,371 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 52; - objects = { - -/* Begin PBXBuildFile section */ - B5C284AA23CD68E2007754E6 /* SwiftTA-Core in Frameworks */ = {isa = PBXBuildFile; productRef = B5C284A923CD68E2007754E6 /* SwiftTA-Core */; }; - B5CAAF2420B26C76003B17D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CAAF2320B26C76003B17D7 /* AppDelegate.swift */; }; - B5CAAF2620B26C76003B17D7 /* GameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CAAF2520B26C76003B17D7 /* GameViewController.swift */; }; - B5CAAF2920B26C76003B17D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5CAAF2720B26C76003B17D7 /* Main.storyboard */; }; - B5CAAF2B20B26C76003B17D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5CAAF2A20B26C76003B17D7 /* Assets.xcassets */; }; - B5CAAF2E20B26C76003B17D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5CAAF2C20B26C76003B17D7 /* LaunchScreen.storyboard */; }; - B5EDC8D524A9684A00313D5F /* SwiftTA-Metal in Frameworks */ = {isa = PBXBuildFile; productRef = B5EDC8D424A9684A00313D5F /* SwiftTA-Metal */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - B5CAAF2020B26C76003B17D7 /* SwiftTA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftTA.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B5CAAF2320B26C76003B17D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B5CAAF2520B26C76003B17D7 /* GameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewController.swift; sourceTree = ""; }; - B5CAAF2820B26C76003B17D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - B5CAAF2A20B26C76003B17D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B5CAAF2D20B26C76003B17D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - B5CAAF2F20B26C76003B17D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - B5CAAF1D20B26C76003B17D7 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - B5C284AA23CD68E2007754E6 /* SwiftTA-Core in Frameworks */, - B5EDC8D524A9684A00313D5F /* SwiftTA-Metal in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - B5C284A823CD68E2007754E6 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; - B5CAAF1720B26C76003B17D7 = { - isa = PBXGroup; - children = ( - B5CAAF2220B26C76003B17D7 /* SwiftTA iOS */, - B5CAAF2120B26C76003B17D7 /* Products */, - B5C284A823CD68E2007754E6 /* Frameworks */, - ); - sourceTree = ""; - }; - B5CAAF2120B26C76003B17D7 /* Products */ = { - isa = PBXGroup; - children = ( - B5CAAF2020B26C76003B17D7 /* SwiftTA.app */, - ); - name = Products; - sourceTree = ""; - }; - B5CAAF2220B26C76003B17D7 /* SwiftTA iOS */ = { - isa = PBXGroup; - children = ( - B5CAAF2320B26C76003B17D7 /* AppDelegate.swift */, - B5CAAF2520B26C76003B17D7 /* GameViewController.swift */, - B5CAAF2720B26C76003B17D7 /* Main.storyboard */, - B5CAAF2A20B26C76003B17D7 /* Assets.xcassets */, - B5CAAF2C20B26C76003B17D7 /* LaunchScreen.storyboard */, - B5CAAF2F20B26C76003B17D7 /* Info.plist */, - ); - path = "SwiftTA iOS"; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - B5CAAF1F20B26C76003B17D7 /* SwiftTA iOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = B5CAAF3220B26C76003B17D7 /* Build configuration list for PBXNativeTarget "SwiftTA iOS" */; - buildPhases = ( - B5CAAF1C20B26C76003B17D7 /* Sources */, - B5CAAF1D20B26C76003B17D7 /* Frameworks */, - B5CAAF1E20B26C76003B17D7 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SwiftTA iOS"; - packageProductDependencies = ( - B5C284A923CD68E2007754E6 /* SwiftTA-Core */, - B5EDC8D424A9684A00313D5F /* SwiftTA-Metal */, - ); - productName = "SwiftTA iOS"; - productReference = B5CAAF2020B26C76003B17D7 /* SwiftTA.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - B5CAAF1820B26C76003B17D7 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0930; - LastUpgradeCheck = 1220; - ORGANIZATIONNAME = "Logan Jones"; - TargetAttributes = { - B5CAAF1F20B26C76003B17D7 = { - CreatedOnToolsVersion = 9.3.1; - LastSwiftMigration = 1000; - }; - }; - }; - buildConfigurationList = B5CAAF1B20B26C76003B17D7 /* Build configuration list for PBXProject "SwiftTA iOS" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = B5CAAF1720B26C76003B17D7; - productRefGroup = B5CAAF2120B26C76003B17D7 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - B5CAAF1F20B26C76003B17D7 /* SwiftTA iOS */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - B5CAAF1E20B26C76003B17D7 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B5CAAF2E20B26C76003B17D7 /* LaunchScreen.storyboard in Resources */, - B5CAAF2B20B26C76003B17D7 /* Assets.xcassets in Resources */, - B5CAAF2920B26C76003B17D7 /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - B5CAAF1C20B26C76003B17D7 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B5CAAF2620B26C76003B17D7 /* GameViewController.swift in Sources */, - B5CAAF2420B26C76003B17D7 /* AppDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - B5CAAF2720B26C76003B17D7 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - B5CAAF2820B26C76003B17D7 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - B5CAAF2C20B26C76003B17D7 /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - B5CAAF2D20B26C76003B17D7 /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - B5CAAF3020B26C76003B17D7 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - B5CAAF3120B26C76003B17D7 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - B5CAAF3320B26C76003B17D7 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = "SwiftTA iOS/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "Toasty.SwiftTA-iOS"; - PRODUCT_NAME = SwiftTA; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - B5CAAF3420B26C76003B17D7 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = "SwiftTA iOS/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "Toasty.SwiftTA-iOS"; - PRODUCT_NAME = SwiftTA; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - B5CAAF1B20B26C76003B17D7 /* Build configuration list for PBXProject "SwiftTA iOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B5CAAF3020B26C76003B17D7 /* Debug */, - B5CAAF3120B26C76003B17D7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B5CAAF3220B26C76003B17D7 /* Build configuration list for PBXNativeTarget "SwiftTA iOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B5CAAF3320B26C76003B17D7 /* Debug */, - B5CAAF3420B26C76003B17D7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCSwiftPackageProductDependency section */ - B5C284A923CD68E2007754E6 /* SwiftTA-Core */ = { - isa = XCSwiftPackageProductDependency; - productName = "SwiftTA-Core"; - }; - B5EDC8D424A9684A00313D5F /* SwiftTA-Metal */ = { - isa = XCSwiftPackageProductDependency; - productName = "SwiftTA-Metal"; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = B5CAAF1820B26C76003B17D7 /* Project object */; -} diff --git a/SwiftTA iOS/SwiftTA iOS.xcodeproj/xcshareddata/xcschemes/SwiftTA iOS.xcscheme b/SwiftTA iOS/SwiftTA iOS.xcodeproj/xcshareddata/xcschemes/SwiftTA iOS.xcscheme deleted file mode 100644 index 5c7711d..0000000 --- a/SwiftTA iOS/SwiftTA iOS.xcodeproj/xcshareddata/xcschemes/SwiftTA iOS.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftTA iOS/SwiftTA iOS/AppDelegate.swift b/SwiftTA iOS/SwiftTA iOS/AppDelegate.swift deleted file mode 100644 index 5ac2e51..0000000 --- a/SwiftTA iOS/SwiftTA iOS/AppDelegate.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// AppDelegate.swift -// SwiftTA iOS -// -// Created by Logan Jones on 5/20/18. -// Copyright © 2018 Logan Jones. All rights reserved. -// - -import UIKit -import SwiftTA_Core - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - startLoading() - return true - } - - func startLoading() { - DispatchQueue(label: "Loading").async { - do { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - throw RuntimeError("No Documents directory?!") - } - - let state = try GameState(testLoadFromDocumentsDirectory: documents) - - DispatchQueue.main.async { - self.proceedWithLoaded(state) - } - } - catch { - print("Loading phase failed with error: \(error)") - } - } - } - - func proceedWithLoaded(_ state: GameState) { - let vc = GameViewController(state) - window?.rootViewController = vc - } - -} diff --git a/SwiftTA iOS/SwiftTA iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/SwiftTA iOS/SwiftTA iOS/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d8db8d6..0000000 --- a/SwiftTA iOS/SwiftTA iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "3x" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "83.5x83.5", - "scale" : "2x" - }, - { - "idiom" : "ios-marketing", - "size" : "1024x1024", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/SwiftTA iOS/SwiftTA iOS/Assets.xcassets/Contents.json b/SwiftTA iOS/SwiftTA iOS/Assets.xcassets/Contents.json deleted file mode 100644 index da4a164..0000000 --- a/SwiftTA iOS/SwiftTA iOS/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/SwiftTA iOS/SwiftTA iOS/Base.lproj/LaunchScreen.storyboard b/SwiftTA iOS/SwiftTA iOS/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 5fbc41a..0000000 --- a/SwiftTA iOS/SwiftTA iOS/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftTA iOS/SwiftTA iOS/Base.lproj/Main.storyboard b/SwiftTA iOS/SwiftTA iOS/Base.lproj/Main.storyboard deleted file mode 100644 index f6d7b20..0000000 --- a/SwiftTA iOS/SwiftTA iOS/Base.lproj/Main.storyboard +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftTA iOS/SwiftTA iOS/GameViewController.swift b/SwiftTA iOS/SwiftTA iOS/GameViewController.swift deleted file mode 100644 index 69bc6c4..0000000 --- a/SwiftTA iOS/SwiftTA iOS/GameViewController.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// ViewController.swift -// SwiftTA iOS -// -// Created by Logan Jones on 5/20/18. -// Copyright © 2018 Logan Jones. All rights reserved. -// - -import UIKit -import SwiftTA_Core -import SwiftTA_Metal - -class GameViewController: UIViewController { - - let game: GameManager - let renderer: GameRenderer & GameViewProvider - - private let scrollView: UIScrollView - private let dummy: UIView - - required init(_ state: GameState) { - let initialViewState = state.generateInitialViewState(viewportSize: Size2(640, 480)) - - self.renderer = MetalRenderer(loadedState: state, viewState: initialViewState)! - self.game = GameManager(state: state, renderer: renderer) - - let defaultFrameRect = CGRect(size: initialViewState.viewport.size) - scrollView = UIScrollView(frame: defaultFrameRect) - dummy = UIView(frame: defaultFrameRect) - - super.init(nibName: nil, bundle: nil) - game.start() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - let frameRect = scrollView.frame - view = UIView(frame: frameRect) - - let gameView = renderer.view - gameView.frame = frameRect - gameView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - let scale: CGFloat = 1 - scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - scrollView.contentOffset = CGPoint(renderer.viewState.viewport.origin) * scale - scrollView.contentSize = CGSize(game.loadedState.map.resolution) * scale - scrollView.minimumZoomScale = 0.5 - scrollView.maximumZoomScale = 2 - scrollView.zoomScale = scale - scrollView.delegate = self - - dummy.frame.size = CGSize(game.loadedState.map.resolution) * scale - scrollView.addSubview(dummy) - - view.addSubview(gameView) - view.addSubview(scrollView) - } - - fileprivate func updateRendererViewport() { - renderer.viewState.viewport = Rect4f(origin: Point2f(scrollView.contentOffset / scrollView.zoomScale), - size: Size2f(scrollView.bounds.size / scrollView.zoomScale)) - } - -} - -extension GameViewController: UIScrollViewDelegate { - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - updateRendererViewport() - } - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return dummy - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - updateRendererViewport() - } - -} diff --git a/SwiftTA iOS/SwiftTA iOS/Info.plist b/SwiftTA iOS/SwiftTA iOS/Info.plist deleted file mode 100644 index b5d1dd2..0000000 --- a/SwiftTA iOS/SwiftTA iOS/Info.plist +++ /dev/null @@ -1,47 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIFileSharingEnabled - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/SwiftTA macOS/SwiftTA macOS.xcodeproj/project.pbxproj b/SwiftTA macOS/SwiftTA macOS.xcodeproj/project.pbxproj deleted file mode 100644 index 2e39fa0..0000000 --- a/SwiftTA macOS/SwiftTA macOS.xcodeproj/project.pbxproj +++ /dev/null @@ -1,372 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 52; - objects = { - -/* Begin PBXBuildFile section */ - B5CAAF0820B26BB2003B17D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CAAF0720B26BB2003B17D7 /* AppDelegate.swift */; }; - B5CAAF0A20B26BB2003B17D7 /* GameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CAAF0920B26BB2003B17D7 /* GameViewController.swift */; }; - B5CAAF0C20B26BB3003B17D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5CAAF0B20B26BB3003B17D7 /* Assets.xcassets */; }; - B5CAAF0F20B26BB3003B17D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5CAAF0D20B26BB3003B17D7 /* Main.storyboard */; }; - B5E6FE8623CBF9540016A704 /* SwiftTA-Core in Frameworks */ = {isa = PBXBuildFile; productRef = B5E6FE8523CBF9540016A704 /* SwiftTA-Core */; }; - B5EDC8D124A9606100313D5F /* SwiftTA-Metal in Frameworks */ = {isa = PBXBuildFile; productRef = B5EDC8D024A9606100313D5F /* SwiftTA-Metal */; }; - B5EDC8D324A9656000313D5F /* SwiftTA-OpenGL3 in Frameworks */ = {isa = PBXBuildFile; productRef = B5EDC8D224A9656000313D5F /* SwiftTA-OpenGL3 */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - B5CAAF0420B26BB2003B17D7 /* SwiftTA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftTA.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B5CAAF0720B26BB2003B17D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B5CAAF0920B26BB2003B17D7 /* GameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewController.swift; sourceTree = ""; }; - B5CAAF0B20B26BB3003B17D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B5CAAF0E20B26BB3003B17D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - B5CAAF1020B26BB3003B17D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B5CAAF1120B26BB3003B17D7 /* SwiftTA_macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SwiftTA_macOS.entitlements; sourceTree = ""; }; - F0A9DE2520B49B50007E71C1 /* OpenGL.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OpenGL.framework; path = System/Library/Frameworks/OpenGL.framework; sourceTree = SDKROOT; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - B5CAAF0120B26BB2003B17D7 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - B5EDC8D324A9656000313D5F /* SwiftTA-OpenGL3 in Frameworks */, - B5EDC8D124A9606100313D5F /* SwiftTA-Metal in Frameworks */, - B5E6FE8623CBF9540016A704 /* SwiftTA-Core in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - B5CAAEFB20B26BB2003B17D7 = { - isa = PBXGroup; - children = ( - B5CAAF0620B26BB2003B17D7 /* SwiftTA macOS */, - B5CAAF0520B26BB2003B17D7 /* Products */, - F0A9DE2420B49B4F007E71C1 /* Frameworks */, - ); - sourceTree = ""; - }; - B5CAAF0520B26BB2003B17D7 /* Products */ = { - isa = PBXGroup; - children = ( - B5CAAF0420B26BB2003B17D7 /* SwiftTA.app */, - ); - name = Products; - sourceTree = ""; - }; - B5CAAF0620B26BB2003B17D7 /* SwiftTA macOS */ = { - isa = PBXGroup; - children = ( - B5CAAF0720B26BB2003B17D7 /* AppDelegate.swift */, - B5CAAF0920B26BB2003B17D7 /* GameViewController.swift */, - B5CAAF0B20B26BB3003B17D7 /* Assets.xcassets */, - B5CAAF0D20B26BB3003B17D7 /* Main.storyboard */, - B5CAAF1020B26BB3003B17D7 /* Info.plist */, - B5CAAF1120B26BB3003B17D7 /* SwiftTA_macOS.entitlements */, - ); - path = "SwiftTA macOS"; - sourceTree = ""; - }; - F0A9DE2420B49B4F007E71C1 /* Frameworks */ = { - isa = PBXGroup; - children = ( - F0A9DE2520B49B50007E71C1 /* OpenGL.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - B5CAAF0320B26BB2003B17D7 /* SwiftTA macOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = B5CAAF1420B26BB3003B17D7 /* Build configuration list for PBXNativeTarget "SwiftTA macOS" */; - buildPhases = ( - B5CAAF0020B26BB2003B17D7 /* Sources */, - B5CAAF0120B26BB2003B17D7 /* Frameworks */, - B5CAAF0220B26BB2003B17D7 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SwiftTA macOS"; - packageProductDependencies = ( - B5E6FE8523CBF9540016A704 /* SwiftTA-Core */, - B5EDC8D024A9606100313D5F /* SwiftTA-Metal */, - B5EDC8D224A9656000313D5F /* SwiftTA-OpenGL3 */, - ); - productName = "SwiftTA macOS"; - productReference = B5CAAF0420B26BB2003B17D7 /* SwiftTA.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - B5CAAEFC20B26BB2003B17D7 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0930; - LastUpgradeCheck = 1220; - ORGANIZATIONNAME = "Logan Jones"; - TargetAttributes = { - B5CAAF0320B26BB2003B17D7 = { - CreatedOnToolsVersion = 9.3.1; - LastSwiftMigration = 1020; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 0; - }; - }; - }; - }; - }; - buildConfigurationList = B5CAAEFF20B26BB2003B17D7 /* Build configuration list for PBXProject "SwiftTA macOS" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = B5CAAEFB20B26BB2003B17D7; - productRefGroup = B5CAAF0520B26BB2003B17D7 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - B5CAAF0320B26BB2003B17D7 /* SwiftTA macOS */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - B5CAAF0220B26BB2003B17D7 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B5CAAF0C20B26BB3003B17D7 /* Assets.xcassets in Resources */, - B5CAAF0F20B26BB3003B17D7 /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - B5CAAF0020B26BB2003B17D7 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B5CAAF0A20B26BB2003B17D7 /* GameViewController.swift in Sources */, - B5CAAF0820B26BB2003B17D7 /* AppDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - B5CAAF0D20B26BB3003B17D7 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - B5CAAF0E20B26BB3003B17D7 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - B5CAAF1220B26BB3003B17D7 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - B5CAAF1320B26BB3003B17D7 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - B5CAAF1520B26BB3003B17D7 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = "SwiftTA macOS/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "Toasty.SwiftTA-macOS"; - PRODUCT_NAME = SwiftTA; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - B5CAAF1620B26BB3003B17D7 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = "SwiftTA macOS/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "Toasty.SwiftTA-macOS"; - PRODUCT_NAME = SwiftTA; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - B5CAAEFF20B26BB2003B17D7 /* Build configuration list for PBXProject "SwiftTA macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B5CAAF1220B26BB3003B17D7 /* Debug */, - B5CAAF1320B26BB3003B17D7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B5CAAF1420B26BB3003B17D7 /* Build configuration list for PBXNativeTarget "SwiftTA macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B5CAAF1520B26BB3003B17D7 /* Debug */, - B5CAAF1620B26BB3003B17D7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCSwiftPackageProductDependency section */ - B5E6FE8523CBF9540016A704 /* SwiftTA-Core */ = { - isa = XCSwiftPackageProductDependency; - productName = "SwiftTA-Core"; - }; - B5EDC8D024A9606100313D5F /* SwiftTA-Metal */ = { - isa = XCSwiftPackageProductDependency; - productName = "SwiftTA-Metal"; - }; - B5EDC8D224A9656000313D5F /* SwiftTA-OpenGL3 */ = { - isa = XCSwiftPackageProductDependency; - productName = "SwiftTA-OpenGL3"; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = B5CAAEFC20B26BB2003B17D7 /* Project object */; -} diff --git a/SwiftTA macOS/SwiftTA macOS/AppDelegate.swift b/SwiftTA macOS/SwiftTA macOS/AppDelegate.swift deleted file mode 100644 index 896bfdd..0000000 --- a/SwiftTA macOS/SwiftTA macOS/AppDelegate.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// AppDelegate.swift -// SwiftTA macOS -// -// Created by Logan Jones on 5/20/18. -// Copyright © 2018 Logan Jones. All rights reserved. -// - -import Cocoa -import SwiftTA_Core - -@NSApplicationMain -class AppDelegate: NSObject, NSApplicationDelegate { - - func applicationDidFinishLaunching(_ aNotification: Notification) { - - } - - func applicationWillTerminate(_ aNotification: Notification) { - - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - -} - -class MainWindowController: NSWindowController { - - override func windowDidLoad() { - super.windowDidLoad() - startLoading() - } - - func startLoading() { - DispatchQueue(label: "Loading").async { - do { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - throw RuntimeError("No Documents directory?!") - } - - let state = try GameState(testLoadFromDocumentsDirectory: documents) - - DispatchQueue.main.async { - self.proceedWithLoaded(state) - } - } - catch { - print("Loading phase failed with error: \(error)") - } - } - } - - func proceedWithLoaded(_ state: GameState) { - let vc = GameViewController(state) - self.contentViewController = vc - } - -} - -class LoadingViewController: NSViewController { - - @IBOutlet weak var loadingIndicator: NSProgressIndicator! - - override func viewDidLoad() { - super.viewDidLoad() - loadingIndicator.usesThreadedAnimation = true - loadingIndicator.startAnimation(nil) - } - -} diff --git a/SwiftTA macOS/SwiftTA macOS/Assets.xcassets/Contents.json b/SwiftTA macOS/SwiftTA macOS/Assets.xcassets/Contents.json deleted file mode 100644 index da4a164..0000000 --- a/SwiftTA macOS/SwiftTA macOS/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/SwiftTA macOS/SwiftTA macOS/Base.lproj/Main.storyboard b/SwiftTA macOS/SwiftTA macOS/Base.lproj/Main.storyboard deleted file mode 100644 index 9c380ec..0000000 --- a/SwiftTA macOS/SwiftTA macOS/Base.lproj/Main.storyboard +++ /dev/null @@ -1,742 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Default - - - - - - - Left to Right - - - - - - - Right to Left - - - - - - - - - - - Default - - - - - - - Left to Right - - - - - - - Right to Left - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftTA macOS/SwiftTA macOS/GameViewController.swift b/SwiftTA macOS/SwiftTA macOS/GameViewController.swift deleted file mode 100644 index ea2cc46..0000000 --- a/SwiftTA macOS/SwiftTA macOS/GameViewController.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// ViewController.swift -// SwiftTA macOS -// -// Created by Logan Jones on 5/20/18. -// Copyright © 2018 Logan Jones. All rights reserved. -// - -import Cocoa -import SwiftTA_Core -import SwiftTA_Metal -import SwiftTA_OpenGL3 - -class GameViewController: NSViewController { - - let game: GameManager - let renderer: GameRenderer & GameViewProvider - - private let scrollView: NSScrollView - private let emptyView: NSView - - private let invisibleCursor = { () -> NSCursor in - let image = NSImage(size: NSSize(width: 16, height: 16), flipped: true) { rect in - //NSColor.red.drawSwatch(in: rect) - return true - } - return NSCursor(image: image, hotSpot: .zero) - }() - - required init(_ state: GameState) { - let initialViewState = state.generateInitialViewState(viewportSize: Size2(1024, 768)) - - self.renderer = MetalRenderer(loadedState: state, viewState: initialViewState)! - //self.renderer = OpenglCore3Renderer(loadedState: state, viewState: initialViewState)! - self.game = GameManager(state: state, renderer: renderer) - - let defaultFrameRect = CGRect(size: initialViewState.viewport.size) - scrollView = NSScrollView(frame: defaultFrameRect) - emptyView = Dummy(frame: defaultFrameRect) - - super.init(nibName: nil, bundle: nil) - game.start() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - NotificationCenter.default.removeObserver(self, name: NSView.boundsDidChangeNotification, object: scrollView.contentView) - NotificationCenter.default.removeObserver(self, name: NSView.boundsDidChangeNotification, object: view) - } - - override func loadView() { - let frameRect = scrollView.frame - let view = MouseTrackingView(frame: frameRect) - view.trackingDelegate = self - self.view = view - - let gameView = renderer.view - gameView.frame = frameRect - gameView.autoresizingMask = [.width, .height] - - scrollView.hasHorizontalScroller = true - scrollView.hasVerticalScroller = true - scrollView.allowsMagnification = true - //scrollView.wantsLayer = true - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.autoresizingMask = [.width, .height] - - emptyView.alphaValue = 0 - emptyView.frame = NSRect(size: game.loadedState.map.resolution) - - view.addSubview(gameView) - view.addSubview(scrollView) - scrollView.documentView = emptyView - scrollView.contentView.bounds = CGRect(renderer.viewState.viewport) - scrollView.contentView.postsBoundsChangedNotifications = true - NotificationCenter.default.addObserver(self, selector: #selector(contentBoundsDidChange), name: NSView.boundsDidChangeNotification, object: scrollView.contentView) - NotificationCenter.default.addObserver(self, selector: #selector(viewFrameDidChange), name: NSView.frameDidChangeNotification, object: view) - } - - private class Dummy: NSView { - override var isFlipped: Bool { - return true - } - } - - @objc func contentBoundsDidChange(_ notification: NSNotification) { - renderer.viewState.viewport = Rect4f(scrollView.contentView.bounds) - } - - @objc func viewFrameDidChange(_ notification: NSNotification) { - renderer.viewState.viewport = Rect4f(scrollView.contentView.bounds) - renderer.viewState.screenSize = Size2f(scrollView.bounds.size) - } - - override var acceptsFirstResponder: Bool { true } - - override func viewDidLoad() { - super.viewDidLoad() - - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] in - self?.handleKeyEvent($0, .down) - } - NSEvent.addLocalMonitorForEvents(matching: .keyUp) { [weak self] in - self?.handleKeyEvent($0, .up) - } - } - - private func handleKeyEvent(_ event: NSEvent, _ state: ButtonState) -> NSEvent? { - let input = KeyInput( - characters: event.characters ?? "", - state: state, - isRepeat: event.isARepeat - ) - game.enqueueInput(.key(input)) - return nil - } - -} - -extension GameViewController: MouseTrackingDelegate { - - override func cursorUpdate(with event: NSEvent) { - super.cursorUpdate(with: event) - invisibleCursor.set() - //print("[TEST] cursorUpdate") - } - - override func mouseEntered(with event: NSEvent) { - super.mouseEntered(with: event) - //print("[TEST] mouseEntered: \(event.locationInWindow)") - } - - override func mouseExited(with event: NSEvent) { - super.mouseExited(with: event) - //print("[TEST] mouseExited: \(event.locationInWindow)") - } - - override func mouseMoved(with event: NSEvent) { - super.mouseMoved(with: event) - renderer.viewState.cursorLocation = event.location(in: view) - //print("[TEST] mouseMoved: \(event.locationInWindow)") - } - override func mouseDragged(with event: NSEvent) { - renderer.viewState.cursorLocation = event.location(in: view) - } - - override func mouseDown(with event: NSEvent) { - enqueueMouseInput(with: event) - } - override func mouseUp(with event: NSEvent) { - enqueueMouseInput(with: event) - } - override func rightMouseDown(with event: NSEvent) { - enqueueMouseInput(with: event) - } - override func rightMouseUp(with event: NSEvent) { - enqueueMouseInput(with: event) - } - override func otherMouseDown(with event: NSEvent) { - enqueueMouseInput(with: event) - } - override func otherMouseUp(with event: NSEvent) { - enqueueMouseInput(with: event) - } - - private func enqueueMouseInput(with event: NSEvent) { - game.enqueueInput(.click(MouseInput( - button: event.buttonNumber, - state: event.type.buttonState, - cursorLocation: event.location(in: view) - ))) - } - -} - -private protocol MouseTrackingDelegate: AnyObject { - func cursorUpdate(with event: NSEvent) - func mouseEntered(with event: NSEvent) - func mouseExited(with event: NSEvent) - func mouseMoved(with event: NSEvent) -} - -private class MouseTrackingView: NSView { - - weak var trackingDelegate: MouseTrackingDelegate? - private weak var trackingArea: NSTrackingArea? - - override func updateTrackingAreas() { - super.updateTrackingAreas() - - if let existing = trackingArea { - removeTrackingArea(existing) - } - - let new = NSTrackingArea( - rect: bounds, - options: [.activeAlways, .cursorUpdate, .mouseEnteredAndExited, .mouseMoved, .enabledDuringMouseDrag], - owner: trackingDelegate, - userInfo: nil) - addTrackingArea(new) - trackingArea = new - } - -} - -private extension NSEvent { - func location(in view: NSView) -> Point2f { - var location = self.locationInWindow - location.y = view.bounds.size.height - location.y - return Point2f(location) - } -} - -private extension NSEvent.EventType { - var buttonState: ButtonState { - switch self { - case .leftMouseUp, .rightMouseUp, .otherMouseUp, .keyUp: - return .up - case .leftMouseDown, .rightMouseDown, .otherMouseDown, .keyDown: - return .down - default: - return .down - } - } -} diff --git a/SwiftTA macOS/SwiftTA macOS/SwiftTA_macOS.entitlements b/SwiftTA macOS/SwiftTA macOS/SwiftTA_macOS.entitlements deleted file mode 100644 index 0c67376..0000000 --- a/SwiftTA macOS/SwiftTA macOS/SwiftTA_macOS.entitlements +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift b/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift index 8b4fd9f..4553492 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift @@ -14,20 +14,33 @@ public class FileSystem { public static let weightedArchiveExtensions = ["ufo", "gp3", "ccx", "gpf", "hpi"] - public init(mergingHpisIn searchDirectory: URL, extensions: [String] = FileSystem.weightedArchiveExtensions) throws { + public init(mergingHpisIn searchDirectory: URL, + modDirectory: URL? = nil, + extensions: [String] = FileSystem.weightedArchiveExtensions) throws { let weighArchives: (URL, URL) -> Bool = { (a,b) in let weightA = extensions.firstIndex(of: a.pathExtension) ?? -1 let weightB = extensions.firstIndex(of: b.pathExtension) ?? -1 return weightA < weightB } - - let merged = try FileSystem.listArchives(in: searchDirectory, allowedExtensions: Set(extensions)) + + let baseArchives = try FileSystem.listArchives(in: searchDirectory, allowedExtensions: Set(extensions)) .sorted { weighArchives($0, $1) } + + let base = try baseArchives .map { FileSystem.Directory(from: try HpiItem.loadFromArchive(contentsOf: $0), in: $0) } .reduce(FileSystem.Directory()) { $0.adding(directory: $1) } - - root = merged + + if let modDirectory = modDirectory { + let modArchives = try FileSystem.listArchives(in: modDirectory, allowedExtensions: Set(extensions)) + .sorted { weighArchives($0, $1) } + let withMods = try modArchives + .map { FileSystem.Directory(from: try HpiItem.loadFromArchive(contentsOf: $0), in: $0) } + .reduce(base) { $0.adding(directory: $1, overwrite: true) } + root = withMods + } else { + root = base + } } #if !os(Linux) @@ -307,7 +320,21 @@ public extension FileSystem.Directory { .compactMap { $0.asFile() } .filter { $0.hasExtension(ext) } } - + + /// Recursively collects every file matching `ext` in this directory and all descendants. + func allFiles(withExtension ext: String) -> [FileSystem.File] { + var result: [FileSystem.File] = [] + for item in items { + switch item { + case .file(let f): + if f.hasExtension(ext) { result.append(f) } + case .directory(let d): + result.append(contentsOf: d.allFiles(withExtension: ext)) + } + } + return result + } + } // MARK:- FileHandle diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/GameManager.swift b/SwiftTA-Core/Sources/SwiftTA-Core/GameManager.swift index ada5026..416f044 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/GameManager.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/GameManager.swift @@ -137,7 +137,7 @@ public class GameManager: ScriptMachine { //guard let type = loadedState.units[unit.type] else { continue } var updated = unit - updated.scriptContext.run(for: updated.modelInstance, on: self) + updated.scriptContext.run(for: &updated.modelInstance, on: self) updated.scriptContext.applyAnimations(to: &updated.modelInstance, for: updateRate) updated.applyMovement(loadedState.map) objects[id] = .unit(updated) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/MapModel+Writer.swift b/SwiftTA-Core/Sources/SwiftTA-Core/MapModel+Writer.swift new file mode 100644 index 0000000..305cf8c --- /dev/null +++ b/SwiftTA-Core/Sources/SwiftTA-Core/MapModel+Writer.swift @@ -0,0 +1,201 @@ +// +// MapModel+Writer.swift +// SwiftTA-Core +// +// Serializers for the Cavedog TNT binary map format plus the companion +// minimap block. Reader side lives in MapModel.swift. +// +// The writer targets semantic (not byte-for-byte) round-trip fidelity: +// a map that is read and immediately written back through this serializer +// produces a file that, when re-read, yields a TaMapModel equal to the +// original. Section *contents* match the original byte-for-byte; the +// file layout is canonical (tile-index → map-info → tiles → features → +// minimap, with no inter-section padding), so files whose original +// layout differed will also differ in the header offsets, which is +// allowed by the format — Cavedog's engine, Spring, and our own reader +// all index by the header offsets, never by position. +// + +import Foundation +import SwiftTA_Ctypes + + +public extension TaMapModel { + + enum WriteError: Error { + case featureNameTooLong(String) + case invalidMapSize(Size2) + case tileDataSizeMismatch(expected: Int, actual: Int) + case tileIndexSizeMismatch(expected: Int, actual: Int) + case heightSampleCountMismatch(expected: Int, actual: Int) + case featureMapSizeMismatch(expected: Int, actual: Int) + case invalidFeatureIndex(Int) + } + + /// Binary sizes of the fixed prefix, in bytes. These are not + /// `MemoryLayout.size` of the C structs because the C structs are + /// `#pragma pack(1)` — different compilers have been known to disagree + /// with Swift's assumptions. The on-wire format is fixed. + static let tntHeaderSize = 12 // int32 version + uint32 width + uint32 height + static let tntExtHeaderSize = 52 // 9 × uint32 + 16 bytes padding + static let tntPrefixSize = tntHeaderSize + tntExtHeaderSize // 64 + + /// Byte size of a single TA_TNT_MAP_ENTRY: uint8 elevation + uint16 special + uint8 unknown. + static let tntMapEntrySize = 4 + + /// Byte size of a single TA_TNT_FEATURE_ENTRY: uint32 index + uint8 name[128]. + static let tntFeatureEntrySize = 132 + + /// Serialize this map to a TNT v1 (Total Annihilation) binary blob. + /// The output is suitable to hand back to the Cavedog engine or + /// round-trip through `TaMapModel.init(_:reading:)`. + func writeTnt() throws -> Data { + try validateBeforeWriting() + + let tileIndexOffset = Self.tntPrefixSize + let tileIndexBytes = tileIndexMap.indices + let mapInfoOffset = tileIndexOffset + tileIndexBytes.count + + let mapInfoBytes = encodeMapInfo() + let tileArrayOffset = mapInfoOffset + mapInfoBytes.count + + let tileArrayBytes = tileSet.tiles + let featureOffset = tileArrayOffset + tileArrayBytes.count + + let featureBytes = try encodeFeatureEntries() + let minimapOffset = featureOffset + featureBytes.count + + let minimapBytes = encodeMinimap() + + var output = Data(capacity: minimapOffset + minimapBytes.count) + + // Main header. + output.appendUInt32LE(UInt32(bitPattern: Int32(TA_TNT_TOTAL_ANNIHILATION))) + output.appendUInt32LE(UInt32(mapSize.width)) + output.appendUInt32LE(UInt32(mapSize.height)) + + // Extended header. + output.appendUInt32LE(UInt32(tileIndexOffset)) + output.appendUInt32LE(UInt32(mapInfoOffset)) + output.appendUInt32LE(UInt32(tileArrayOffset)) + output.appendUInt32LE(UInt32(tileSet.count)) + output.appendUInt32LE(UInt32(features.count)) + output.appendUInt32LE(UInt32(featureOffset)) + output.appendUInt32LE(UInt32(seaLevel)) + output.appendUInt32LE(UInt32(minimapOffset)) + output.appendUInt32LE(1) // unknown_1 — Cavedog always writes 1 + output.append(Data(repeating: 0, count: 16)) // padding + + // Sections in canonical order. + output.append(tileIndexBytes) + output.append(mapInfoBytes) + output.append(tileArrayBytes) + output.append(featureBytes) + output.append(minimapBytes) + + return output + } + + private func validateBeforeWriting() throws { + guard mapSize.width > 0, mapSize.height > 0 else { + throw WriteError.invalidMapSize(mapSize) + } + + let expectedHeightSamples = mapSize.area + guard heightMap.samples.count == expectedHeightSamples else { + throw WriteError.heightSampleCountMismatch(expected: expectedHeightSamples, actual: heightMap.samples.count) + } + + guard featureMap.count == expectedHeightSamples else { + throw WriteError.featureMapSizeMismatch(expected: expectedHeightSamples, actual: featureMap.count) + } + + let expectedTileIndexBytes = (mapSize.width / 2) * (mapSize.height / 2) * MemoryLayout.size + guard tileIndexMap.indices.count == expectedTileIndexBytes else { + throw WriteError.tileIndexSizeMismatch(expected: expectedTileIndexBytes, actual: tileIndexMap.indices.count) + } + + let expectedTileBytes = tileSet.count * tileSet.tileSize.area + guard tileSet.tiles.count == expectedTileBytes else { + throw WriteError.tileDataSizeMismatch(expected: expectedTileBytes, actual: tileSet.tiles.count) + } + + let featureRange = 0.. Data { + var data = Data(capacity: mapSize.area * Self.tntMapEntrySize) + for i in 0.. Data { + var data = Data(capacity: features.count * Self.tntFeatureEntrySize) + for (index, featureId) in features.enumerated() { + data.appendUInt32LE(UInt32(index)) + + let nameBytes = Array(featureId.name.utf8) + guard nameBytes.count < 128 else { + // Reserve byte 128 for the null terminator. + throw WriteError.featureNameTooLong(featureId.name) + } + data.append(contentsOf: nameBytes) + data.append(contentsOf: [UInt8](repeating: 0, count: 128 - nameBytes.count)) + } + return data + } + + private func encodeMinimap() -> Data { + var data = Data(capacity: 8 + minimap.data.count) + data.appendUInt32LE(UInt32(minimap.size.width)) + data.appendUInt32LE(UInt32(minimap.size.height)) + data.append(minimap.data) + return data + } +} + + +// MARK: - Little-endian write helpers + +private extension Data { + mutating func appendUInt32LE(_ value: UInt32) { + var le = value.littleEndian + Swift.withUnsafeBytes(of: &le) { buf in self.append(contentsOf: buf) } + } + + mutating func appendUInt16LE(_ value: UInt16) { + var le = value.littleEndian + Swift.withUnsafeBytes(of: &le) { buf in self.append(contentsOf: buf) } + } +} + + +// MARK: - Helpers + +private extension TaMapModel.WriteError { + func withIndex(_ i: Int) -> TaMapModel.WriteError { + // The invalid-feature-index path needs to surface which map entry + // went wrong; the enum value already carries the feature index, so + // this shim exists solely to make the caller's intent explicit when + // tracing which cell caused the problem. Currently a no-op that + // returns self, reserved for future richer diagnostics. + _ = i + return self + } +} diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift b/SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift index 66627f3..f1a040f 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift @@ -23,7 +23,7 @@ public struct Palette { self.colors = colors } - public init() { colors = Array(repeating: Color.white, count: 255) } + public init() { colors = Array(repeating: Color.white, count: 256) } public subscript(index: Int) -> Color { return colors[index] diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser+Writer.swift b/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser+Writer.swift new file mode 100644 index 0000000..68fb4ff --- /dev/null +++ b/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser+Writer.swift @@ -0,0 +1,90 @@ +// +// TdfParser+Writer.swift +// SwiftTA-Core +// +// Serializer for the Cavedog TDF container format used by .ota, +// .tdf, .fbi, and related configuration files. Reader is in +// TdfParser.swift. +// +// The writer targets semantic — not byte-for-byte — round-trip +// fidelity. TdfParser.Object stores properties and subobjects in +// Swift Dictionaries, which are unordered. Serialization emits keys +// in sorted order at each level for determinism, so the output will +// generally not match an original author-formatted .ota byte-for-byte +// but will parse back to the same object graph, which is what the +// Cavedog engine and our own reader care about. +// +// The map editor's strategy for OTA editing is: parse an OTA file +// into a full TdfParser.Object tree (lossless, preserves every +// field), mutate the specific fields the user is editing, then +// serialize the whole tree back. That preserves mod-author or +// mission-scripting fields we don't have structs for. +// + +import Foundation + + +public extension TdfParser.Object { + + /// Serialize this object as a TDF-formatted named block: + /// + /// [Name] + /// { + /// key=value; + /// [SubBlock] + /// { … } + /// } + /// + /// Properties are emitted before subobjects at each nesting + /// level, and keys within a level are sorted alphabetically. + /// + /// - Parameters: + /// - name: the block name to open with. When nil, only the + /// contents are emitted (no surrounding `[Name]{…}`) — useful + /// when wrapping the whole tree yourself. + /// - indent: tab-depth of the outer block. Subobjects indent one + /// level deeper. + func serializeAsTdf(name: String? = nil, indent: Int = 0) -> String { + var out = "" + let outerPad = String(repeating: "\t", count: indent) + let innerIndent = (name != nil) ? indent + 1 : indent + let innerPad = String(repeating: "\t", count: innerIndent) + + if let name = name { + out.append("\(outerPad)[\(name)]\n") + out.append("\(outerPad){\n") + } + + for key in properties.keys.sorted() { + let value = properties[key] ?? "" + out.append("\(innerPad)\(key)=\(value);\n") + } + + for subKey in subobjects.keys.sorted() { + guard let subObject = subobjects[subKey] else { continue } + out.append(subObject.serializeAsTdf(name: subKey, indent: innerIndent)) + } + + if name != nil { + out.append("\(outerPad)}\n") + } + + return out + } +} + + +public extension Dictionary where Key == String, Value == TdfParser.Object { + + /// Serialize a top-level TDF document: every entry is written as a + /// named block in sorted order. Matches the shape returned by + /// `TdfParser.extractAll()`. + func serializeAsTdf() -> String { + var out = "" + for name in keys.sorted() { + guard let object = self[name] else { continue } + out.append(object.serializeAsTdf(name: name, indent: 0)) + } + return out + } +} diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser.swift b/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser.swift index b7bc246..a3d3681 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/TdfParser.swift @@ -47,9 +47,9 @@ public extension TdfParser { let count = data.count var token: Token? - data.withUnsafeBytes() { + data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in while scanPosition < count && token == nil { - (state, token) = TdfParser.transition(state, consuming: $0[scanPosition], context: &context) + (state, token) = TdfParser.transition(state, consuming: bytes[scanPosition], context: &context) scanPosition += 1 } } @@ -127,9 +127,9 @@ public extension TdfParser { let count = data.count var token: Token? - data.withUnsafeBytes() { + data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in while scanPosition < count { - (state, token) = TdfParser.transition(state, consuming: $0[scanPosition], context: &context) + (state, token) = TdfParser.transition(state, consuming: bytes[scanPosition], context: &context) scanPosition += 1 switch token { case let .property(key, value)? where depth == startDepth: @@ -151,12 +151,12 @@ public extension TdfParser { static func parse(_ data: Data, tokenHandler: (Token) -> () ) { let count = data.count - data.withUnsafeBytes() { + data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in var state = State.seekingSection var context = Context() var token: Token? for i in 0.. [UnitInfo] { - + guard let unitsDirectory = filesystem.root[directory: "units"] else { return [] } - - let units = unitsDirectory.files(withExtension: "fbi") + + let units = unitsDirectory.allFiles(withExtension: "fbi") .compactMap { try? filesystem.openFile($0) } .compactMap { try? UnitInfo(contentsOf: $0) } - + return units } - + static func collectUnits(from filesystem: FileSystem, onlyAllowing allowedUnits: [String]) -> [UnitInfo] { - + guard let unitsDirectory = filesystem.root[directory: "units"] else { return [] } - + let allowed = Set(allowedUnits.map { $0.lowercased() }) - - let units = unitsDirectory.files(withExtension: "fbi") + + let units = unitsDirectory.allFiles(withExtension: "fbi") .filter { allowed.contains($0.baseName.lowercased()) } .compactMap { try? filesystem.openFile($0) } .compactMap { try? UnitInfo(contentsOf: $0) } - + return units } diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift new file mode 100644 index 0000000..5777035 --- /dev/null +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift @@ -0,0 +1,102 @@ +// +// UnitModel+Bounds.swift +// SwiftTA-Core +// + +import Foundation +import simd + +public extension UnitModel { + + /// World-space position of a piece with the current animation state applied. + /// Walks the piece's ancestor chain from the root down, multiplying each + /// piece's local translation/rotation matrix so Create-time IK queries like + /// `get PIECE_XZ(tip)` see the result of intermediate `turn ... now` calls. + func pieceWorldPosition(_ index: Pieces.Index, instance: UnitModel.Instance) -> Vertex3f { + let t = pieceWorldTransform(index, instance: instance) + return Vertex3f(x: t.columns.3.x, y: t.columns.3.y, z: t.columns.3.z) + } + + func pieceWorldTransform(_ index: Pieces.Index, instance: UnitModel.Instance) -> matrix_float4x4 { + var transform = matrix_float4x4.identity + if index < parents.count { + for ancestor in parents[index] { + transform = transform * pieceLocalTransform(ancestor, instance: instance) + } + } + transform = transform * pieceLocalTransform(index, instance: instance) + return transform + } + + private func pieceLocalTransform(_ index: Pieces.Index, instance: UnitModel.Instance) -> matrix_float4x4 { + let piece = pieces[index] + let anim = index < instance.pieces.count ? instance.pieces[index] : PieceState() + let offset = piece.offset + let move = anim.move + let turn = anim.turn + let rad = GameFloat.pi / 180 + let sx = Darwin.sin(turn.x * rad), cx = Darwin.cos(turn.x * rad) + let sy = Darwin.sin(turn.y * rad), cy = Darwin.cos(turn.y * rad) + let sz = Darwin.sin(turn.z * rad), cz = Darwin.cos(turn.z * rad) + // R = R_SIMDz(turn.y) · R_SIMDy(turn.z) · R_SIMDx(turn.x). Yaw is the + // outermost rotation; a child's turn.x (pitch) therefore rotates around + // an axis that stays in the horizontal plane regardless of the parent's + // own pitch. TA walker IK (CORMKL's Create/PositionLegs) assumes this — + // with pitch outermost the bisection's knee axis rotated into a near- + // vertical line whenever the shoulder had any pitch, making the + // distance-vs-angle function unimodal instead of monotonic and freezing + // the bisection at its degenerate minimum. + return matrix_float4x4(columns: ( + vector_float4(cy * cz, + sy * cz, + -sz, + 0), + vector_float4((cy * sz * sx) - (sy * cx), + (sy * sz * sx) + (cy * cx), + cz * sx, + 0), + vector_float4((cy * sz * cx) + (sy * sx), + (sy * sz * cx) - (cy * sx), + cz * cx, + 0), + vector_float4(offset.x - move.x, + offset.y - move.z, + offset.z + move.y, + 1) + )) + } +} + +public extension UnitModel { + + /// The farthest distance from the model origin to any vertex, measured after + /// each piece's local offset is added to the accumulated parent offset. + var maxWorldExtent: GameFloat { + var extent: GameFloat = 0 + let rootsToVisit: [Pieces.Index] = roots.isEmpty ? [root] : roots + for rootIndex in rootsToVisit { + accumulate(pieceIndex: rootIndex, parentOffset: .zero, into: &extent) + } + return extent + } + + private func accumulate(pieceIndex: Pieces.Index, + parentOffset: Vertex3f, + into extent: inout GameFloat) { + let piece = pieces[pieceIndex] + let offset = piece.offset + parentOffset + + for primitiveIndex in piece.primitives { + guard primitiveIndex != groundPlate else { continue } + for vertexIndex in primitives[primitiveIndex].indices { + let v = vertices[vertexIndex] + offset + let local = max(abs(v.x), abs(v.y), abs(v.z)) + if local > extent { extent = local } + } + } + + for child in piece.children { + accumulate(pieceIndex: child, parentOffset: offset, into: &extent) + } + } +} diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift index 54899d5..a0c521f 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift @@ -17,6 +17,8 @@ public struct UnitModel { public typealias Textures = Array public var pieces: Pieces + public var roots: [Pieces.Index] = [] + public var parents: [[Pieces.Index]] = [] public var primitives: Primitives public var vertices: Vertices public var textures: Textures @@ -40,13 +42,43 @@ public struct UnitModel { textures = model.textures root = model.roots.first! + roots = model.roots groundPlate = model.groundPlate - + var names: [String: Pieces.Index] = [:] for (index, piece) in pieces.enumerated() { names[piece.name.lowercased()] = index } nameLookup = names + + var parents = Array<[Pieces.Index]>(repeating: [], count: pieces.count) + for rootIndex in roots { + UnitModel.populateParents(pieceIndex: rootIndex, ancestors: [], pieces: pieces, parents: &parents) + } + self.parents = parents + } + + private static func populateParents(pieceIndex: Pieces.Index, + ancestors: [Pieces.Index], + pieces: Pieces, + parents: inout [[Pieces.Index]]) { + parents[pieceIndex] = ancestors + let next = ancestors + [pieceIndex] + for child in pieces[pieceIndex].children { + populateParents(pieceIndex: child, ancestors: next, pieces: pieces, parents: &parents) + } + } + + /// World-space position of a piece assuming no animated translation — just the + /// sum of its ancestors' static piece offsets. Adequate for the IK queries that + /// Create/RestoreAfterDelay scripts use to position legs on spider bots. + public func pieceStaticOffset(_ index: Pieces.Index) -> Vertex3f { + var sum = Vertex3f.zero + for ancestor in parents[index] { + sum += pieces[ancestor].offset + } + sum += pieces[index].offset + return sum } public func piece(named name: String) -> Piece? { diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+CobDecompile.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+CobDecompile.swift index cd3c836..e7c9e23 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+CobDecompile.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+CobDecompile.swift @@ -730,6 +730,13 @@ extension UnitScript.UnitValue { case .yardOpen: return "YARD_OPEN" case .buggerOff: return "BUGGER_OFF" case .armored: return "ARMORED" + // TADR / TA Recorder extension identifiers. + case .minUnitID: return "MIN_ID" + case .maxUnitID: return "MAX_ID" + case .myUnitID: return "MY_ID" + case .unitBuildPercentLeft: return "UNIT_BUILD_PERCENT_LEFT" + case .unitAllied: return "UNIT_ALLIED" + case .unitIsOnThisComp: return "UNIT_IS_ON_THIS_COMP" } } } diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 326490a..935dc45 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -12,7 +12,10 @@ import Foundation struct ScriptExecutionContext { var process: UnitScript.Context var thread: UnitScript.Thread - var model: UnitModel.Instance + /// The instance is shared mutably across all threads within a single run() + /// tick so that `turn piece now` / `move piece now` take effect immediately + /// and later reads in the same script evaluation see the updated state. + var model: UnsafeMutablePointer var machine: ScriptMachine } @@ -63,7 +66,7 @@ let Instructions: [UnitScript.Opcode: Instruction] = [ .add: operatorFunc(operation: &+), .subtract: operatorFunc(operation: &-), .multiply: operatorFunc(operation: &*), - .divide: operatorFunc(operation: /), + .divide: operatorFunc(operation: { lhs, rhs in rhs == 0 ? 0 : lhs / rhs }), .bitwiseAnd: operatorFunc(operation: &), .bitwiseOr: operatorFunc(operation: |), .unknown1: unknownOperator, @@ -115,7 +118,7 @@ private func movePieceWithSpeed(execution: ScriptExecutionContext) throws { let destination = try execution.thread.stack.pop() let speed = try execution.thread.stack.pop() - let translation = execution.model.beginTranslation( + let translation = execution.model.pointee.beginTranslation( for: try execution.process.pieceIndex(at: piece), along: try execution.thread.makeAxis(for: axis), to: destination.linearValue, @@ -145,7 +148,7 @@ private func turnPieceWithSpeed(execution: ScriptExecutionContext) throws { let destination = try execution.thread.stack.pop() let speed = try execution.thread.stack.pop() - let rotation = execution.model.beginRotation( + let rotation = execution.model.pointee.beginRotation( for: try execution.process.pieceIndex(at: piece), around: try execution.thread.makeAxis(for: axis), to: destination.angularValue, @@ -175,7 +178,7 @@ private func startSpin(execution: ScriptExecutionContext) throws { let speed = try execution.thread.stack.pop() let acceleration = try execution.thread.stack.pop() - let spin = execution.model.beginSpin( + let spin = execution.model.pointee.beginSpin( for: try execution.process.pieceIndex(at: piece), around: try execution.thread.makeAxis(for: axis), accelerating: acceleration.angularValue, @@ -295,17 +298,20 @@ private func dontShadow(execution: ScriptExecutionContext) throws { */ private func movePieceNow(execution: ScriptExecutionContext) throws { - + let piece = execution.immediate(at: 1) let axis = execution.immediate(at: 2) let destination = try execution.thread.stack.pop() - - execution.process.animations.append(.setPosition(UnitScript.SetPosition( - piece: try execution.process.pieceIndex(at: piece), - axis: try execution.thread.makeAxis(for: axis), - target: destination.linearValue - ))) - + + let pieceIndex = try execution.process.pieceIndex(at: piece) + let resolvedAxis = try execution.thread.makeAxis(for: axis) + let target = destination.linearValue + switch resolvedAxis { + case .x: execution.model.pointee.pieces[pieceIndex].move.x = target + case .y: execution.model.pointee.pieces[pieceIndex].move.y = target + case .z: execution.model.pointee.pieces[pieceIndex].move.z = target + } + //print("[\(execution.thread.id)] Move \(piece) along \(axis) to \(destination)") execution.thread.instructionPointer += 3 } @@ -322,17 +328,20 @@ private func movePieceNow(execution: ScriptExecutionContext) throws { */ private func turnPieceNow(execution: ScriptExecutionContext) throws { - + let piece = execution.immediate(at: 1) let axis = execution.immediate(at: 2) let destination = try execution.thread.stack.pop() - - execution.process.animations.append(.setAngle(UnitScript.SetAngle( - piece: try execution.process.pieceIndex(at: piece), - axis: try execution.thread.makeAxis(for: axis), - target: destination.angularValue - ))) - + + let pieceIndex = try execution.process.pieceIndex(at: piece) + let resolvedAxis = try execution.thread.makeAxis(for: axis) + let target = destination.angularValue + switch resolvedAxis { + case .x: execution.model.pointee.pieces[pieceIndex].turn.x = target + case .y: execution.model.pointee.pieces[pieceIndex].turn.y = target + case .z: execution.model.pointee.pieces[pieceIndex].turn.z = target + } + //print("[\(execution.thread.id)] Turn \(piece) around \(axis) to \(destination)") execution.thread.instructionPointer += 3 } @@ -374,10 +383,15 @@ private func waitForTurn(execution: ScriptExecutionContext) throws { let piece = execution.immediate(at: 1) let axis = execution.immediate(at: 2) - execution.thread.status = .waitingForTurn(Int(piece), try execution.thread.makeAxis(for: axis)) - - print("[\(execution.thread.id)] wait for turn: \(piece) around \(axis)") + let modelPiece = try execution.process.pieceIndex(at: piece) + let resolvedAxis = try execution.thread.makeAxis(for: axis) execution.thread.instructionPointer += 3 + // If no matching animation is pending, the wait is already satisfied — leave + // the thread running rather than parking it for the applyAnimations pass. + let matches = execution.process.animations.contains(where: { UnitScript.Context.animationMatchesTurn($0, piece: modelPiece, axis: resolvedAxis) }) + if matches { + execution.thread.status = .waitingForTurn(modelPiece, resolvedAxis) + } } /** @@ -389,14 +403,17 @@ private func waitForTurn(execution: ScriptExecutionContext) throws { */ private func waitForMove(execution: ScriptExecutionContext) throws { - + let piece = execution.immediate(at: 1) let axis = execution.immediate(at: 2) - - execution.thread.status = .waitingForMove(Int(piece), try execution.thread.makeAxis(for: axis)) - - print("[\(execution.thread.id)] wait for move: \(piece) along \(axis)") + + let modelPiece = try execution.process.pieceIndex(at: piece) + let resolvedAxis = try execution.thread.makeAxis(for: axis) execution.thread.instructionPointer += 3 + let matches = execution.process.animations.contains(where: { UnitScript.Context.animationMatchesTranslation($0, piece: modelPiece, axis: resolvedAxis) }) + if matches { + execution.thread.status = .waitingForMove(modelPiece, resolvedAxis) + } } /** @@ -657,22 +674,36 @@ private func taRandom(min: _StackValue, max: _StackValue) -> _StackValue { */ private func getUnitValue(execution: ScriptExecutionContext) throws { - + let what = try execution.thread.stack.pop() + var result: UnitScript.CodeUnit = 0 if let uv = UnitScript.UnitValue(rawValue: what) { - // print("[\(execution.thread.id)] Get \(uv)") switch uv { - default: () // TODO: Do something with requested UnitValue here. + case .activation, .standingFireOrders, .armored: + result = 1 + case .health: + result = 100 + case .inBuildStance, .busy, .yardOpen, .buggerOff: + result = 0 + case .unitXZ, .unitY, .unitHeight, .groundHeight: + result = 0 + // TADR target-scan extension stubs. CORMKL (and most TAESC mod + // units) iterate `for id := MIN_ID to MAX_ID do` inside Detect(). + // Returning MIN_ID = 1, MAX_ID = 0 makes that loop run zero times + // so the scan exits cleanly instead of treating id=0 as a + // hostile neighbour. MY_ID has no viewer meaning; 0 is fine. + case .minUnitID: + result = 1 + case .maxUnitID: + result = 0 + case .myUnitID: + result = 0 + default: + () } } - else { - // TODO: Do something with out-of-bounds UnitValue here. - print("[\(execution.thread.id)] Get Unit-Value[\(what)?]") - } - - // TODO: Implement getFunctionResult - execution.thread.stack.push(0) - + execution.thread.stack.push(result) + execution.thread.instructionPointer += 1 } @@ -691,17 +722,139 @@ private func getUnitValue(execution: ScriptExecutionContext) throws { */ private func getFunctionResult(execution: ScriptExecutionContext) throws { - - let params: [_StackValue] = try execution.thread.stack.pop(count: 4).reversed() + + // pop(count: 4) returns items in push order (oldest first), so params[0] is + // the first argument the script wrote — matching the TA convention where + // get(PIECE_XZ, piece, 0, 0, 0) puts the piece index in the first slot. + let params: [_StackValue] = try execution.thread.stack.pop(count: 4) let what = try execution.thread.stack.pop() - - // TODO: Implement getFunctionResult - execution.thread.stack.push(0) - - print("[\(execution.thread.id)] Get Function[\(what)]\(params) Result ") + + var result: _StackValue = 0 + if let uv = UnitScript.UnitValue(rawValue: what) { + switch uv { + case .pieceXZ: + if let pos = pieceWorldPosition(scriptPiece: params[0], execution: execution) { + // UnitModel remaps 3DO (x, y, z) → SIMD (x, z, y) so pos.y is TA's Z + // (horizontal depth) and pos.z is TA's Y (vertical height). TA's + // PIECE_XZ returns world coordinates truncated to integers in the + // native TA unit system; scripts compare the hypot against + // literal constants sized in those same integer units, so any + // scaling here would break every IK bisection that uses fixed + // thresholds. + result = packXZ(x: pos.x, z: pos.y) + } + case .pieceY: + if let pos = pieceWorldPosition(scriptPiece: params[0], execution: execution) { + result = _StackValue(truncatingIfNeeded: Int(pos.z.rounded())) + } + case .xzAtan: + let (x, z) = unpackXZ(params[0]) + result = taAtan2(z: z, x: x) + case .xzHypot: + let (x, z) = unpackXZ(params[0]) + result = taHypot(x: x, z: z) + case .atan: + result = taAtan2(z: GameFloat(params[1]), x: GameFloat(params[0])) + case .hypot: + result = taHypot(x: GameFloat(params[0]), z: GameFloat(params[1])) + case .groundHeight: + // Walker scripts (CORMKL's PositionLegs, etc.) use `move Point to y-axis + // [GROUND_HEIGHT(PIECE_XZ(Point)) - PIECE_Y(Point)] speed [...]` to drive + // foot-target pieces to terrain level every tick. In the unit viewer we + // have no terrain, so returning 0 (the Y=0 plane) forces Point.move.y to + // track -PIECE_Y(Point) every tick — which just oscillates the piece up + // and down and destabilises the whole IK loop. + // + // Returning the queried point's own current Y makes the formula + // collapse to `target = Y - Y = 0`, so the reference piece holds its + // baseline position and the leg-IK sees a stationary target. When we + // wire this up against a real map later this becomes the actual + // terrain sample; the viewer just needs a stable value. + if let pos = pieceWorldPositionFromPackedXZ(params[0], execution: execution) { + result = _StackValue(truncatingIfNeeded: Int(pos.z.rounded())) + } + case .unitXZ, .unitY, .unitHeight: + result = 0 + // TADR extensions. We only hit these when mod scripts scan other + // units; given we report an empty unit range from getUnitValue, + // in-range neighbours shouldn't actually be passed as params + // here. The stubs still cover the case where a script uses MY_ID + // (i.e. our single viewer unit) as the argument, so we report it + // as fully built, allied with itself, and local. + case .unitBuildPercentLeft: + result = 0 + case .unitAllied: + result = 1 + case .unitIsOnThisComp: + result = 1 + default: + () + } + } + execution.thread.stack.push(result) execution.thread.instructionPointer += 1 } +private func pieceWorldPosition(scriptPiece: UnitScript.CodeUnit, execution: ScriptExecutionContext) -> Vertex3f? { + guard let modelIndex = try? execution.process.pieceIndex(at: scriptPiece) else { return nil } + let model = execution.process.model + guard modelIndex < model.pieces.count else { return nil } + return model.pieceWorldPosition(modelIndex, instance: execution.model.pointee) +} + +/// Locate a piece whose current world XZ matches the queried packed coordinate +/// and return its full world position. Used by the GROUND_HEIGHT stub so that +/// walker scripts' `move Point to y-axis [GROUND_HEIGHT(PIECE_XZ(Point)) - +/// PIECE_Y(Point)]` pattern evaluates to a stable target instead of +/// oscillating each tick. +private func pieceWorldPositionFromPackedXZ(_ packed: UnitScript.CodeUnit, execution: ScriptExecutionContext) -> Vertex3f? { + let (qx, qz) = unpackXZ(packed) + let model = execution.process.model + let instance = execution.model.pointee + for i in 0.. UnitScript.CodeUnit { + // TA packs the XZ coordinate as (x << 16) | (z & 0xFFFF), both as 16-bit + // signed values. Piece offsets fit comfortably in 16 bits for normal units. + let xi = max(-32768, min(32767, Int(x.rounded()))) & 0xFFFF + let zi = max(-32768, min(32767, Int(z.rounded()))) & 0xFFFF + return UnitScript.CodeUnit(truncatingIfNeeded: (xi << 16) | zi) +} + +private func unpackXZ(_ packed: UnitScript.CodeUnit) -> (GameFloat, GameFloat) { + let xRaw = Int(packed) >> 16 + var zRaw = Int(packed) & 0xFFFF + if zRaw >= 0x8000 { zRaw -= 0x10000 } + return (GameFloat(xRaw), GameFloat(zRaw)) +} + +/// TA represents a full turn as 65536 angle units in the unsigned range +/// [0, 65536). Walker scripts compare the result of XZ_ATAN / ATAN against +/// constants like 16384 (90°), 32768 (180°), 49152 (270°) and rely on the +/// third and fourth quadrants being represented as values > 32768 rather than +/// as negatives; returning a signed result would make `> 16384 && < 49152` +/// (the "second-or-third quadrant" check that drives LegGroups' bisection) +/// fail for every actual second/third-quadrant angle. +private func taAtan2(z: GameFloat, x: GameFloat) -> UnitScript.CodeUnit { + guard x != 0 || z != 0 else { return 0 } + let radians = atan2(z, x) + var turns = radians / (2 * .pi) + if turns < 0 { turns += 1 } + return UnitScript.CodeUnit(truncatingIfNeeded: Int((turns * 65536).rounded())) +} + +private func taHypot(x: GameFloat, z: GameFloat) -> UnitScript.CodeUnit { + let h = hypot(x, z) + return UnitScript.CodeUnit(truncatingIfNeeded: Int(h.rounded())) +} + /** Code @@ -720,8 +873,8 @@ private func startScript(execution: ScriptExecutionContext) throws { let module = try execution.process.module(at: moduleIndex) let params = try execution.thread.stack.pop(count: Int(paramCount)) - - execution.process.startScript(module, parameters: params.reversed()) + + execution.process.startScript(module, parameters: params) execution.thread.instructionPointer += 3 } @@ -743,9 +896,9 @@ private func callScript(execution: ScriptExecutionContext) throws { let module = try execution.process.module(at: moduleIndex) let params = try execution.thread.stack.pop(count: Int(paramCount)) - + execution.thread.instructionPointer += 3 - execution.thread.callScript(module, parameters: params.reversed()) + execution.thread.callScript(module, parameters: params) } /** diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+PieceReferences.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+PieceReferences.swift new file mode 100644 index 0000000..a693490 --- /dev/null +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+PieceReferences.swift @@ -0,0 +1,97 @@ +// +// UnitScript+PieceReferences.swift +// SwiftTA-Core +// + +import Foundation + +public extension UnitScript { + + /// A reference to a model piece emitted by a COB instruction. + struct PieceReference: Hashable { + public let moduleName: String + public let opcode: UnitScript.Opcode + } + + /// Maps each script-piece-index to the set of instructions across all modules that touch that piece. + /// + /// The index is into `UnitScript.pieces`. Resolve piece names via `pieces[index]`, then match + /// against `UnitModel.nameLookup` (case-insensitive) to locate the model piece. + func pieceReferences() -> [Int: [PieceReference]] { + var result: [Int: [PieceReference]] = [:] + let moduleEnds = sortedModuleEnds() + + for (moduleIndex, module) in modules.enumerated() { + let end = moduleEnds[moduleIndex] + var ip = module.offset + while ip < end { + guard let opcode = UnitScript.Opcode(rawValue: code[ip]) else { + ip += 1 + continue + } + let layout = UnitScript.operandLayout(for: opcode) + if let pieceOffset = layout.pieceOperandOffset, ip + pieceOffset < code.count { + let pieceIdx = Int(code[ip + pieceOffset]) + if pieces.indices.contains(pieceIdx) { + result[pieceIdx, default: []].append( + PieceReference(moduleName: module.name, opcode: opcode)) + } + } + ip += layout.totalLength + if opcode == .`return` { break } + } + } + return result + } + + private func sortedModuleEnds() -> [Code.Index] { + let starts = modules.map { $0.offset } + let sortedStarts = starts.sorted() + var ends = Array(repeating: code.count, count: modules.count) + for (i, start) in modules.enumerated() { + if let next = sortedStarts.first(where: { $0 > start.offset }) { + ends[i] = next + } + } + return ends + } + + /// Per-opcode operand layout: the length of the opcode + its trailing immediate operands in the code stream, + /// and where (if anywhere) the piece-index operand sits relative to the opcode. + static func operandLayout(for opcode: UnitScript.Opcode) -> (totalLength: Int, pieceOperandOffset: Int?) { + switch opcode { + case .movePieceWithSpeed, .turnPieceWithSpeed, + .startSpin, .stopSpin, + .movePieceNow, .turnPieceNow, + .waitForTurn, .waitForMove, + .explode: + return (3, 1) + case .showPiece, .hidePiece, + .cachePiece, .dontCachePiece, + .dontShadow, .dontShade, + .emitSfx: + return (2, 1) + case .pushImmediate, .pushLocal, .pushStatic, + .setLocal, .setStatic, + .jumpToOffset, .jumpToOffsetIfFalse, + .playSound, .setSignalMask: + return (2, nil) + case .startScript, .callScript: + return (3, nil) + case .stackAllocate, .popStack, .sleep, + .add, .subtract, .multiply, .divide, + .bitwiseAnd, .bitwiseOr, + .unknown1, .unknown2, .unknown3, + .random, + .getUnitValue, .getFunctionResult, + .lessThan, .lessThanOrEqual, + .greaterThan, .greaterThanOrEqual, + .equal, .notEqual, + .and, .or, .not, + .`return`, .signal, + .mapCommand, .setUnitValue, + .attachUnit, .dropUnit: + return (1, nil) + } + } +} diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift index 127708e..cd7aedc 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift @@ -15,13 +15,15 @@ public extension UnitScript { class Context { public var script: UnitScript + public var model: UnitModel public var staticVariables: [UnitScript.CodeUnit] public var threads: [Thread] public var animations: [Animation] public var pieceMap: [UnitModel.Pieces.Index] - + public init(_ script: UnitScript, _ model: UnitModel) throws { self.script = script + self.model = model staticVariables = Array(repeating: 0, count: script.numberOfStaticVariables) threads = [] animations = [] @@ -32,7 +34,7 @@ public extension UnitScript { return index } } - + } class Thread { @@ -79,9 +81,13 @@ public protocol ScriptMachine { } public extension UnitScript.Context { - - func run(for instance: UnitModel.Instance, on machine: Machine) { - threads.forEach { $0.run(with: self, for: instance, on: machine) } + + func run(for instance: inout UnitModel.Instance, on machine: Machine) { + withUnsafeMutablePointer(to: &instance) { pointer in + for thread in threads { + thread.run(with: self, for: pointer, on: machine) + } + } threads = threads.filter { !$0.isFinished } } @@ -127,6 +133,47 @@ public extension UnitScript.Context { func applyAnimations(to instance: inout UnitModel.Instance, for delta: GameFloat) { let unfinished = animations.compactMap { instance.apply($0, with: delta) } animations = unfinished + // Release any thread that was waiting for a turn / move that's now + // finished. Without this, wait-for-turn and wait-for-move freeze the + // thread forever and loops like walklegs() never advance past their + // first synchronization point. + for thread in threads where !thread.isFinished { + switch thread.status { + case .waitingForTurn(let piece, let axis): + if !animations.contains(where: { UnitScript.Context.animation($0, matchesTurnOn: piece, around: axis) }) { + thread.status = .running + } + case .waitingForMove(let piece, let axis): + if !animations.contains(where: { UnitScript.Context.animation($0, matchesTranslationOn: piece, along: axis) }) { + thread.status = .running + } + default: + break + } + } + } + + private static func animation(_ anim: UnitScript.Animation, matchesTurnOn piece: Int, around axis: UnitScript.Axis) -> Bool { + return animationMatchesTurn(anim, piece: piece, axis: axis) + } + + private static func animation(_ anim: UnitScript.Animation, matchesTranslationOn piece: Int, along axis: UnitScript.Axis) -> Bool { + return animationMatchesTranslation(anim, piece: piece, axis: axis) + } + + public static func animationMatchesTurn(_ anim: UnitScript.Animation, piece: Int, axis: UnitScript.Axis) -> Bool { + switch anim { + case .rotation(let r): return r.piece == piece && r.axis == axis + case .spinUp(let s): return s.piece == piece && s.axis == axis + case .spin(let s): return s.piece == piece && s.axis == axis + case .spinDown(let s): return s.piece == piece && s.axis == axis + default: return false + } + } + + public static func animationMatchesTranslation(_ anim: UnitScript.Animation, piece: Int, axis: UnitScript.Axis) -> Bool { + if case .translation(let t) = anim { return t.piece == piece && t.axis == axis } + return false } func findSpinAnimation(of piece: Int, around axis: UnitScript.Axis) -> (index: Int, spin: UnitScript.SpinAnimation)? { @@ -217,7 +264,7 @@ public extension UnitScript.Thread { return (signalMask & mask) != 0 } - func run(with context: UnitScript.Context, for instance: UnitModel.Instance, on machine: Machine) { + func run(with context: UnitScript.Context, for instance: UnsafeMutablePointer, on machine: Machine) { let execution = ScriptExecutionContext(process: context, thread: self, model: instance, machine: machine) do { runLoop: while true { @@ -284,7 +331,11 @@ public extension UnitScript.Thread.Stack { guard n > 0 else { return [] } if _array.count >= n { defer { _array.removeLast(n) } - return Array( _array.suffix(from: n-1) ) + // Return the last n elements (top of stack), in bottom-to-top order. + // The previous implementation used `suffix(from: n - 1)` which only + // happens to return n elements when the stack has exactly 2·n - 1 + // items, and silently returned the wrong count the rest of the time. + return Array(_array.suffix(n)) } else { throw Error.stackUnderflow } } diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript.swift index dff154e..6cc88e9 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript.swift @@ -247,7 +247,33 @@ public extension UnitScript { /// #define ARMORED 20 // set or get case armored = 20 - + + // ----------------------------------------------------------------- + // TADR / TA Recorder COB extensions. These are the values used by + // mod scripts to iterate over other units, check alliances, and so + // on. The full list is large (see TADR's COB_extensions.pas) and + // most are sim-state-dependent; SwiftTA only stubs the handful + // that modded unit scripts actually call on load so that Detect() + // / target-scan loops exit cleanly instead of treating everything + // as hostile. See UnitScript+Instructions.swift for the returned + // values. + // ----------------------------------------------------------------- + + /// TADR MIN_ID — lowest valid unit ID in the game's unit array. + case minUnitID = 69 + /// TADR MAX_ID — highest valid unit ID in the game's unit array. + case maxUnitID = 70 + /// TADR MY_ID — the ID of the unit running this COB script. + case myUnitID = 71 + /// TADR UNIT_BUILD_PERCENT_LEFT(unit_id) — same semantics as the + /// standard BUILD_PERCENT_LEFT but addressable to any unit ID. + case unitBuildPercentLeft = 73 + /// TADR UNIT_ALLIED(unit_id) — 1 if that unit is allied, else 0. + case unitAllied = 74 + /// TADR UNIT_IS_ON_THIS_COMP(unit_id) — 1 if the unit is local to + /// this machine (vs a multiplayer peer); 0 otherwise. + case unitIsOnThisComp = 75 + // New in TA:K /// #define WEAPON_AIM_ABORTED 21 /// #define WEAPON_READY 22 diff --git a/SwiftTA-Core/Tests/SwiftTA-CoreTests/SwiftTA_CoreTests.swift b/SwiftTA-Core/Tests/SwiftTA-CoreTests/SwiftTA_CoreTests.swift index 50d6e81..56af4ed 100644 --- a/SwiftTA-Core/Tests/SwiftTA-CoreTests/SwiftTA_CoreTests.swift +++ b/SwiftTA-Core/Tests/SwiftTA-CoreTests/SwiftTA_CoreTests.swift @@ -1,15 +1,13 @@ +// +// Placeholder test file — the actual suites live in +// TaMapModelWriterTests.swift (and siblings as they're added). This +// empty case is kept so the test target has a stable source-file +// list that was carried over from the Swift package template. +// + import XCTest @testable import SwiftTA_Core final class SwiftTA_CoreTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(SwiftTA_Core().text, "Hello, World!") - } - - static var allTests = [ - ("testExample", testExample), - ] + // Intentionally empty. } diff --git a/SwiftTA-Core/Tests/SwiftTA-CoreTests/TaMapModelWriterTests.swift b/SwiftTA-Core/Tests/SwiftTA-CoreTests/TaMapModelWriterTests.swift new file mode 100644 index 0000000..988945e --- /dev/null +++ b/SwiftTA-Core/Tests/SwiftTA-CoreTests/TaMapModelWriterTests.swift @@ -0,0 +1,285 @@ +// +// TaMapModelWriterTests.swift +// SwiftTA-CoreTests +// +// Round-trip coverage for the TNT writer. Phase 1 of the map-editor +// work asserts that every map serialized by TaMapModel.writeTnt() +// reads back equal to the original — this is the regression gate the +// later editor UI rests on. +// +// The synthetic tests construct a MapModel in memory and round-trip +// it, which catches writer bugs without needing any external .tnt +// fixtures. Real-map round-tripping (setting the +// SWIFTTA_TEST_MAPS_DIR env var to a directory of loose .tnt files) +// is optional and skipped when the variable is unset — useful for +// local validation against the actual Cavedog shipping maps without +// bundling them in the repo. +// + +import XCTest +@testable import SwiftTA_Core + + +final class TaMapModelWriterTests: XCTestCase { + + // MARK: - Synthetic round-trip + + /// A minimal 4×4 map with two tiles in the tileset, one feature, a + /// non-zero sea level, and a deterministic height pattern. Exercises + /// every section of the writer. + func testSyntheticMap_RoundTripsExactly() throws { + let original = makeSyntheticMap( + mapSize: Size2(width: 4, height: 4), + tileCount: 2, + seaLevel: 42, + featureNames: ["Tree01"], + placeFeatureEveryNCells: 3 + ) + + let encoded = try original.writeTnt() + let rehydrated = try decodeTa(from: encoded) + + assertMapsEqual(rehydrated, original) + } + + /// Larger map with many features to exercise the feature-entry loop + /// and a tile count that produces a non-trivial tile array. + func testSyntheticMap_ManyFeatures_RoundTripsExactly() throws { + let original = makeSyntheticMap( + mapSize: Size2(width: 16, height: 16), + tileCount: 10, + seaLevel: 128, + featureNames: (0..<32).map { "Feature\($0)" }, + placeFeatureEveryNCells: 5 + ) + + let encoded = try original.writeTnt() + let rehydrated = try decodeTa(from: encoded) + + assertMapsEqual(rehydrated, original) + } + + /// Edge case: no features at all. featureCount must be 0, no + /// feature-entry section written, and every featureMap cell must + /// round-trip as nil. + func testSyntheticMap_NoFeatures_RoundTripsExactly() throws { + let original = makeSyntheticMap( + mapSize: Size2(width: 8, height: 8), + tileCount: 4, + seaLevel: 0, + featureNames: [], + placeFeatureEveryNCells: 0 + ) + + let encoded = try original.writeTnt() + let rehydrated = try decodeTa(from: encoded) + + XCTAssertEqual(rehydrated.features.count, 0) + XCTAssertTrue(rehydrated.featureMap.allSatisfy { $0 == nil }) + assertMapsEqual(rehydrated, original) + } + + // MARK: - Error surface + + func testRejectsOversizedFeatureName() throws { + var model = makeSyntheticMap(mapSize: Size2(4, 4), tileCount: 1, seaLevel: 0, featureNames: ["TooLong"], placeFeatureEveryNCells: 0) + model.features = [FeatureTypeId(named: String(repeating: "A", count: 128))] + XCTAssertThrowsError(try model.writeTnt()) { error in + guard case TaMapModel.WriteError.featureNameTooLong = error else { + XCTFail("Expected featureNameTooLong, got \(error)") + return + } + } + } + + func testRejectsHeightSampleCountMismatch() throws { + var model = makeSyntheticMap(mapSize: Size2(4, 4), tileCount: 1, seaLevel: 0, featureNames: [], placeFeatureEveryNCells: 0) + model.heightMap.samples.removeLast() + XCTAssertThrowsError(try model.writeTnt()) { error in + guard case TaMapModel.WriteError.heightSampleCountMismatch = error else { + XCTFail("Expected heightSampleCountMismatch, got \(error)") + return + } + } + } + + // MARK: - Optional real-map round-trip + + /// Runs only when `SWIFTTA_TEST_MAPS_DIR` is set — iterates every + /// loose `.tnt` in that directory and verifies the writer round-trips + /// semantically. Skipped (not failed) when the env var is unset, so + /// CI can stay hermetic while local devs can validate against their + /// tafiles. + func testRealMaps_RoundTripIfConfigured() throws { + guard let dir = ProcessInfo.processInfo.environment["SWIFTTA_TEST_MAPS_DIR"] else { + throw XCTSkip("Set SWIFTTA_TEST_MAPS_DIR to a directory of loose .tnt files to enable.") + } + let fm = FileManager.default + let url = URL(fileURLWithPath: dir, isDirectory: true) + let tntFiles = (try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)) + .filter { $0.pathExtension.lowercased() == "tnt" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + + XCTAssertFalse(tntFiles.isEmpty, "No .tnt files found under \(dir)") + + for file in tntFiles { + guard let bytes = try? Data(contentsOf: file) else { + XCTFail("\(file.lastPathComponent): could not read file") + continue + } + let reader = DataReader(data: bytes, name: file.lastPathComponent) + guard let parsed = try? MapModel(contentsOf: reader) else { + XCTFail("\(file.lastPathComponent): could not parse TNT") + continue + } + guard case .ta(let original) = parsed else { + XCTFail("\(file.lastPathComponent): not a TA TNT file (skipping)") + continue + } + + let encoded: Data + do { + encoded = try original.writeTnt() + } catch { + XCTFail("\(file.lastPathComponent): writeTnt failed — \(error)") + continue + } + + let rehydrated: TaMapModel + do { + rehydrated = try decodeTa(from: encoded) + } catch { + XCTFail("\(file.lastPathComponent): rewritten TNT does not re-parse — \(error)") + continue + } + + assertMapsEqual(rehydrated, original, prefix: file.lastPathComponent) + } + } + + // MARK: - Helpers + + private func makeSyntheticMap( + mapSize: Size2, + tileCount: Int, + seaLevel: Int, + featureNames: [String], + placeFeatureEveryNCells: Int + ) -> TaMapModel { + let tileSize = Size2(width: 32, height: 32) + let tileBytes = tileCount * tileSize.area + var tiles = Data(count: tileBytes) + for i in 0...size) + tileIndexBytes.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt16.self) + for i in 0.. 0 && !features.isEmpty { + for i in stride(from: 0, to: mapSize.area, by: placeFeatureEveryNCells) { + let idx = i % features.count + if featureIndexRange.contains(idx) { + featureMap[i] = idx + } + } + } + + let minimapSize = Size2(width: max(1, mapSize.width), height: max(1, mapSize.height)) + var minimapData = Data(count: minimapSize.area) + for i in 0.. TaMapModel { + let handle = DataReader(data: data) + let model = try MapModel(contentsOf: handle) + switch model { + case .ta(let ta): return ta + case .tak: + XCTFail("Writer produced a Kingdoms-version TNT — expected TA") + throw CocoaError(.fileReadCorruptFile) + } + } + + private func assertMapsEqual(_ lhs: TaMapModel, _ rhs: TaMapModel, prefix: String = "", file: StaticString = #file, line: UInt = #line) { + let p = prefix.isEmpty ? "" : "\(prefix): " + XCTAssertEqual(lhs.mapSize, rhs.mapSize, "\(p)mapSize", file: file, line: line) + XCTAssertEqual(lhs.seaLevel, rhs.seaLevel, "\(p)seaLevel", file: file, line: line) + XCTAssertEqual(lhs.heightMap.samples, rhs.heightMap.samples, "\(p)heightMap.samples", file: file, line: line) + XCTAssertEqual(lhs.heightMap.sampleCount, rhs.heightMap.sampleCount, "\(p)heightMap.sampleCount", file: file, line: line) + XCTAssertEqual(lhs.tileSet.count, rhs.tileSet.count, "\(p)tileSet.count", file: file, line: line) + XCTAssertEqual(lhs.tileSet.tileSize, rhs.tileSet.tileSize, "\(p)tileSet.tileSize", file: file, line: line) + XCTAssertEqual(lhs.tileSet.tiles, rhs.tileSet.tiles, "\(p)tileSet.tiles", file: file, line: line) + XCTAssertEqual(lhs.tileIndexMap.indices, rhs.tileIndexMap.indices, "\(p)tileIndexMap.indices", file: file, line: line) + XCTAssertEqual(lhs.tileIndexMap.size, rhs.tileIndexMap.size, "\(p)tileIndexMap.size", file: file, line: line) + XCTAssertEqual(lhs.featureMap, rhs.featureMap, "\(p)featureMap", file: file, line: line) + XCTAssertEqual(lhs.features.map { $0.name }, rhs.features.map { $0.name }, "\(p)features", file: file, line: line) + XCTAssertEqual(lhs.minimap.size, rhs.minimap.size, "\(p)minimap.size", file: file, line: line) + XCTAssertEqual(lhs.minimap.data, rhs.minimap.data, "\(p)minimap.data", file: file, line: line) + } +} + + +// MARK: - In-memory FileReadHandle + +/// Minimal in-memory seekable/readable file used by the synthetic round-trip +/// tests. The real map loader only requires `FileReadHandle` semantics +/// (read + seek), so Data-backed tests can avoid touching the filesystem. +private final class DataReader: FileReadHandle { + private let data: Data + private var cursor: Int = 0 + let fileName: String + + init(data: Data, name: String = "") { + self.data = data + self.fileName = name + } + + var fileSize: Int { data.count } + var fileOffset: Int { cursor } + + func seek(toFileOffset offset: Int) { + cursor = max(0, min(offset, data.count)) + } + + func readData(ofLength length: Int) -> Data { + let end = min(cursor + length, data.count) + let slice = data.subdata(in: cursor.. Data { + let slice = data.subdata(in: cursor.. Bool { + lhs.properties == rhs.properties && lhs.subobjects == rhs.subobjects + } +} diff --git a/SwiftTA-Metal/Sources/SwiftTA-Metal/MetalUnitDrawable.swift b/SwiftTA-Metal/Sources/SwiftTA-Metal/MetalUnitDrawable.swift index ed2cffb..40e0610 100644 --- a/SwiftTA-Metal/Sources/SwiftTA-Metal/MetalUnitDrawable.swift +++ b/SwiftTA-Metal/Sources/SwiftTA-Metal/MetalUnitDrawable.swift @@ -314,25 +314,26 @@ private extension MetalUnitDrawable.Instance { let sin = vector_float3( anims.turn.map { ($0 * deg2rad).sine } ) let cos = vector_float3( anims.turn.map { ($0 * deg2rad).cosine } ) + // Yaw-outermost rotation order — see UnitModel+Bounds.pieceLocalTransform. let t = matrix_float4x4(columns: ( vector_float4( cos.y * cos.z, - (sin.y * cos.x) + (sin.x * cos.y * sin.z), - (sin.x * sin.y) - (cos.x * cos.y * sin.z), + sin.y * cos.z, + -sin.z, 0), - + vector_float4( - -sin.y * cos.z, - (cos.x * cos.y) - (sin.x * sin.y * sin.z), - (sin.x * cos.y) + (cos.x * sin.y * sin.z), + (cos.y * sin.z * sin.x) - (sin.y * cos.x), + (sin.y * sin.z * sin.x) + (cos.y * cos.x), + cos.z * sin.x, 0), - + vector_float4( - sin.z, - -sin.x * cos.z, - cos.x * cos.z, + (cos.y * sin.z * cos.x) + (sin.y * sin.x), + (sin.y * sin.z * cos.x) - (cos.y * sin.x), + cos.z * cos.x, 0), - + vector_float4( offset.x - move.x, offset.y - move.z, diff --git a/SwiftTA-OpenGL3/Sources/SwiftTA-OpenGL3/OpenglCore3UnitDrawable.swift b/SwiftTA-OpenGL3/Sources/SwiftTA-OpenGL3/OpenglCore3UnitDrawable.swift index 9ba746e..ec71d0c 100644 --- a/SwiftTA-OpenGL3/Sources/SwiftTA-OpenGL3/OpenglCore3UnitDrawable.swift +++ b/SwiftTA-OpenGL3/Sources/SwiftTA-OpenGL3/OpenglCore3UnitDrawable.swift @@ -345,22 +345,23 @@ private extension OpenglCore3UnitDrawable.Instance { let sin: Vector3f = anims.turn.map { ($0 * deg2rad).sine } let cos: Vector3f = anims.turn.map { ($0 * deg2rad).cosine } + // Yaw-outermost rotation order — see UnitModel+Bounds.pieceLocalTransform. let t = Matrix4x4f( cos.y * cos.z, - (sin.y * cos.x) + (sin.x * cos.y * sin.z), - (sin.x * sin.y) - (cos.x * cos.y * sin.z), + sin.y * cos.z, + -sin.z, 0, - - -sin.y * cos.z, - (cos.x * cos.y) - (sin.x * sin.y * sin.z), - (sin.x * cos.y) + (cos.x * sin.y * sin.z), + + (cos.y * sin.z * sin.x) - (sin.y * cos.x), + (sin.y * sin.z * sin.x) + (cos.y * cos.x), + cos.z * sin.x, 0, - - sin.z, - -sin.x * cos.z, - cos.x * cos.z, + + (cos.y * sin.z * cos.x) + (sin.y * sin.x), + (sin.y * sin.z * cos.x) - (cos.y * sin.x), + cos.z * cos.x, 0, - + offset.x - move.x, offset.y - move.z, offset.z + move.y, diff --git a/SwiftTA.xcworkspace/contents.xcworkspacedata b/SwiftTA.xcworkspace/contents.xcworkspacedata index 49f4a1e..60b6b83 100644 --- a/SwiftTA.xcworkspace/contents.xcworkspacedata +++ b/SwiftTA.xcworkspace/contents.xcworkspacedata @@ -13,16 +13,13 @@ - - - - + + diff --git a/TAassets/TAassets.xcodeproj/project.pbxproj b/TAassets/TAassets.xcodeproj/project.pbxproj index 8881719..0031a53 100644 --- a/TAassets/TAassets.xcodeproj/project.pbxproj +++ b/TAassets/TAassets.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ B5C556841E3058A9001BEFAB /* HpiFinderRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C556831E3058A9001BEFAB /* HpiFinderRow.xib */; }; B5C5568F1E3437EF001BEFAB /* ModelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C5568E1E3437EF001BEFAB /* ModelView.swift */; }; B5C556911E353FC1001BEFAB /* UnitBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C556901E353FC1001BEFAB /* UnitBrowser.swift */; }; + FACE0002000001000000FACE /* PieceHierarchyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACE0001000001000000FACE /* PieceHierarchyView.swift */; }; + FACE0006000003000000FACE /* PlaybackControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACE0005000003000000FACE /* PlaybackControlsView.swift */; }; + FACE0008000004000000FACE /* WeaponsBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACE0007000004000000FACE /* WeaponsBrowser.swift */; }; B5D432FA1F0995CC005B468E /* QuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D432F91F0995CC005B468E /* QuickLookView.swift */; }; B5E26F381ED9F0ED006C329B /* GafView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E26F371ED9F0ED006C329B /* GafView.swift */; }; B5E26F441EE3745F006C329B /* UnitView+Opengl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E26F431EE3745F006C329B /* UnitView+Opengl.swift */; }; @@ -85,6 +88,9 @@ B5C556831E3058A9001BEFAB /* HpiFinderRow.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = HpiFinderRow.xib; path = ../../HPIView/HPIView/HpiFinderRow.xib; sourceTree = ""; }; B5C5568E1E3437EF001BEFAB /* ModelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ModelView.swift; path = ../../HPIView/HPIView/ModelView.swift; sourceTree = ""; }; B5C556901E353FC1001BEFAB /* UnitBrowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnitBrowser.swift; sourceTree = ""; }; + FACE0001000001000000FACE /* PieceHierarchyView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PieceHierarchyView.swift; sourceTree = ""; }; + FACE0005000003000000FACE /* PlaybackControlsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackControlsView.swift; sourceTree = ""; }; + FACE0007000004000000FACE /* WeaponsBrowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeaponsBrowser.swift; sourceTree = ""; }; B5D432F91F0995CC005B468E /* QuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookView.swift; sourceTree = ""; }; B5E26F371ED9F0ED006C329B /* GafView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GafView.swift; path = ../../HPIView/HPIView/GafView.swift; sourceTree = ""; }; B5E26F431EE3745F006C329B /* UnitView+Opengl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UnitView+Opengl.swift"; sourceTree = ""; }; @@ -173,6 +179,9 @@ B5C556491E2C86A2001BEFAB /* AppDelegate.swift */, B5C5564D1E2C86A2001BEFAB /* TaassetsDocument.swift */, B5C556901E353FC1001BEFAB /* UnitBrowser.swift */, + FACE0001000001000000FACE /* PieceHierarchyView.swift */, + FACE0005000003000000FACE /* PlaybackControlsView.swift */, + FACE0007000004000000FACE /* WeaponsBrowser.swift */, B5C5567F1E2F1262001BEFAB /* FileBrowser.swift */, B5E26F4F1EE4813A006C329B /* MapBrowser.swift */, B5C556811E2F1747001BEFAB /* FinderView.swift */, @@ -360,6 +369,9 @@ F08A641D20EEAA47001E5982 /* ModelView+Opengl.swift in Sources */, F0CD87CB20FFB5D60012B1C8 /* MapView+Cocoa.swift in Sources */, B5C556911E353FC1001BEFAB /* UnitBrowser.swift in Sources */, + FACE0002000001000000FACE /* PieceHierarchyView.swift in Sources */, + FACE0006000003000000FACE /* PlaybackControlsView.swift in Sources */, + FACE0008000004000000FACE /* WeaponsBrowser.swift in Sources */, F015C79820AE261400873642 /* UnitViewRenderer+OpenglLegacy.swift in Sources */, B5E26F441EE3745F006C329B /* UnitView+Opengl.swift in Sources */, F08A641920EEAA47001E5982 /* ModelViewRenderer+OpenglLegacy.swift in Sources */, @@ -479,7 +491,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -532,7 +544,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/TAassets/TAassets/AppDelegate.swift b/TAassets/TAassets/AppDelegate.swift index 8579cf1..bf9466c 100644 --- a/TAassets/TAassets/AppDelegate.swift +++ b/TAassets/TAassets/AppDelegate.swift @@ -11,19 +11,83 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { + private let modsMenu = NSMenu(title: "Mods") + override init() { super.init() + setbuf(stdout, nil) + setbuf(stderr, nil) let _ = TaassetsDocumentController.shared } func applicationDidFinishLaunching(_ aNotification: Notification) { - // Insert code here to initialize your application + let item = NSMenuItem(title: "Mods", action: nil, keyEquivalent: "") + modsMenu.delegate = self + modsMenu.autoenablesItems = false + item.submenu = modsMenu + if let mainMenu = NSApp.mainMenu { + let insertIndex = max(0, mainMenu.items.count - 1) + mainMenu.insertItem(item, at: insertIndex) + } } func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down your application } +} + +extension AppDelegate: NSMenuDelegate { + + func menuWillOpen(_ menu: NSMenu) { + guard menu === modsMenu else { return } + menu.removeAllItems() + + guard let document = NSDocumentController.shared.currentDocument as? TaassetsDocument else { + let placeholder = NSMenuItem(title: "No open TA document", action: nil, keyEquivalent: "") + placeholder.isEnabled = false + menu.addItem(placeholder) + return + } + + let baseName = document.baseURL?.lastPathComponent ?? "Base" + let baseTitle = "Base only: \(baseName)" + let baseItem = NSMenuItem(title: baseTitle, + action: #selector(activateModFromMenu(_:)), + keyEquivalent: "") + baseItem.target = self + baseItem.representedObject = nil + baseItem.state = (document.currentModURL == nil) ? .on : .off + menu.addItem(baseItem) + + let mods = document.availableMods + if mods.isEmpty { + menu.addItem(NSMenuItem.separator()) + let none = NSMenuItem(title: "No mods found in \(baseName)/mods", + action: nil, keyEquivalent: "") + none.isEnabled = false + menu.addItem(none) + return + } + menu.addItem(NSMenuItem.separator()) + for modURL in mods { + let item = NSMenuItem(title: modURL.lastPathComponent, + action: #selector(activateModFromMenu(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = modURL + item.state = (document.currentModURL == modURL) ? .on : .off + menu.addItem(item) + } + } + + @IBAction func activateModFromMenu(_ sender: NSMenuItem) { + Swift.print(">>> activateModFromMenu delegate fired; represented=\((sender.representedObject as? URL)?.lastPathComponent ?? "base only")") + guard let doc = NSDocumentController.shared.currentDocument as? TaassetsDocument else { + Swift.print(" no current TaassetsDocument") + return + } + doc.activateMod(sender) + } } diff --git a/TAassets/TAassets/MapBrowser.swift b/TAassets/TAassets/MapBrowser.swift index cce19c0..f872210 100644 --- a/TAassets/TAassets/MapBrowser.swift +++ b/TAassets/TAassets/MapBrowser.swift @@ -10,62 +10,86 @@ import Cocoa import SwiftTA_Core class MapBrowserViewController: NSViewController, ContentViewController { - + var shared = TaassetsSharedState.empty + private var allMaps: [FileSystem.File] = [] private var maps: [FileSystem.File] = [] - + private var searchTerm: String = "" + private var tableView: NSTableView! + private var searchField: NSSearchField! private var detailViewContainer: NSView! private var detailViewController = MapDetailViewController() private var isShowingDetail = false - + override func loadView() { let bounds = NSRect(x: 0, y: 0, width: 480, height: 480) let mainView = NSView(frame: bounds) - + let listWidth: CGFloat = 240 - - let scrollView = NSScrollView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height)) + let searchHeight: CGFloat = 28 + + let searchField = NSSearchField(frame: NSMakeRect(4, bounds.size.height - searchHeight - 2, listWidth - 8, searchHeight - 4)) + searchField.autoresizingMask = [.minYMargin] + searchField.placeholderString = "Filter maps" + searchField.target = self + searchField.action = #selector(searchFieldChanged(_:)) + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = false + mainView.addSubview(searchField) + + let scrollView = NSScrollView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height - searchHeight)) scrollView.autoresizingMask = [.height] scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false - - let tableView = NSTableView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height)) + + let tableView = NSTableView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height - searchHeight)) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "name")) column.width = listWidth-2 tableView.addTableColumn(column) tableView.identifier = NSUserInterfaceItemIdentifier(rawValue: "maps") tableView.headerView = nil tableView.rowHeight = 32 - + scrollView.documentView = tableView - + tableView.dataSource = self tableView.delegate = self mainView.addSubview(scrollView) - + let detail = NSView(frame: NSMakeRect(listWidth, 0, bounds.size.width - listWidth, bounds.size.height)) detail.autoresizingMask = [.width, .height] mainView.addSubview(detail) - + self.view = mainView self.detailViewContainer = detail self.tableView = tableView + self.searchField = searchField } - + override func viewDidLoad() { let begin = Date() let mapsDirectory = shared.filesystem.root[directory: "maps"] ?? FileSystem.Directory() - let maps = mapsDirectory.items - .compactMap { $0.asFile() } - .filter { $0.hasExtension("ota") } + let maps = mapsDirectory.allFiles(withExtension: "ota") .sorted { FileSystem.sortNames($0.name, $1.name) } + self.allMaps = maps self.maps = maps let end = Date() - print("Map list load time: \(end.timeIntervalSince(begin)) seconds") + print("Map list load time: \(end.timeIntervalSince(begin)) seconds; maps found: \(maps.count)") } - + + @objc private func searchFieldChanged(_ sender: NSSearchField) { + searchTerm = sender.stringValue.trimmingCharacters(in: .whitespaces) + if searchTerm.isEmpty { + maps = allMaps + } else { + let term = searchTerm.lowercased() + maps = allMaps.filter { $0.baseName.lowercased().contains(term) || $0.name.lowercased().contains(term) } + } + tableView.reloadData() + } + } extension MapBrowserViewController: NSTableViewDataSource { @@ -99,11 +123,11 @@ extension MapBrowserViewController: NSTableViewDelegate { else { return } let row = tableView.selectedRow if row >= 0 { - + if !isShowingDetail { let controller = detailViewController controller.view.frame = detailViewContainer.bounds - controller.view.autoresizingMask = [.width, .width] + controller.view.autoresizingMask = [.width, .height] addChild(controller) detailViewContainer.addSubview(controller.view) isShowingDetail = true @@ -152,33 +176,79 @@ class MapInfoCell: NSTableCellView { } class MapDetailViewController: NSViewController { - + let mapView = MapViewController() - + private var overlayMode: MapOverlayMode = .none + private var slopeThreshold: Int = 20 + func loadMap(in otaFile: FileSystem.File, from filesystem: FileSystem) throws { let name = otaFile.baseName try mapView.load(name, from: filesystem) mapTitle = name + mapView.setOverlayMode(overlayMode) + mapView.setSlopeThreshold(slopeThreshold) + + if let info = try? MapInfo(contentsOf: otaFile, in: filesystem) { + container.detailLabel.stringValue = Self.describe(info, mapName: name) + } else { + container.detailLabel.stringValue = "" + } } - + func clear() { mapView.clear() + container.titleLabel.stringValue = "" + container.detailLabel.stringValue = "" } - + + @objc private func overlayModeChanged(_ sender: NSSegmentedControl) { + guard let mode = MapOverlayMode(rawValue: sender.selectedSegment) else { return } + overlayMode = mode + mapView.setOverlayMode(mode) + container.setSlopeControlVisible(mode == .passability) + } + + @objc private func slopeThresholdChanged(_ sender: NSSlider) { + let value = Int(sender.integerValue) + slopeThreshold = value + container.updateSlopeLabel(value) + mapView.setSlopeThreshold(value) + } + var mapTitle: String { get { return container.titleLabel.stringValue } set(new) { container.titleLabel.stringValue = new } } - + + private static func describe(_ info: MapInfo, mapName: String) -> String { + var parts: [String] = [] + let primary = (info.name.isEmpty ? mapName : info.name) + if primary.lowercased() != mapName.lowercased() { + parts.append(primary) + } + if let planet = info.planet, !planet.isEmpty { parts.append(planet) } + if let schema = info.schema.first { + parts.append("\(schema.startPositions.count) players") + } + parts.append("wind \(info.windSpeed.lowerBound)-\(info.windSpeed.upperBound)") + parts.append("tidal \(info.tidalStrength)") + parts.append("gravity \(info.gravity)") + return parts.joined(separator: " · ") + } + private var container: ContainerView { return view as! ContainerView } - private class ContainerView: NSView { - + fileprivate class ContainerView: NSView { + unowned let titleLabel: NSTextField + unowned let detailLabel: NSTextField + unowned let overlayControl: NSSegmentedControl + unowned let slopeSlider: NSSlider + unowned let slopeLabel: NSTextField let emptyContentView: NSView - + weak var contentView: NSView? { didSet { guard contentView != oldValue else { return } @@ -195,48 +265,111 @@ class MapDetailViewController: NSViewController { } } } - + + func setSlopeControlVisible(_ visible: Bool) { + slopeSlider.isHidden = !visible + slopeLabel.isHidden = !visible + } + + func updateSlopeLabel(_ value: Int) { + slopeLabel.stringValue = "slope ≤ \(value)" + } + override init(frame frameRect: NSRect) { - let titleLabel = NSTextField(labelWithString: "Title") - titleLabel.font = NSFont.systemFont(ofSize: 18) + let titleLabel = NSTextField(labelWithString: "") + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) titleLabel.textColor = NSColor.labelColor + titleLabel.lineBreakMode = .byTruncatingMiddle + + let detailLabel = NSTextField(labelWithString: "") + detailLabel.font = NSFont.systemFont(ofSize: 11) + detailLabel.textColor = NSColor.secondaryLabelColor + detailLabel.lineBreakMode = .byTruncatingTail + + let overlayControl = NSSegmentedControl(labels: MapOverlayMode.allCases.map { $0.title }, + trackingMode: .selectOne, + target: nil, + action: nil) + overlayControl.selectedSegment = 0 + overlayControl.controlSize = .small + overlayControl.segmentStyle = .rounded + + let slopeSlider = NSSlider(value: 20, minValue: 1, maxValue: 120, target: nil, action: nil) + slopeSlider.controlSize = .small + slopeSlider.isContinuous = true + slopeSlider.isHidden = true + + let slopeLabel = NSTextField(labelWithString: "slope ≤ 20") + slopeLabel.font = NSFont.systemFont(ofSize: 11) + slopeLabel.textColor = NSColor.secondaryLabelColor + slopeLabel.isHidden = true + let contentBox = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: 32)) - + self.titleLabel = titleLabel + self.detailLabel = detailLabel + self.overlayControl = overlayControl + self.slopeSlider = slopeSlider + self.slopeLabel = slopeLabel self.emptyContentView = contentBox super.init(frame: frameRect) - + addSubview(contentBox) addSubview(titleLabel) - + addSubview(detailLabel) + addSubview(overlayControl) + addSubview(slopeSlider) + addSubview(slopeLabel) + contentBox.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false - + detailLabel.translatesAutoresizingMaskIntoConstraints = false + overlayControl.translatesAutoresizingMaskIntoConstraints = false + slopeSlider.translatesAutoresizingMaskIntoConstraints = false + slopeLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), + titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), + titleLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 6), + detailLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 12), + detailLabel.trailingAnchor.constraint(lessThanOrEqualTo: overlayControl.leadingAnchor, constant: -12), + detailLabel.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor), + + overlayControl.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + overlayControl.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + + slopeLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), + slopeLabel.trailingAnchor.constraint(equalTo: overlayControl.trailingAnchor), + slopeSlider.centerYAnchor.constraint(equalTo: slopeLabel.centerYAnchor), + slopeSlider.trailingAnchor.constraint(equalTo: slopeLabel.leadingAnchor, constant: -6), + slopeSlider.widthAnchor.constraint(equalToConstant: 120), ]) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func addContentViewConstraints(_ contentBox: NSView) { NSLayoutConstraint.activate([ - contentBox.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), - contentBox.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), - contentBox.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - contentBox.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.61803398875), - titleLabel.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 8), + contentBox.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4), + contentBox.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4), + contentBox.topAnchor.constraint(equalTo: overlayControl.bottomAnchor, constant: 6), + contentBox.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -4), ]) } - + } - + override func loadView() { let container = ContainerView(frame: NSRect(x: 0, y: 0, width: 256, height: 256)) self.view = container - + + container.overlayControl.target = self + container.overlayControl.action = #selector(overlayModeChanged(_:)) + container.slopeSlider.target = self + container.slopeSlider.action = #selector(slopeThresholdChanged(_:)) + addChild(mapView) container.contentView = mapView.view } diff --git a/TAassets/TAassets/MapView+Metal.swift b/TAassets/TAassets/MapView+Metal.swift index 3d63e04..63b5306 100644 --- a/TAassets/TAassets/MapView+Metal.swift +++ b/TAassets/TAassets/MapView+Metal.swift @@ -13,7 +13,9 @@ import SwiftTA_Core class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { private(set) var viewState = MetalTntViewState() - + private(set) var mapInfo: MapInfo? + private(set) var mapResolution: Size2 = .zero + private let library: MTLLibrary private let commandQueue: MTLCommandQueue private var tntRenderer: MetalTntRenderer? @@ -21,7 +23,7 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { private unowned let metalView: MTKView private unowned let scrollView: NSScrollView - private unowned let emptyView: NSView + private unowned let emptyView: MapOverlayView required init?(tntViewFrame frameRect: CGRect) { // self.stateProvider = stateProvider @@ -46,8 +48,7 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { scrollView.borderType = .noBorder scrollView.autoresizingMask = [.width, .height] - let emptyView = Dummy(frame: frameRect) - emptyView.alphaValue = 0 + let emptyView = MapOverlayView(frame: frameRect) self.library = library self.commandQueue = metalCommandQueue @@ -76,13 +77,22 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { } func load(_ mapName: String, from filesystem: FileSystem) throws { - + let beginMap = Date() - + let beginOta = Date() - guard let otaFile = filesystem.root[filePath: "maps/" + mapName + ".ota"] - else { throw FileSystem.Directory.ResolveError.notFound } + let otaPath = "maps/" + mapName + ".ota" + let otaFile: FileSystem.File + if let f = filesystem.root[filePath: otaPath] { + otaFile = f + } else if let f = filesystem.root[directory: "maps"]?.allFiles(withExtension: "ota").first(where: { $0.baseName.lowercased() == mapName.lowercased() }) { + otaFile = f + } else { + throw FileSystem.Directory.ResolveError.notFound + } let info = try MapInfo(contentsOf: otaFile, in: filesystem) + self.mapInfo = info + emptyView.startPositions = info.schema.first?.startPositions ?? [] let endOta = Date() let tileCountString: String @@ -102,7 +112,11 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { let endTnt = Date() let beginFeatures = Date() - try? featureRenderer.loadFeatures(containedIn: map, startingWith: info.planet, from: filesystem) + do { + try featureRenderer.loadFeatures(containedIn: map, startingWith: info.planet, from: filesystem) + } catch { + Swift.print("Warning: feature loading failed for \(mapName): \(error). Maps often need TA_Features_2013.ccx (or equivalent feature pack) present alongside the base archives.") + } let endFeatures = Date() let endMap = Date() @@ -120,7 +134,7 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { func load(_ map: TaMapModel, using palette: Palette) { guard let device = metalView.device else { return } - + let renderer: MetalTntRenderer if map.resolution.max() > device.maximum2dTextureSize { print("Using tiled tnt renderer") @@ -130,48 +144,76 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { print("Using simple tnt renderer") renderer = SingleTextureMetalTntViewRenderer(device) } - + try? renderer.load(map, using: palette) + mapResolution = map.resolution emptyView.frame = NSRect(size: map.resolution) - - scrollView.magnification = 1.0 + configureOverlay(heightMap: map.heightMap, featureMap: map.featureMap, seaLevel: map.seaLevel, mapSize: map.mapSize) + emptyView.needsDisplay = true + + scrollView.magnification = zoomToFit(resolution: map.resolution) scrollView.contentView.scroll(to: .zero) - + DispatchQueue.main.async { self.scrollView.flashScrollers() } - + try? renderer.configure(for: MetalHost(view: metalView, device: device, library: library)) self.tntRenderer = renderer } - + func load(_ map: TakMapModel, from filesystem: FileSystem) { - // let contentView = TakMapTileView(frame: NSRect(size: map.resolution)) - // contentView.load(map, filesystem) - // contentView.drawFeatures = drawFeatures - // scrollView.documentView = contentView - guard let device = metalView.device else { return } let renderer = DynamicTileMetalTntViewRenderer(device) - + try? renderer.load(map, from: filesystem) + mapResolution = map.resolution emptyView.frame = NSRect(size: map.resolution) - - scrollView.magnification = 1.0 + configureOverlay(heightMap: map.heightMap, featureMap: map.featureMap, seaLevel: map.seaLevel, mapSize: map.mapSize) + emptyView.needsDisplay = true + + scrollView.magnification = zoomToFit(resolution: map.resolution) scrollView.contentView.scroll(to: .zero) - + DispatchQueue.main.async { self.scrollView.flashScrollers() } - + try? renderer.configure(for: MetalHost(view: metalView, device: device, library: library)) self.tntRenderer = renderer } - + + private func configureOverlay(heightMap: HeightMap, featureMap: [Int?], seaLevel: Int, mapSize: Size2) { + emptyView.heightMap = heightMap + emptyView.featureMap = featureMap + emptyView.seaLevel = seaLevel + emptyView.mapSize = mapSize + } + + func setOverlayMode(_ mode: MapOverlayMode) { + emptyView.overlayMode = mode + } + + func setSlopeThreshold(_ threshold: Int) { + emptyView.slopeThreshold = max(1, threshold) + } + + private func zoomToFit(resolution: Size2) -> CGFloat { + let viewport = scrollView.contentView.bounds.size + guard viewport.width > 0, viewport.height > 0, resolution.width > 0, resolution.height > 0 else { return 1.0 } + let sx = viewport.width / CGFloat(resolution.width) + let sy = viewport.height / CGFloat(resolution.height) + return max(0.1, min(1.0, min(sx, sy))) + } + func clear() { - // drawFeatures = nil - // scrollView.documentView = nil tntRenderer = nil + mapInfo = nil + emptyView.startPositions = [] + emptyView.heightMap = nil + emptyView.featureMap = [] + emptyView.seaLevel = 0 + emptyView.mapSize = .zero } func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { @@ -179,10 +221,14 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { } func draw(in view: MTKView) { - + + // Always refresh the viewport from the current clip view so scrolling stays + // in sync even if a bounds-changed notification is missed across map swaps. + viewState.viewport = Rect4f(scrollView.contentView.bounds) + guard let commandBuffer = commandQueue.makeCommandBuffer() else { return } defer { commandBuffer.commit() } - + tntRenderer?.setupNextFrame(viewState, commandBuffer) featureRenderer.setupNextFrame(viewState) @@ -218,12 +264,154 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { } } - private class Dummy: NSView { - override var isFlipped: Bool { - return true +} + +enum MapOverlayMode: Int, CaseIterable { + case none = 0 + case heights + case passability + + var title: String { + switch self { + case .none: return "None" + case .heights: return "Heights" + case .passability: return "Passability" + } + } +} + +class MapOverlayView: NSView { + + override var isFlipped: Bool { true } + + var startPositions: [Point2] = [] { didSet { needsDisplay = true } } + var showMarkers: Bool = true { didSet { needsDisplay = true } } + + var overlayMode: MapOverlayMode = .none { didSet { if oldValue != overlayMode { needsDisplay = true } } } + /// Maximum per-neighbor elevation delta a ground unit can climb. Matches + /// the typical TA "medium KBOT" movement class; adjustable by the caller. + var slopeThreshold: Int = 20 { didSet { if oldValue != slopeThreshold && overlayMode == .passability { needsDisplay = true } } } + var heightMap: HeightMap? { didSet { if overlayMode != .none { needsDisplay = true } } } + var featureMap: [Int?] = [] + var seaLevel: Int = 0 + var mapSize: Size2 = .zero + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = false + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + + drawOverlay(in: dirtyRect, ctx: ctx) + + if showMarkers && !startPositions.isEmpty { + drawStartPositionMarkers(ctx: ctx) + } + } + + private func drawOverlay(in dirtyRect: NSRect, ctx: CGContext) { + guard overlayMode != .none, let heightMap = heightMap, mapSize.area > 0 else { return } + + let cellW = CGFloat(heightMap.sampleSize.width) + let cellH = CGFloat(heightMap.sampleSize.height) + + // Only iterate cells that overlap the dirty rect. + let colStart = max(0, Int(floor(dirtyRect.minX / cellW))) + let colEnd = min(mapSize.width - 1, Int(floor(dirtyRect.maxX / cellW))) + let rowStart = max(0, Int(floor(dirtyRect.minY / cellH))) + let rowEnd = min(mapSize.height - 1, Int(floor(dirtyRect.maxY / cellH))) + guard colStart <= colEnd && rowStart <= rowEnd else { return } + + let samples = heightMap.samples + let stride = mapSize.width + + for row in rowStart...rowEnd { + for col in colStart...colEnd { + let index = row * stride + col + let elevation = samples[index] + let color: CGColor + + switch overlayMode { + case .none: + continue + case .heights: + color = colorForElevation(elevation) + case .passability: + color = colorForPassability(index: index, col: col, row: row, elevation: elevation, samples: samples, stride: stride) + } + + ctx.setFillColor(color) + ctx.fill(CGRect(x: CGFloat(col) * cellW, y: CGFloat(row) * cellH, width: cellW, height: cellH)) + } + } + } + + private func colorForElevation(_ elevation: Int) -> CGColor { + // Deep water → shallow water → coast → grass → rock → snow. + let e = max(0, min(255, elevation)) + let relativeToSea = e - seaLevel + if relativeToSea < 0 { + let depth = CGFloat(min(64, -relativeToSea)) / 64.0 + return NSColor(calibratedRed: 0.0, green: 0.15 + 0.25 * (1 - depth), blue: 0.45 + 0.35 * (1 - depth), alpha: 0.55).cgColor + } + let t = CGFloat(min(255, relativeToSea)) / 255.0 + let r = 0.25 + 0.7 * t + let g = 0.55 - 0.25 * t + 0.35 * max(0, t - 0.5) + let b = 0.15 + 0.65 * max(0, t - 0.75) / 0.25 + return NSColor(calibratedRed: r, green: g, blue: b, alpha: 0.50).cgColor + } + + private func colorForPassability(index: Int, col: Int, row: Int, elevation: Int, samples: [Int], stride: Int) -> CGColor { + if elevation < seaLevel { + return NSColor(calibratedRed: 0.1, green: 0.3, blue: 0.85, alpha: 0.55).cgColor + } + if index < featureMap.count, featureMap[index] != nil { + return NSColor(calibratedRed: 0.75, green: 0.55, blue: 0.0, alpha: 0.55).cgColor + } + var maxDelta = 0 + if col > 0 { maxDelta = max(maxDelta, abs(elevation - samples[index - 1])) } + if col < mapSize.width - 1 { maxDelta = max(maxDelta, abs(elevation - samples[index + 1])) } + if row > 0 { maxDelta = max(maxDelta, abs(elevation - samples[index - stride])) } + if row < mapSize.height - 1 { maxDelta = max(maxDelta, abs(elevation - samples[index + stride])) } + if maxDelta > slopeThreshold { + return NSColor(calibratedRed: 0.85, green: 0.10, blue: 0.10, alpha: 0.55).cgColor + } + let t = CGFloat(min(slopeThreshold, maxDelta)) / CGFloat(max(1, slopeThreshold)) + // Green → yellow as slope climbs within the passable band. + return NSColor(calibratedRed: 0.2 + 0.7 * t, green: 0.75 - 0.1 * t, blue: 0.2, alpha: 0.40).cgColor + } + + private func drawStartPositionMarkers(ctx: CGContext) { + let radius: CGFloat = 18 + let fillColor = NSColor(calibratedRed: 1.0, green: 0.75, blue: 0.15, alpha: 0.55).cgColor + let strokeColor = NSColor(calibratedRed: 0.25, green: 0.18, blue: 0.0, alpha: 0.95).cgColor + let labelAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.boldSystemFont(ofSize: 16), + .foregroundColor: NSColor.black, + ] + + for (index, pos) in startPositions.enumerated() { + let center = CGPoint(x: CGFloat(pos.x), y: CGFloat(pos.y)) + let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2) + + ctx.setFillColor(fillColor) + ctx.fillEllipse(in: rect) + ctx.setStrokeColor(strokeColor) + ctx.setLineWidth(3) + ctx.strokeEllipse(in: rect) + + let number = "\(index + 1)" + let size = (number as NSString).size(withAttributes: labelAttrs) + let origin = CGPoint(x: center.x - size.width / 2, y: center.y - size.height / 2) + (number as NSString).draw(at: origin, withAttributes: labelAttrs) } } - } private let maxBuffersInFlight = 3 diff --git a/TAassets/TAassets/MapView.swift b/TAassets/TAassets/MapView.swift index 44d5635..f65176a 100644 --- a/TAassets/TAassets/MapView.swift +++ b/TAassets/TAassets/MapView.swift @@ -31,14 +31,28 @@ class MapViewController: NSViewController { func load(_ mapName: String, from filesystem: FileSystem) throws { try mapView.load(mapName, from: filesystem) } - + func clear() { mapView.clear() } - + + func setOverlayMode(_ mode: MapOverlayMode) { + mapView.setOverlayMode(mode) + } + + func setSlopeThreshold(_ threshold: Int) { + mapView.setSlopeThreshold(threshold) + } } protocol MapViewLoader { func load(_ mapName: String, from filesystem: FileSystem) throws func clear() + func setOverlayMode(_ mode: MapOverlayMode) + func setSlopeThreshold(_ threshold: Int) +} + +extension MapViewLoader { + func setOverlayMode(_ mode: MapOverlayMode) {} + func setSlopeThreshold(_ threshold: Int) {} } diff --git a/TAassets/TAassets/PieceHierarchyView.swift b/TAassets/TAassets/PieceHierarchyView.swift new file mode 100644 index 0000000..e270c7d --- /dev/null +++ b/TAassets/TAassets/PieceHierarchyView.swift @@ -0,0 +1,174 @@ +// +// PieceHierarchyView.swift +// TAassets +// + +import AppKit +import SwiftTA_Core + +protocol PieceHierarchyViewDelegate: AnyObject { + func pieceHierarchyView(_ view: PieceHierarchyView, didSelectPieceAt index: UnitModel.Pieces.Index?) +} + +final class PieceHierarchyView: NSView { + + weak var selectionDelegate: PieceHierarchyViewDelegate? + + private let outline = NSOutlineView() + private let scrollView = NSScrollView() + private let nameColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + private let detailColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("detail")) + private let scriptsColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("scripts")) + private var nodes: [Node] = [] + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + nameColumn.title = "Piece" + nameColumn.minWidth = 120 + nameColumn.width = 200 + detailColumn.title = "Prims / Verts / Children" + detailColumn.minWidth = 140 + detailColumn.width = 160 + scriptsColumn.title = "Script Refs" + scriptsColumn.minWidth = 140 + scriptsColumn.width = 260 + outline.addTableColumn(nameColumn) + outline.addTableColumn(detailColumn) + outline.addTableColumn(scriptsColumn) + outline.outlineTableColumn = nameColumn + outline.rowSizeStyle = .small + outline.usesAlternatingRowBackgroundColors = true + outline.headerView = NSTableHeaderView() + outline.dataSource = self + outline.delegate = self + outline.autoresizesOutlineColumn = false + outline.allowsEmptySelection = true + outline.allowsMultipleSelection = false + + scrollView.documentView = outline + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.borderType = .bezelBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + func apply(model: UnitModel, script: UnitScript? = nil) { + let refsByScriptIndex = script?.pieceReferences() ?? [:] + var refsByModelIndex: [UnitModel.Pieces.Index: String] = [:] + if let script = script { + for (scriptIdx, refs) in refsByScriptIndex { + guard script.pieces.indices.contains(scriptIdx) else { continue } + let name = script.pieces[scriptIdx].lowercased() + guard let modelIdx = model.nameLookup[name] else { continue } + let byModule = Dictionary(grouping: refs, by: \.moduleName) + .map { moduleName, calls -> String in + let ops = Set(calls.map { String(describing: $0.opcode) }).sorted().joined(separator: ",") + return "\(moduleName)[\(ops)]" + } + .sorted() + refsByModelIndex[modelIdx] = byModule.joined(separator: " ") + } + } + let rootsToVisit: [UnitModel.Pieces.Index] = model.roots.isEmpty ? [model.root] : model.roots + nodes = rootsToVisit.map { Node(index: $0, model: model, refs: refsByModelIndex) } + outline.reloadData() + outline.expandItem(nil, expandChildren: true) + } + + func clear() { + nodes = [] + outline.reloadData() + } + + fileprivate final class Node { + let index: UnitModel.Pieces.Index + let name: String + let detail: String + let scripts: String + let children: [Node] + + init(index: UnitModel.Pieces.Index, model: UnitModel, refs: [UnitModel.Pieces.Index: String]) { + self.index = index + let piece = model.pieces[index] + self.name = piece.name.isEmpty ? "(unnamed)" : piece.name + let vertexCount = piece.primitives.reduce(0) { $0 + model.primitives[$1].indices.count } + self.detail = "\(piece.primitives.count) / \(vertexCount) / \(piece.children.count)" + self.scripts = refs[index] ?? "" + self.children = piece.children.map { Node(index: $0, model: model, refs: refs) } + } + } +} + +extension PieceHierarchyView: NSOutlineViewDataSource { + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if let node = item as? Node { return node.children.count } + return nodes.count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if let node = item as? Node { return node.children[index] } + return nodes[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + (item as? Node)?.children.isEmpty == false + } +} + +extension PieceHierarchyView: NSOutlineViewDelegate { + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? Node, let column = tableColumn else { return nil } + let identifier = NSUserInterfaceItemIdentifier("PieceCell.\(column.identifier.rawValue)") + let cell: NSTableCellView + if let existing = outlineView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView { + cell = existing + } else { + cell = NSTableCellView() + cell.identifier = identifier + let textField = NSTextField(labelWithString: "") + textField.translatesAutoresizingMaskIntoConstraints = false + textField.lineBreakMode = .byTruncatingTail + textField.font = NSFont.systemFont(ofSize: 11) + cell.addSubview(textField) + cell.textField = textField + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 2), + textField.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -2), + textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + } + let value: String + switch column { + case nameColumn: value = node.name + case detailColumn: value = node.detail + case scriptsColumn: value = node.scripts + default: value = "" + } + cell.textField?.stringValue = value + cell.textField?.toolTip = column === scriptsColumn && !node.scripts.isEmpty ? node.scripts : nil + return cell + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + let selected = outline.item(atRow: outline.selectedRow) as? Node + selectionDelegate?.pieceHierarchyView(self, didSelectPieceAt: selected?.index) + } +} diff --git a/TAassets/TAassets/PlaybackControlsView.swift b/TAassets/TAassets/PlaybackControlsView.swift new file mode 100644 index 0000000..52a6ce8 --- /dev/null +++ b/TAassets/TAassets/PlaybackControlsView.swift @@ -0,0 +1,147 @@ +// +// PlaybackControlsView.swift +// TAassets +// + +import AppKit + +protocol PlaybackControlsViewDelegate: AnyObject { + func playbackControls(_ view: PlaybackControlsView, didChangeSpeed speed: Float) + func playbackControlsDidRequestStep(_ view: PlaybackControlsView) + func playbackControls(_ view: PlaybackControlsView, didChooseScript name: String) +} + +final class PlaybackControlsView: NSView { + + weak var delegate: PlaybackControlsViewDelegate? + + private let playButton = NSButton(title: "Pause", target: nil, action: nil) + private let stepButton = NSButton(title: "Step", target: nil, action: nil) + private let speedSlider = NSSlider(value: 1.0, minValue: 0.0, maxValue: 2.0, + target: nil, action: nil) + private let speedLabel = NSTextField(labelWithString: "1.00x") + private let scriptPopup = NSPopUpButton(frame: .zero, pullsDown: true) + + private var lastNonZeroSpeed: Float = 1.0 + private var scriptFunctions: [String] = [] + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: 28) + } + + private func setup() { + playButton.bezelStyle = .rounded + playButton.controlSize = .small + playButton.target = self + playButton.action = #selector(togglePlayPause) + + stepButton.bezelStyle = .rounded + stepButton.controlSize = .small + stepButton.target = self + stepButton.action = #selector(stepTapped) + + speedSlider.target = self + speedSlider.action = #selector(speedChanged) + speedSlider.controlSize = .small + speedSlider.numberOfTickMarks = 5 + speedSlider.allowsTickMarkValuesOnly = false + + speedLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + speedLabel.alignment = .right + + scriptPopup.controlSize = .small + scriptPopup.pullsDown = true + scriptPopup.removeAllItems() + scriptPopup.addItem(withTitle: "Run script…") + + let stack = NSStackView(views: [playButton, stepButton, speedSlider, speedLabel, scriptPopup]) + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + speedSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 120), + speedLabel.widthAnchor.constraint(equalToConstant: 52), + scriptPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 140), + ]) + } + + func reset(scriptFunctions: [String]) { + self.scriptFunctions = scriptFunctions + scriptPopup.removeAllItems() + scriptPopup.addItem(withTitle: "Run script…") + for name in scriptFunctions { + let item = NSMenuItem(title: name, + action: #selector(scriptMenuChanged(_:)), + keyEquivalent: "") + item.target = self + scriptPopup.menu?.addItem(item) + } + speedSlider.floatValue = 1.0 + updateSpeedLabel(1.0) + lastNonZeroSpeed = 1.0 + playButton.title = "Pause" + } + + @objc private func togglePlayPause() { + if speedSlider.floatValue == 0 { + let restored = lastNonZeroSpeed > 0 ? lastNonZeroSpeed : 1.0 + speedSlider.floatValue = restored + updateSpeedLabel(restored) + playButton.title = "Pause" + delegate?.playbackControls(self, didChangeSpeed: restored) + } else { + lastNonZeroSpeed = speedSlider.floatValue + speedSlider.floatValue = 0 + updateSpeedLabel(0) + playButton.title = "Play" + delegate?.playbackControls(self, didChangeSpeed: 0) + } + } + + @objc private func stepTapped() { + if speedSlider.floatValue != 0 { + lastNonZeroSpeed = speedSlider.floatValue + speedSlider.floatValue = 0 + updateSpeedLabel(0) + playButton.title = "Play" + delegate?.playbackControls(self, didChangeSpeed: 0) + } + delegate?.playbackControlsDidRequestStep(self) + } + + @objc private func speedChanged() { + let v = speedSlider.floatValue + updateSpeedLabel(v) + if v != 0 { lastNonZeroSpeed = v } + playButton.title = (v == 0) ? "Play" : "Pause" + delegate?.playbackControls(self, didChangeSpeed: v) + } + + @objc private func scriptMenuChanged(_ sender: NSMenuItem) { + guard let index = scriptPopup.menu?.index(of: sender), index > 0 else { return } + let idx = index - 1 + guard scriptFunctions.indices.contains(idx) else { return } + delegate?.playbackControls(self, didChooseScript: scriptFunctions[idx]) + scriptPopup.selectItem(at: 0) + } + + private func updateSpeedLabel(_ value: Float) { + speedLabel.stringValue = String(format: "%.2fx", value) + } +} diff --git a/TAassets/TAassets/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index 80a8ab7..15eab49 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -13,30 +13,126 @@ class TaassetsDocument: NSDocument { var filesystem: FileSystem! var sides: [SideInfo] = [] + private(set) var baseURL: URL! + private(set) var currentModURL: URL? + + var availableMods: [URL] { + guard let baseURL = baseURL else { return [] } + let modsDir = baseURL.appendingPathComponent("mods", isDirectory: true) + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: modsDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + let allowed = Set(FileSystem.weightedArchiveExtensions) + return entries + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + .filter { dir in + ((try? fm.contentsOfDirectory(atPath: dir.path)) ?? []) + .contains { allowed.contains(($0 as NSString).pathExtension.lowercased()) } + } + .sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } + } override func makeWindowControllers() { - // Returns the Storyboard that contains your Document window. let storyboard = NSStoryboard(name: "Main", bundle: nil) let windowController = storyboard.instantiateController(withIdentifier: "Document Window Controller") as! NSWindowController let viewController = windowController.contentViewController as! TaassetsViewController viewController.shared = TaassetsSharedState(filesystem: filesystem, sides: sides) self.addWindowController(windowController) + + if let window = windowController.window { + window.minSize = NSSize(width: 900, height: 600) + // Restore last size/position when available; otherwise open at a + // reasonable default that actually fits the asset browsers. + let autosaveName = "TaassetsMainWindow" + window.setFrameAutosaveName(autosaveName) + if !window.setFrameUsingName(autosaveName), let screen = window.screen ?? NSScreen.main { + let visible = screen.visibleFrame + let target = NSSize( + width: min(1600, max(1100, visible.width * 0.7)), + height: min(1100, max(750, visible.height * 0.8)) + ) + let origin = NSPoint( + x: visible.midX - target.width / 2, + y: visible.midY - target.height / 2 + ) + window.setFrame(NSRect(origin: origin, size: target), display: true) + window.saveFrame(usingName: autosaveName) + } + } } - + override func read(from directoryURL: URL, ofType typeName: String) throws { - + let fm = FileManager.default var dirCheck: ObjCBool = false guard directoryURL.isFileURL, fm.fileExists(atPath: directoryURL.path, isDirectory: &dirCheck), dirCheck.boolValue else { throw NSError(domain: NSOSStatusErrorDomain, code: readErr, userInfo: nil) } - + + let parent = directoryURL.deletingLastPathComponent() + let parentName = parent.lastPathComponent.lowercased() + if (parentName == "mods" || parentName == "mod"), + TaassetsDocument.folderHasArchives(parent.deletingLastPathComponent()) { + let grandparent = parent.deletingLastPathComponent() + Swift.print("Detected mod folder \(directoryURL.lastPathComponent) under base \(grandparent.lastPathComponent); loading combined") + baseURL = grandparent + currentModURL = directoryURL + } else { + baseURL = directoryURL + currentModURL = nil + } + try loadFilesystem() + } + + private static func folderHasArchives(_ url: URL) -> Bool { + let allowed = Set(FileSystem.weightedArchiveExtensions) + let contents = (try? FileManager.default.contentsOfDirectory(atPath: url.path)) ?? [] + return contents.contains { allowed.contains(($0 as NSString).pathExtension.lowercased()) } + } + + private func loadFilesystem() throws { let begin = Date() - filesystem = try! FileSystem(mergingHpisIn: directoryURL) + filesystem = try FileSystem(mergingHpisIn: baseURL, modDirectory: currentModURL) let end = Date() - Swift.print("\(directoryURL.lastPathComponent) filesystem load time: \(end.timeIntervalSince(begin)) seconds") - - let sidedata = try filesystem.openFile(at: "gamedata/sidedata.tdf") - sides = try SideInfo.load(contentsOf: sidedata) + let label = currentModURL.map { "\(baseURL.lastPathComponent) + mod:\($0.lastPathComponent)" } ?? baseURL.lastPathComponent + Swift.print("\(label) filesystem load time: \(end.timeIntervalSince(begin)) seconds") + + if let sidedata = try? filesystem.openFile(at: "gamedata/sidedata.tdf"), + let loaded = try? SideInfo.load(contentsOf: sidedata) { + sides = loaded + } else { + sides = [] + Swift.print("No gamedata/sidedata.tdf found — palettes will fall back to PALETTE.PAL") + } + } + + @IBAction func activateMod(_ sender: NSMenuItem) { + let newMod = sender.representedObject as? URL + Swift.print(">>> activateMod invoked: \(newMod?.lastPathComponent ?? "base only")") + guard newMod != currentModURL else { + Swift.print(" same as current; skipping reload") + return + } + let previous = currentModURL + currentModURL = newMod + do { + try loadFilesystem() + var controllersUpdated = 0 + for wc in windowControllers { + if let vc = wc.contentViewController as? TaassetsViewController { + vc.shared = TaassetsSharedState(filesystem: filesystem, sides: sides) + vc.reloadCurrentContent() + controllersUpdated += 1 + } + } + Swift.print(" reloaded \(controllersUpdated) view controller(s)") + } catch { + Swift.print(" load failed: \(error) — reverting to \(previous?.lastPathComponent ?? "base")") + currentModURL = previous + NSAlert(error: error).runModal() + } } } @@ -92,7 +188,10 @@ class TaassetsViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - + + applySidebarIcons() + applySidebarSpacing() + // There will be nothing selected the first time this view appears. // Select a default in this case. if selectedButton == nil { @@ -100,27 +199,58 @@ class TaassetsViewController: NSViewController { didChangeSelection(unitsButton) } } + + private func applySidebarSpacing() { + if let stack = unitsButton.superview as? NSStackView { + stack.edgeInsets = NSEdgeInsets(top: 28, left: 0, bottom: 8, right: 0) + } + } + + private func applySidebarIcons() { + guard #available(macOS 11.0, *) else { return } + let entries: [(NSButton, String, String)] = [ + (unitsButton, "cube.fill", "Units"), + (weaponsButton, "scope", "Weapons"), + (mapsButton, "map.fill", "Maps"), + (filesButton, "folder.fill", "Files"), + ] + for (button, symbol, label) in entries { + if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) { + image.isTemplate = true + button.image = image + button.imagePosition = .imageAbove + button.imageScaling = .scaleProportionallyDown + } + } + } @IBAction func didChangeSelection(_ sender: NSButton) { - + // Disallow deselcetion (toggling). // A selected button can only be deselected by selecting something else. guard sender.state == .on, !(sender === selectedButton) else { sender.state = .on return } - + selectedButton?.state = .off selectedButton = sender showSelectedContent(for: sender) } + + func reloadCurrentContent() { + Swift.print(" reloadCurrentContent called; selectedButton=\(String(describing: selectedButton?.identifier?.rawValue))") + if let button = selectedButton { + showSelectedContent(for: button) + } + } func showSelectedContent(for button: NSButton) { switch button { case unitsButton: showSelectedContent(controller: UnitBrowserViewController()) case weaponsButton: - showSelectedContent(controller: EmptyContentViewController()) + showSelectedContent(controller: WeaponsBrowserViewController()) case mapsButton: showSelectedContent(controller: MapBrowserViewController()) case filesButton: diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index fac136a..8d78735 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -10,80 +10,153 @@ import Cocoa import SwiftTA_Core class UnitBrowserViewController: NSViewController, ContentViewController { - + var shared = TaassetsSharedState.empty + private var allUnits: [UnitInfo] = [] private var units: [UnitInfo] = [] private var textures = ModelTexturePack() - + private var searchTerm: String = "" + private var tableView: NSTableView! + private var searchField: NSSearchField! private var detailViewContainer: NSView! private let detailViewController = UnitDetailViewController() private var isShowingDetail = false - + static let picSize: CGFloat = 64 - + override func loadView() { let bounds = NSRect(x: 0, y: 0, width: 480, height: 480) let mainView = NSView(frame: bounds) - + let listWidth: CGFloat = 240 - - let scrollView = NSScrollView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height)) + let searchHeight: CGFloat = 28 + + let searchField = NSSearchField(frame: NSMakeRect(4, bounds.size.height - searchHeight - 2, listWidth - 8, searchHeight - 4)) + searchField.autoresizingMask = [.minYMargin] + searchField.placeholderString = "Filter units" + searchField.target = self + searchField.action = #selector(searchFieldChanged(_:)) + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = false + mainView.addSubview(searchField) + + let scrollView = NSScrollView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height - searchHeight)) scrollView.autoresizingMask = [.height] scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false - - let tableView = NSTableView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height)) + + let tableView = NSTableView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height - searchHeight)) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "name")) column.width = listWidth-2 tableView.addTableColumn(column) tableView.identifier = NSUserInterfaceItemIdentifier(rawValue: "units") tableView.headerView = nil tableView.rowHeight = UnitBrowserViewController.picSize - + scrollView.documentView = tableView - + tableView.dataSource = self tableView.delegate = self mainView.addSubview(scrollView) - + let detail = NSView(frame: NSMakeRect(listWidth, 0, bounds.size.width - listWidth, bounds.size.height)) detail.autoresizingMask = [.width, .height] mainView.addSubview(detail) - + self.view = mainView self.detailViewContainer = detail self.tableView = tableView + self.searchField = searchField + } + + @objc private func searchFieldChanged(_ sender: NSSearchField) { + searchTerm = sender.stringValue.trimmingCharacters(in: .whitespaces) + applyFilter() + } + + private func applyFilter() { + if searchTerm.isEmpty { + units = allUnits + } else { + let term = searchTerm.lowercased() + units = allUnits.filter { + $0.name.lowercased().contains(term) + || $0.title.lowercased().contains(term) + || $0.description.lowercased().contains(term) + || $0.object.lowercased().contains(term) + } + } + tableView.reloadData() } override func viewDidLoad() { let begin = Date() - let unitsDirectory = shared.filesystem.root[directory: "units"] ?? FileSystem.Directory() - let units = unitsDirectory.items - .compactMap { $0.asFile() } - .filter { $0.hasExtension("fbi") } + + let rootNames = shared.filesystem.root.items.map { $0.name }.sorted() + print("Filesystem root contains \(rootNames.count) entries: \(rootNames.prefix(40).joined(separator: ", "))\(rootNames.count > 40 ? "…" : "")") + + var perDir: [(String, Int)] = [] + for item in shared.filesystem.root.items { + if case .directory(let d) = item { + let count = d.allFiles(withExtension: "fbi").count + if count > 0 { perDir.append((d.name, count)) } + } + } + perDir.sort { $0.1 > $1.1 } + if !perDir.isEmpty { + let summary = perDir.map { "\($0.0)=\($0.1)" }.joined(separator: " ") + print("FBI counts per top-level dir: \(summary)") + } + + let fbiFiles = shared.filesystem.root.allFiles(withExtension: "fbi") + + var seenNames = Set() + let units = fbiFiles .sorted { FileSystem.sortNames($0.name, $1.name) } + .filter { seenNames.insert($0.baseName.lowercased()).inserted } .compactMap { try? shared.filesystem.openFile($0) } .compactMap { try? UnitInfo(contentsOf: $0) } + self.allUnits = units self.units = units let end = Date() - print("UnitInfo list load time: \(end.timeIntervalSince(begin)) seconds") - + print("UnitInfo list load time: \(end.timeIntervalSince(begin)) seconds; units found: \(units.count) (from \(fbiFiles.count) FBI files)") + textures = ModelTexturePack(loadFrom: shared.filesystem) } final func buildpic(for unitName: String) -> NSImage? { - if let file = try? shared.filesystem.openFile(at: "unitpics/" + unitName + ".PCX") { - return try? NSImage(pcxContentsOf: file) + let fs = shared.filesystem + + let pictureDirs = fs.root.items.compactMap { item -> FileSystem.Directory? in + guard case .directory(let d) = item else { return nil } + return d.name.lowercased().hasPrefix("unitpic") ? d : nil } - else if let file = try? shared.filesystem.openFile(at: "anims/buildpic/" + unitName + ".jpg") { - let data = file.readDataToEndOfFile() - return NSImage(data: data) + + for dir in pictureDirs { + if let file = dir[file: unitName + ".pcx"], + let handle = try? fs.openFile(file), + let image = try? NSImage(pcxContentsOf: handle) { + return image + } + for ext in ["bmp", "png", "jpg", "jpeg", "tga"] { + if let file = dir[file: unitName + "." + ext], + let handle = try? fs.openFile(file) { + let data = handle.readDataToEndOfFile() + if let image = NSImage(data: data) { return image } + } + } } - else { - return nil + + for ext in ["jpg", "jpeg", "png", "bmp"] { + if let file = try? fs.openFile(at: "anims/buildpic/" + unitName + "." + ext) { + let data = file.readDataToEndOfFile() + if let image = NSImage(data: data) { return image } + } } + + return nil } } @@ -126,7 +199,7 @@ extension UnitBrowserViewController: NSTableViewDelegate { if !isShowingDetail { let controller = detailViewController controller.view.frame = detailViewContainer.bounds - controller.view.autoresizingMask = [.width, .width] + controller.view.autoresizingMask = [.width, .height] addChild(controller) detailViewContainer.addSubview(controller.view) isShowingDetail = true @@ -228,26 +301,84 @@ extension UnitBrowserSharedState { } } -class UnitDetailViewController: NSViewController { - +class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, PlaybackControlsViewDelegate { + var shared = UnitBrowserSharedState.empty let unitView = UnitViewController() - + let pieceView = PieceHierarchyView(frame: .zero) + let playbackControls = PlaybackControlsView(frame: .zero) + + func pieceHierarchyView(_ view: PieceHierarchyView, didSelectPieceAt index: UnitModel.Pieces.Index?) { + unitView.setHighlightedPiece(index) + } + + func playbackControls(_ view: PlaybackControlsView, didChangeSpeed speed: Float) { + unitView.setPlaybackSpeed(speed) + } + func playbackControlsDidRequestStep(_ view: PlaybackControlsView) { + unitView.stepOnce() + } + func playbackControls(_ view: PlaybackControlsView, didChooseScript name: String) { + unitView.startScript(name) + } + func load(_ unit: UnitInfo) throws { - unitTitle = unit.object + unitTitle = unit.object.isEmpty ? unit.name : unit.object + container.detailLabel.stringValue = Self.describe(unit) let modelFile = try shared.filesystem.openFile(at: "objects3d/" + unit.object + ".3DO") let model = try UnitModel(contentsOf: modelFile) let scriptFile = try shared.filesystem.openFile(at: "scripts/" + unit.object + ".COB") let script = try UnitScript(contentsOf: scriptFile) let atlas = UnitTextureAtlas(for: model.textures, from: shared.textures) - let palette = try Palette.texturePalette(for: unit, in: shared.sides, from: shared.filesystem) + let palette = resolvePalette(for: unit) + + let pieceNames = model.pieces.enumerated().map { "[\($0.offset)]\($0.element.name)" }.joined(separator: " ") + print("Unit \(unit.object): \(model.pieces.count) pieces, \(model.primitives.count) primitives, \(script.modules.count) script modules") + print(" pieces: \(pieceNames)") + let moduleNames = script.modules.map { $0.name }.joined(separator: ", ") + print(" modules: \(moduleNames)") + var decompile = "" + script.decompile(writingTo: { decompile += $0 }) + let decompilePath = "/tmp/taassets-last-cob.txt" + try? decompile.write(toFile: decompilePath, atomically: true, encoding: .utf8) + print(" decompile: \(decompilePath)") + try unitView.load(unit, model, script, atlas, shared.filesystem, palette) - + pieceView.apply(model: model, script: script) + playbackControls.reset(scriptFunctions: unitView.availableScriptFunctions) + //try tempSaveAtlasToFile(atlas, palette) } - + func clear() { unitView.clear() + pieceView.clear() + playbackControls.reset(scriptFunctions: []) + container.titleLabel.stringValue = "" + container.detailLabel.stringValue = "" + } + + private static func describe(_ unit: UnitInfo) -> String { + var parts: [String] = [] + if !unit.title.isEmpty { parts.append(unit.title) } + if !unit.description.isEmpty { parts.append(unit.description) } + if !unit.side.isEmpty { parts.append(unit.side) } + if !unit.tedClass.isEmpty { parts.append(unit.tedClass) } + parts.append("footprint \(unit.footprint.width)×\(unit.footprint.height)") + if unit.maxVelocity > 0 { + parts.append(String(format: "speed %.1f", Double(unit.maxVelocity))) + } + return parts.joined(separator: " · ") + } + + private func resolvePalette(for unit: UnitInfo) -> Palette { + if let p = try? Palette.texturePalette(for: unit, in: shared.sides, from: shared.filesystem) { + return p + } + if let p = try? Palette.standardTaPalette(from: shared.filesystem) { + return p.applyingChromaKeys(Palette.textureTransparencies) + } + return Palette() } private func tempSaveAtlasToFile(_ atlas: UnitTextureAtlas, _ palette: Palette) throws { @@ -286,10 +417,12 @@ class UnitDetailViewController: NSViewController { } private class ContainerView: NSView { - + unowned let titleLabel: NSTextField + unowned let detailLabel: NSTextField let emptyContentView: NSView - + let pieceAccessory: NSView + weak var contentView: NSView? { didSet { guard contentView != oldValue else { return } @@ -306,51 +439,87 @@ class UnitDetailViewController: NSViewController { } } } - - override init(frame frameRect: NSRect) { - let titleLabel = NSTextField(labelWithString: "Title") - titleLabel.font = NSFont.systemFont(ofSize: 18) + + init(frame frameRect: NSRect, pieceAccessory: NSView) { + let titleLabel = NSTextField(labelWithString: "") + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) titleLabel.textColor = NSColor.labelColor + titleLabel.lineBreakMode = .byTruncatingMiddle + + let detailLabel = NSTextField(labelWithString: "") + detailLabel.font = NSFont.systemFont(ofSize: 11) + detailLabel.textColor = NSColor.secondaryLabelColor + detailLabel.lineBreakMode = .byTruncatingTail + let contentBox = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: 32)) - + self.titleLabel = titleLabel + self.detailLabel = detailLabel self.emptyContentView = contentBox + self.pieceAccessory = pieceAccessory super.init(frame: frameRect) - + addSubview(contentBox) addSubview(titleLabel) - + addSubview(detailLabel) + pieceAccessory.translatesAutoresizingMaskIntoConstraints = false + addSubview(pieceAccessory) + contentBox.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false - + detailLabel.translatesAutoresizingMaskIntoConstraints = false + addContentViewConstraints(contentBox) NSLayoutConstraint.activate([ - titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), - ]) + titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), + titleLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 6), + detailLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 12), + detailLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + detailLabel.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor), + pieceAccessory.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), + pieceAccessory.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + pieceAccessory.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8), + ]) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func addContentViewConstraints(_ contentBox: NSView) { NSLayoutConstraint.activate([ contentBox.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), contentBox.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), - contentBox.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - contentBox.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.61803398875), - titleLabel.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 8), + contentBox.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), + contentBox.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.55), + pieceAccessory.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 6), ]) } - + } - + override func loadView() { - let container = ContainerView(frame: NSRect(x: 0, y: 0, width: 256, height: 256)) + let stack = NSStackView(views: [playbackControls, pieceView]) + stack.orientation = .vertical + stack.alignment = .leading + stack.distribution = .fill + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + stack.setHuggingPriority(.required, for: .vertical) + pieceView.translatesAutoresizingMaskIntoConstraints = false + playbackControls.translatesAutoresizingMaskIntoConstraints = false + stack.arrangedSubviews.forEach { + $0.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + + let container = ContainerView(frame: NSRect(x: 0, y: 0, width: 256, height: 512), + pieceAccessory: stack) self.view = container - + addChild(unitView) container.contentView = unitView.view + pieceView.selectionDelegate = self + playbackControls.delegate = self } } diff --git a/TAassets/TAassets/UnitView.swift b/TAassets/TAassets/UnitView.swift index 9932005..3318cd0 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -13,10 +13,18 @@ class UnitViewController: NSViewController { private(set) var viewState = UnitViewState() private var unitView: UnitViewLoader! - + private var unit: UnitInstance? private var loadTime: Double = 0 private var shouldStartMoving = false + private var lastScriptHeartbeat: Double = 0 + /// Thread ID of the Create() invocation fired at load. While it's in the + /// script context we let scripts run normally so Create can do its IK + /// bisection and spawn helpers; once it finishes we kill every remaining + /// thread (PositionLegs/LegGroups/SmokeUnit etc.) so the unit sits in its + /// Create-time pose instead of running a forever-gait over an empty scene. + private var createThreadID: Int? = nil + private var didFreezeAfterCreate = false override func loadView() { let defaultFrame = NSRect(x: 0, y: 0, width: 640, height: 480) @@ -40,7 +48,13 @@ class UnitViewController: NSViewController { _ texture: UnitTextureAtlas, _ filesystem: FileSystem, _ palette: Palette) throws { - + viewState.zoom = 1.0 + viewState.rotateX = 0 + viewState.rotateY = 0 + viewState.rotateZ = 160 + viewState.highlightedPieceIndex = -1 + viewState.playbackSpeed = 1.0 + let newUnit = UnitInstance( info: info, model: model, @@ -52,7 +66,15 @@ class UnitViewController: NSViewController { loadTime = getTime() newUnit.scriptContext.startScript("Create") - shouldStartMoving = newUnit.info.maxVelocity > 0 + // Remember the ID of the Create thread so we can tell when it has + // returned (and then freeze the whole script context). + createThreadID = newUnit.scriptContext.threads.last?.id + didFreezeAfterCreate = false + // Don't auto-trigger StartMoving — the viewer has no world translation, + // so a walk cycle just spins legs in place and masks whether Create's + // IK landed the feet on the ground. User can fire StartMoving manually + // from the script-functions menu when they want to see the gait. + shouldStartMoving = false viewState.isMoving = false viewState.movement = 0 @@ -67,11 +89,56 @@ class UnitViewController: NSViewController { unit = nil viewState.model = nil viewState.modelInstance = nil + viewState.highlightedPieceIndex = -1 unitView.clear() } + + func setHighlightedPiece(_ index: UnitModel.Pieces.Index?) { + viewState.highlightedPieceIndex = index.map(Int32.init) ?? -1 + } + + var availableScriptFunctions: [String] { + unit?.script.modules.map { $0.name } ?? [] + } + + func setPlaybackSpeed(_ speed: Float) { + viewState.playbackSpeed = max(0, min(4, speed)) + } + + var playbackSpeed: Float { viewState.playbackSpeed } + + func startScript(_ name: String) { + guard var unit = unit else { + Swift.print("startScript(\(name)) skipped: no loaded unit") + return + } + let before = unit.scriptContext.threads.count + unit.scriptContext.startScript(name) + let after = unit.scriptContext.threads.count + Swift.print("startScript(\(name)): threads \(before) -> \(after), module found: \(unit.script.module(named: name) != nil), playbackSpeed=\(viewState.playbackSpeed)") + self.unit = unit + } + + func stepOnce(by duration: Double = 1.0 / 30.0) { + guard var unit = unit else { return } + unit.scriptContext.run(for: &unit.modelInstance, on: self) + unit.scriptContext.applyAnimations(to: &unit.modelInstance, for: GameFloat(duration)) + viewState.modelInstance = unit.modelInstance + self.unit = unit + } private func computeSceneSize() { - let w = GameFloat( ((unit?.info.footprint.width ?? 2) + 8) * ModelViewState.gridSize ) + let footprintWidth = GameFloat( ((unit?.info.footprint.width ?? 2) + 8) * ModelViewState.gridSize ) + let extent = viewState.model?.maxWorldExtent ?? 0 + // Model fits in a box of side 2·extent centered at the origin. Add 20% + // margin, then pick a scene width that also guarantees the scene height + // (= sceneWidth·aspectRatio) is big enough to hold the full box. Without + // the aspectRatio divisor a very wide window would crop tall mod units. + let modelDiameter = extent * 2.4 + let aspectRatio = max(GameFloat(0.1), viewState.aspectRatio) + let sceneWidthNeeded = max(modelDiameter, modelDiameter / aspectRatio) + let baseWidth = max(footprintWidth, sceneWidthNeeded) + let w = (baseWidth > 0 ? baseWidth : footprintWidth) / GameFloat(viewState.zoom) viewState.sceneSize = Size2f(width: w, height: w * viewState.aspectRatio) } @@ -102,7 +169,24 @@ extension UnitViewController: UnitViewStateProvider { else if event.modifierFlags.contains(.option) { viewState.rotateY += GLfloat(event.deltaX) } else { viewState.rotateZ += GLfloat(event.deltaX) } } - + + override func scrollWheel(with event: NSEvent) { + let delta = Float(event.scrollingDeltaY) + guard delta != 0 else { return } + let factor = exp(delta * 0.02) + let newZoom = max(0.1, min(32.0, viewState.zoom * factor)) + guard newZoom != viewState.zoom else { return } + viewState.zoom = newZoom + computeSceneSize() + } + + override func magnify(with event: NSEvent) { + let factor = Float(1.0 + event.magnification) + let newZoom = max(0.1, min(32.0, viewState.zoom * factor)) + viewState.zoom = newZoom + computeSceneSize() + } + override func keyDown(with event: NSEvent) { switch event.characters { case .some("w"): @@ -115,42 +199,111 @@ extension UnitViewController: UnitViewStateProvider { viewState.textured = !viewState.textured case .some("l"): viewState.lighted = !viewState.lighted + case .some("="), .some("+"): + viewState.zoom = min(32.0, viewState.zoom * 1.25) + computeSceneSize() + case .some("-"), .some("_"): + viewState.zoom = max(0.1, viewState.zoom / 1.25) + computeSceneSize() + case .some("0"): + viewState.zoom = 1.0 + viewState.rotateX = 0 + viewState.rotateY = 0 + viewState.rotateZ = 160 + computeSceneSize() + case .some("d"): + dumpPieceState() default: () } } + + private func dumpPieceState() { + guard let unit = unit else { return } + let model = unit.model + let instance = unit.modelInstance + func f(_ v: GameFloat) -> String { + return String(format: "%7.2f", Double(v)) + } + Swift.print("=== Piece State Dump for \(unit.info.name) ===") + Swift.print(" threads=\(unit.scriptContext.threads.count) anims=\(unit.scriptContext.animations.count) roots=\(model.roots.map { model.pieces[$0].name })") + for i in 0.. ") + let world = model.pieceWorldPosition(i, instance: instance) + let line = " [\(i)] \(p.name)" + + " off=(\(f(p.offset.x)),\(f(p.offset.y)),\(f(p.offset.z)))" + + " turn=(\(f(s.turn.x)),\(f(s.turn.y)),\(f(s.turn.z)))" + + " move=(\(f(s.move.x)),\(f(s.move.y)),\(f(s.move.z)))" + + " world=(\(f(world.x)),\(f(world.y)),\(f(world.z)))" + + " hidden=\(s.hidden ? "Y" : "N") parents=[\(parents)]" + Swift.print(line) + } + Swift.print("=== End ===") + } func updateAnimatingState(deltaTime: Double) { guard var unit = unit else { return } - + + let speed = viewState.playbackSpeed + if speed <= 0 { + viewState.modelInstance = unit.modelInstance + self.unit = unit + return + } + let scaledDelta = deltaTime * Double(speed) + + // Emit a per-second heartbeat so we can tell whether scripts are + // advancing at all. Logs thread count + queued animation count. + let now = Date.timeIntervalSinceReferenceDate + if now - lastScriptHeartbeat > 1.0 { + lastScriptHeartbeat = now + Swift.print("script heartbeat: threads=\(unit.scriptContext.threads.count), anims=\(unit.scriptContext.animations.count), playbackSpeed=\(speed)") + } + if shouldStartMoving && getTime() > loadTime + 1 { unit.scriptContext.startScript("StartMoving") shouldStartMoving = false viewState.isMoving = true viewState.speed = 0 } - - unit.scriptContext.run(for: unit.modelInstance, on: self) - unit.scriptContext.applyAnimations(to: &unit.modelInstance, for: GameFloat(deltaTime)) - + + unit.scriptContext.run(for: &unit.modelInstance, on: self) + unit.scriptContext.applyAnimations(to: &unit.modelInstance, for: GameFloat(scaledDelta)) + + // Once Create returns, kill every background thread it spawned so the + // unit holds its IK pose instead of running PositionLegs/LegGroups + // forever. User-triggered scripts (via the script-functions menu) still + // run because they create new threads after this freeze point. + if !didFreezeAfterCreate, let createID = createThreadID { + if !unit.scriptContext.threads.contains(where: { $0.id == createID }) { + let dropped = unit.scriptContext.threads.count + unit.scriptContext.threads.removeAll() + unit.scriptContext.animations.removeAll() + didFreezeAfterCreate = true + Swift.print("Create finished; froze \(dropped) background thread(s)") + } + } + if viewState.isMoving { - let dt = GameFloat(deltaTime * 10) + let dt = GameFloat(scaledDelta * 10) let acceleration = unit.info.acceleration let maxSpeed = unit.info.maxVelocity - var speed = viewState.speed - - if speed < maxSpeed { - speed = min(speed + dt * acceleration, maxSpeed) + var currentSpeed = viewState.speed + + if currentSpeed < maxSpeed { + currentSpeed = min(currentSpeed + dt * acceleration, maxSpeed) } - viewState.movement += dt * speed - viewState.speed = speed - + viewState.movement += dt * currentSpeed + viewState.speed = currentSpeed + let gridSize = GameFloat(UnitViewState.gridSize) if viewState.movement > gridSize { viewState.movement -= gridSize } } - + viewState.modelInstance = unit.modelInstance self.unit = unit } @@ -183,7 +336,11 @@ struct UnitViewState { var model: UnitModel? var modelInstance: UnitModel.Instance? - + + var zoom: Float = 1.0 + var highlightedPieceIndex: Int32 = -1 + var playbackSpeed: Float = 1.0 + var isMoving = false var speed: GameFloat = 0 var movement: GameFloat = 0 diff --git a/TAassets/TAassets/UnitViewRenderer+Metal.swift b/TAassets/TAassets/UnitViewRenderer+Metal.swift index db87fec..c7f232c 100644 --- a/TAassets/TAassets/UnitViewRenderer+Metal.swift +++ b/TAassets/TAassets/UnitViewRenderer+Metal.swift @@ -12,7 +12,16 @@ import simd import SwiftTA_Core class BasicMetalUnitViewRenderer { - + + // Keep in sync with `pieces[]` in UnitViewRenderer+MetalShaderTypes.h. + fileprivate static let maxPieceMatrices = 128 + + private static var overflowWarnedCounts = Set() + fileprivate static func logPieceOverflowOnce(requested: Int, capacity: Int) { + guard overflowWarnedCounts.insert(requested).inserted else { return } + Swift.print("Warning: unit has \(requested) pieces but the Metal uniform only fits \(capacity); extra pieces will fall back to the first matrix and may render in the wrong spot.") + } + let device: MTLDevice private let commandQueue: MTLCommandQueue private let uniformBuffer: MTLBuffer @@ -61,7 +70,12 @@ extension BasicMetalUnitViewRenderer: MetalUnitViewRenderer { let modelMatrix = matrix_float4x4.identity let projection = matrix_float4x4.ortho(0, viewState.sceneSize.width, viewState.sceneSize.height, 0, -1024, 256) let sceneCentering = matrix_float4x4.translation(viewState.sceneSize.width / 2, viewState.sceneSize.height / 2, 0) - let sceneView = matrix_float4x4.rotate(sceneCentering * matrix_float4x4.taPerspective, radians: -viewState.rotateZ * (Float.pi / 180.0), axis: vector_float3(0, 0, 1)) + let perspective = matrix_float4x4.rotate(matrix_float4x4.taPerspective, + radians: viewState.rotateX * (Float.pi / 180.0), + axis: vector_float3(1, 0, 0)) + let sceneView = matrix_float4x4.rotate(sceneCentering * perspective, + radians: -viewState.rotateZ * (Float.pi / 180.0), + axis: vector_float3(0, 0, 1)) let gridView = matrix_float4x4.translate(sceneView, Float(-grid.size.width / 2), Float(-grid.size.height / 2), 0) let normal = matrix_float3x3(topLeftOf: sceneView).inverse.transpose @@ -73,6 +87,7 @@ extension BasicMetalUnitViewRenderer: MetalUnitViewRenderer { uniforms.pointee.lightPosition = vector_float3(50, 50, 100) uniforms.pointee.viewPosition = vector_float3(viewState.sceneSize.width / 2, viewState.sceneSize.height / 2, 0) + uniforms.pointee.highlightedPieceIndex = Int32(viewState.highlightedPieceIndex) switch (viewState.drawMode, viewState.textured) { case (.solid, true), (.outlined, true), (.wireframe, _): uniforms.pointee.objectColor = vector_float4.zero @@ -82,7 +97,11 @@ extension BasicMetalUnitViewRenderer: MetalUnitViewRenderer { if let model = model { let pieceMats = uniformBuffer.contents() + (modelUniformOffset + (MemoryLayout.offset(of: \UnitMetalRenderer_ModelUniforms.pieces) ?? 0)) - let count = model.transformations.count + let capacity = BasicMetalUnitViewRenderer.maxPieceMatrices + let count = min(model.transformations.count, capacity) + if model.transformations.count > capacity { + BasicMetalUnitViewRenderer.logPieceOverflowOnce(requested: model.transformations.count, capacity: capacity) + } model.transformations.withUnsafeBytes() { pieceMats.copyMemory(from: $0.baseAddress!, byteCount: MemoryLayout.stride * count) } @@ -293,9 +312,14 @@ private class MetalModel { buffer.label = "UnitModel" var p = UnsafeMutableRawPointer(buffer.contents()).bindMemory(to: UnitMetalRenderer_ModelVertex.self, capacity: vertexCount) - MetalModel.collectVertexAttributes(pieceIndex: model.root, model: model, textures: textures, vertexBuffer: &p) + let rootsToVisit: [UnitModel.Pieces.Index] = model.roots.isEmpty ? [model.root] : model.roots + for rootIndex in rootsToVisit { + MetalModel.collectVertexAttributes(pieceIndex: rootIndex, model: model, textures: textures, vertexBuffer: &p) + } p = (UnsafeMutableRawPointer(buffer.contents()) + vertexSize).bindMemory(to: UnitMetalRenderer_ModelVertex.self, capacity: outlineCount) - MetalModel.collectOutlineVertexAttributes(pieceIndex: model.root, model: model, vertexBuffer: &p) + for rootIndex in rootsToVisit { + MetalModel.collectOutlineVertexAttributes(pieceIndex: rootIndex, model: model, vertexBuffer: &p) + } self.buffer = buffer self.vertexCount = vertexCount @@ -477,7 +501,10 @@ private extension MetalModel { } static func applyPieceTransformations(model: UnitModel, instance: UnitModel.Instance, transformations: inout [matrix_float4x4]) { - applyPieceTransformations(pieceIndex: model.root, p: matrix_float4x4.identity, model: model, instance: instance, transformations: &transformations) + let rootsToVisit: [UnitModel.Pieces.Index] = model.roots.isEmpty ? [model.root] : model.roots + for rootIndex in rootsToVisit { + applyPieceTransformations(pieceIndex: rootIndex, p: matrix_float4x4.identity, model: model, instance: instance, transformations: &transformations) + } } static func applyPieceTransformations(pieceIndex: UnitModel.Pieces.Index, p: matrix_float4x4, model: UnitModel, instance: UnitModel.Instance, transformations: inout [matrix_float4x4]) { @@ -496,25 +523,27 @@ private extension MetalModel { let sin = vector_float3( anims.turn.map { Darwin.sin($0 * rad2deg) } ) let cos = vector_float3( anims.turn.map { Darwin.cos($0 * rad2deg) } ) + // R = R_SIMDz(turn.y) · R_SIMDy(turn.z) · R_SIMDx(turn.x). See + // UnitModel+Bounds.pieceLocalTransform for why yaw is outermost. let t = matrix_float4x4(columns: ( vector_float4( cos.y * cos.z, - (sin.y * cos.x) + (sin.x * cos.y * sin.z), - (sin.x * sin.y) - (cos.x * cos.y * sin.z), + sin.y * cos.z, + -sin.z, 0), - + vector_float4( - -sin.y * cos.z, - (cos.x * cos.y) - (sin.x * sin.y * sin.z), - (sin.x * cos.y) + (cos.x * sin.y * sin.z), + (cos.y * sin.z * sin.x) - (sin.y * cos.x), + (sin.y * sin.z * sin.x) + (cos.y * cos.x), + cos.z * sin.x, 0), - + vector_float4( - sin.z, - -sin.x * cos.z, - cos.x * cos.z, + (cos.y * sin.z * cos.x) + (sin.y * sin.x), + (sin.y * sin.z * cos.x) - (cos.y * sin.x), + cos.z * cos.x, 0), - + vector_float4( offset.x - move.x, offset.y - move.z, diff --git a/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h b/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h index d4cd43f..31da079 100644 --- a/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h +++ b/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h @@ -67,7 +67,8 @@ typedef struct vector_float4 objectColor; vector_float3 lightPosition; vector_float3 viewPosition; - matrix_float4x4 pieces[40]; + matrix_float4x4 pieces[128]; + int highlightedPieceIndex; } UnitMetalRenderer_ModelUniforms; #pragma pack(pop) diff --git a/TAassets/TAassets/UnitViewRenderer+MetalShaders.metal b/TAassets/TAassets/UnitViewRenderer+MetalShaders.metal index 778f666..bec5ded 100644 --- a/TAassets/TAassets/UnitViewRenderer+MetalShaders.metal +++ b/TAassets/TAassets/UnitViewRenderer+MetalShaders.metal @@ -24,6 +24,7 @@ typedef struct float3 positionM; float3 normal; float2 texCoord; + int pieceIndex [[flat]]; } FragmentIn; vertex FragmentIn unitVertexShader(UnitMetalRenderer_ModelVertex in [[stage_in]], @@ -36,6 +37,7 @@ vertex FragmentIn unitVertexShader(UnitMetalRenderer_ModelVertex in [[stage_in]] out.positionM = float3(position); out.normal = uniforms.normalMatrix * in.normal; out.texCoord = in.texCoord; + out.pieceIndex = in.pieceIndex; return out; } @@ -79,6 +81,9 @@ fragment float4 unitFragmentShader(FragmentIn in [[stage_in]], else { out_color = lightContribution * uniforms.objectColor; } + if (uniforms.highlightedPieceIndex >= 0 && in.pieceIndex == uniforms.highlightedPieceIndex) { + out_color.rgb = mix(out_color.rgb, float3(1.0, 0.85, 0.15), 0.55); + } return out_color; } @@ -90,7 +95,7 @@ fragment float4 unitUnlitFragmentShader(FragmentIn in [[stage_in]], mag_filter::nearest, min_filter::nearest); half4 colorSample = colorMap.sample(colorSampler, in.texCoord.xy); - + float4 out_color; if (uniforms.objectColor.a == 0.0) { out_color = float4(colorSample); @@ -98,5 +103,8 @@ fragment float4 unitUnlitFragmentShader(FragmentIn in [[stage_in]], else { out_color = uniforms.objectColor; } + if (uniforms.highlightedPieceIndex >= 0 && in.pieceIndex == uniforms.highlightedPieceIndex) { + out_color.rgb = mix(out_color.rgb, float3(1.0, 0.85, 0.15), 0.55); + } return out_color; } diff --git a/TAassets/TAassets/UnitViewRenderer+OpenglCore33.swift b/TAassets/TAassets/UnitViewRenderer+OpenglCore33.swift index d437a95..06a5d9e 100644 --- a/TAassets/TAassets/UnitViewRenderer+OpenglCore33.swift +++ b/TAassets/TAassets/UnitViewRenderer+OpenglCore33.swift @@ -601,22 +601,23 @@ private class GLBufferedModel { let sin = Vector3f( anims.turn.map { Darwin.sin($0 * rad2deg) } ) let cos = Vector3f( anims.turn.map { Darwin.cos($0 * rad2deg) } ) + // Yaw-outermost rotation order — see UnitModel+Bounds.pieceLocalTransform. let t = Matrix4x4f( cos.y * cos.z, - (sin.y * cos.x) + (sin.x * cos.y * sin.z), - (sin.x * sin.y) - (cos.x * cos.y * sin.z), + sin.y * cos.z, + -sin.z, 0, - - -sin.y * cos.z, - (cos.x * cos.y) - (sin.x * sin.y * sin.z), - (sin.x * cos.y) + (cos.x * sin.y * sin.z), + + (cos.y * sin.z * sin.x) - (sin.y * cos.x), + (sin.y * sin.z * sin.x) + (cos.y * cos.x), + cos.z * sin.x, 0, - - sin.z, - -sin.x * cos.z, - cos.x * cos.z, + + (cos.y * sin.z * cos.x) + (sin.y * sin.x), + (sin.y * sin.z * cos.x) - (cos.y * sin.x), + cos.z * cos.x, 0, - + offset.x - move.x, offset.y - move.z, offset.z + move.y, diff --git a/TAassets/TAassets/UnitViewRenderer+OpenglLegacy.swift b/TAassets/TAassets/UnitViewRenderer+OpenglLegacy.swift index 2dafd9f..de85f44 100644 --- a/TAassets/TAassets/UnitViewRenderer+OpenglLegacy.swift +++ b/TAassets/TAassets/UnitViewRenderer+OpenglLegacy.swift @@ -391,17 +391,18 @@ private func makeTransform(from piece: UnitModel.PieceState, with offset: Vector M[13] = offset.y - piece.move.z M[14] = offset.z + piece.move.y + // Yaw-outermost rotation order — see UnitModel+Bounds.pieceLocalTransform. M[0] = cos.y * cos.z - M[1] = (sin.y * cos.x) + (sin.x * cos.y * sin.z) - M[2] = (sin.x * sin.y) - (cos.x * cos.y * sin.z) - - M[4] = -sin.y * cos.z - M[5] = (cos.x * cos.y) - (sin.x * sin.y * sin.z) - M[6] = (sin.x * cos.y) + (cos.x * sin.y * sin.z) - - M[8] = sin.z - M[9] = -sin.x * cos.z - M[10] = cos.x * cos.z + M[1] = sin.y * cos.z + M[2] = -sin.z + + M[4] = (cos.y * sin.z * sin.x) - (sin.y * cos.x) + M[5] = (sin.y * sin.z * sin.x) + (cos.y * cos.x) + M[6] = cos.z * sin.x + + M[8] = (cos.y * sin.z * cos.x) + (sin.y * sin.x) + M[9] = (sin.y * sin.z * cos.x) - (cos.y * sin.x) + M[10] = cos.z * cos.x return M } diff --git a/TAassets/TAassets/WeaponsBrowser.swift b/TAassets/TAassets/WeaponsBrowser.swift new file mode 100644 index 0000000..b06ab24 --- /dev/null +++ b/TAassets/TAassets/WeaponsBrowser.swift @@ -0,0 +1,266 @@ +// +// WeaponsBrowser.swift +// TAassets +// + +import Cocoa +import SwiftTA_Core + +struct WeaponInfo { + let key: String + let sourceFile: String + let name: String + let weaponType: String + let range: Int + let damage: [String: Int] + let properties: [String: String] +} + +class WeaponsBrowserViewController: NSViewController, ContentViewController { + + var shared = TaassetsSharedState.empty + private var allWeapons: [WeaponInfo] = [] + private var weapons: [WeaponInfo] = [] + private var searchTerm: String = "" + + private var tableView: NSTableView! + private var searchField: NSSearchField! + private var detailText: NSTextView! + + override func loadView() { + let bounds = NSRect(x: 0, y: 0, width: 720, height: 480) + let mainView = NSView(frame: bounds) + + let listWidth: CGFloat = 280 + let searchHeight: CGFloat = 28 + + let searchField = NSSearchField(frame: NSMakeRect(4, bounds.size.height - searchHeight - 2, listWidth - 8, searchHeight - 4)) + searchField.autoresizingMask = [.minYMargin] + searchField.placeholderString = "Filter weapons" + searchField.target = self + searchField.action = #selector(searchFieldChanged(_:)) + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = false + mainView.addSubview(searchField) + + let scrollView = NSScrollView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height - searchHeight)) + scrollView.autoresizingMask = [.height] + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + + let tableView = NSTableView(frame: NSMakeRect(0, 0, listWidth, bounds.size.height - searchHeight)) + let nameCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + nameCol.title = "Weapon" + nameCol.width = listWidth - 100 + let rangeCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("range")) + rangeCol.title = "Range" + rangeCol.width = 80 + tableView.addTableColumn(nameCol) + tableView.addTableColumn(rangeCol) + tableView.rowHeight = 22 + tableView.dataSource = self + tableView.delegate = self + scrollView.documentView = tableView + mainView.addSubview(scrollView) + + let detailScroll = NSScrollView(frame: NSMakeRect(listWidth, 0, bounds.size.width - listWidth, bounds.size.height)) + detailScroll.autoresizingMask = [.width, .height] + detailScroll.borderType = .noBorder + detailScroll.hasVerticalScroller = true + + let textContainer = NSTextContainer(size: NSSize(width: bounds.size.width - listWidth, height: .greatestFiniteMagnitude)) + textContainer.widthTracksTextView = true + let layoutManager = NSLayoutManager() + let textStorage = NSTextStorage() + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + let detailText = NSTextView(frame: NSMakeRect(0, 0, bounds.size.width - listWidth, bounds.size.height), textContainer: textContainer) + detailText.autoresizingMask = [.width] + detailText.isEditable = false + detailText.isRichText = false + if #available(macOS 10.15, *) { + detailText.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + } else { + detailText.font = NSFont.userFixedPitchFont(ofSize: 11) ?? NSFont.systemFont(ofSize: 11) + } + detailText.textColor = NSColor.labelColor + detailText.backgroundColor = NSColor.textBackgroundColor + detailText.textContainerInset = NSSize(width: 8, height: 8) + detailScroll.documentView = detailText + mainView.addSubview(detailScroll) + + self.view = mainView + self.tableView = tableView + self.searchField = searchField + self.detailText = detailText + } + + override func viewDidLoad() { + let begin = Date() + + // Gather every top-level directory whose name starts with "weapon" + // (weapons, weaponE, weaponsE, etc.) to cover mod layouts. + let weaponDirs: [FileSystem.Directory] = shared.filesystem.root.items.compactMap { item in + guard case .directory(let d) = item, + d.name.lowercased().hasPrefix("weapon") else { return nil } + return d + } + + let tdfFiles = weaponDirs.flatMap { $0.allFiles(withExtension: "tdf") } + if weaponDirs.isEmpty { + print("Weapons: no weapon directories found in filesystem root") + } else { + print("Weapons: scanning \(weaponDirs.map { $0.name }.joined(separator: ", ")) (\(tdfFiles.count) TDFs)") + } + + var all: [WeaponInfo] = [] + var seen = Set() + for file in tdfFiles { + guard let handle = try? shared.filesystem.openFile(file) else { continue } + let parser = TdfParser(handle) + let root = parser.extractObject(normalizeKeys: true) + WeaponsBrowserViewController.collectWeapons(from: root, sourceFile: file.name, into: &all, seen: &seen) + } + all.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + self.allWeapons = all + self.weapons = all + let end = Date() + print("Weapons list load time: \(end.timeIntervalSince(begin)) seconds; weapons found: \(all.count) from \(tdfFiles.count) TDFs across \(weaponDirs.count) dir(s)") + } + + /// Walks a parsed TDF tree and emits every block with properties as a potential + /// weapon entry. Container blocks with only subobjects (e.g. `[WEAPONDEFS]`) are + /// descended into rather than listed, but any leaf block is included so the user + /// can filter down via the search field rather than have the code guess. + private static func collectWeapons(from object: TdfParser.Object, + sourceFile: String, + into results: inout [WeaponInfo], + seen: inout Set) { + for (key, sub) in object.subobjects { + let hasProperties = !sub.properties.isEmpty + let hasSubobjects = !sub.subobjects.isEmpty + + if hasProperties { + let dedupKey = (sourceFile + "#" + key).lowercased() + if seen.insert(dedupKey).inserted { + results.append(WeaponInfo(from: sub, key: key, sourceFile: sourceFile)) + } + } + if hasSubobjects { + collectWeapons(from: sub, sourceFile: sourceFile, into: &results, seen: &seen) + } + } + } + + @objc private func searchFieldChanged(_ sender: NSSearchField) { + searchTerm = sender.stringValue.trimmingCharacters(in: .whitespaces) + applyFilter() + } + + private func applyFilter() { + if searchTerm.isEmpty { + weapons = allWeapons + } else { + let term = searchTerm.lowercased() + weapons = allWeapons.filter { + $0.key.lowercased().contains(term) + || $0.name.lowercased().contains(term) + || $0.weaponType.lowercased().contains(term) + || $0.sourceFile.lowercased().contains(term) + } + } + tableView.reloadData() + detailText.string = "" + } +} + +extension WeaponsBrowserViewController: NSTableViewDataSource { + func numberOfRows(in tableView: NSTableView) -> Int { weapons.count } +} + +extension WeaponsBrowserViewController: NSTableViewDelegate { + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let column = tableColumn else { return nil } + let weapon = weapons[row] + let id = NSUserInterfaceItemIdentifier("WeaponCell.\(column.identifier.rawValue)") + let cell: NSTableCellView + if let existing = tableView.makeView(withIdentifier: id, owner: self) as? NSTableCellView { + cell = existing + } else { + cell = NSTableCellView() + cell.identifier = id + let field = NSTextField(labelWithString: "") + field.translatesAutoresizingMaskIntoConstraints = false + field.lineBreakMode = .byTruncatingTail + field.font = NSFont.systemFont(ofSize: 12) + cell.addSubview(field) + cell.textField = field + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + field.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + field.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + } + switch column.identifier.rawValue { + case "name": cell.textField?.stringValue = weapon.name.isEmpty ? weapon.key : weapon.name + case "range": cell.textField?.stringValue = weapon.range > 0 ? "\(weapon.range)" : "" + default: cell.textField?.stringValue = "" + } + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + guard tableView.selectedRow >= 0, tableView.selectedRow < weapons.count else { + detailText.string = "" + return + } + let weapon = weapons[tableView.selectedRow] + detailText.string = weapon.detailText() + } +} + +private extension WeaponInfo { + + init(from object: TdfParser.Object, key: String, sourceFile: String) { + self.key = key + self.sourceFile = sourceFile + self.name = object.properties["name"] ?? key + self.weaponType = object.properties["weapontype"] ?? "" + self.range = Int(object.properties["range"] ?? "") ?? 0 + + var damage: [String: Int] = [:] + if let damages = object.subobjects["damage"] { + for (armorClass, value) in damages.properties { + if let v = Int(value) { damage[armorClass] = v } + } + } + self.damage = damage + self.properties = object.properties + } + + func detailText() -> String { + var lines: [String] = [] + lines.append(name) + lines.append(String(repeating: "─", count: max(4, name.count))) + lines.append("Key: \(key)") + lines.append("Source: \(sourceFile)") + if !weaponType.isEmpty { lines.append("Weapon type: \(weaponType)") } + if range > 0 { lines.append("Range: \(range)") } + + if !damage.isEmpty { + lines.append("") + lines.append("Damage") + for (armor, value) in damage.sorted(by: { $0.key < $1.key }) { + lines.append(String(format: " %-20@ %d", armor as NSString, value)) + } + } + + lines.append("") + lines.append("Properties") + for (key, value) in properties.sorted(by: { $0.key < $1.key }) { + lines.append(String(format: " %-20@ %@", key as NSString, value as NSString)) + } + return lines.joined(separator: "\n") + } +} diff --git a/ci/release.entitlements b/ci/release.entitlements new file mode 100644 index 0000000..3d25042 --- /dev/null +++ b/ci/release.entitlements @@ -0,0 +1,13 @@ + + + + + + com.apple.security.get-task-allow + + + diff --git a/docs/FORK_NOTES.md b/docs/FORK_NOTES.md new file mode 100644 index 0000000..c180bf3 --- /dev/null +++ b/docs/FORK_NOTES.md @@ -0,0 +1,25 @@ +# Fork notes + +This repository is a fork of [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) that keeps the original Swift game-engine experiment intact and adds a set of asset-inspection and COB-scripting improvements focused on **TAassets** and **HPIView** running on current Apple silicon / Xcode / macOS. The deep technical write-up (file-by-file fixes, script-VM changes, renderer patches) lives in [notes/SwiftTA_Apple_Silicon_Bootstrap.md](../notes/SwiftTA_Apple_Silicon_Bootstrap.md). + +The original upstream README (Swift 4.2 / Ubuntu 16.04 era game-client instructions) is preserved at [ORIGINAL_README.md](ORIGINAL_README.md). + +**Removed from this fork:** the `SwiftTA macOS`, `SwiftTA iOS`, and `SwiftTA Linux` game-client source trees. CI never built them, and maintaining them was never the intent of this fork; they survive in the upstream [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) for anyone who wants them. The Swift packages they once depended on (`SwiftTA-Core`, `SwiftTA-Ctypes`, `SwiftTA-Metal`, `SwiftTA-OpenGL3`) remain because TAassets and HPIView still use them. + +## Highlights added in this fork + +- **Builds cleanly on Xcode 26 / macOS 26 / Apple silicon** — Swift disambiguation fixes, deployment-target bump, Metal toolchain check, palette off-by-one fix. +- **Piece hierarchy inspector** (both apps) — outline of every 3DO piece with primitive / vertex / child counts. Selecting a piece tints it gold in the 3D view. `Script Refs` column lists every COB module that manipulates each piece, extracted statically from the bytecode. +- **COB playback controls** (TAassets) — pause / step / 0×–4× speed slider, plus a "Run script…" pull-down for every module in the unit's COB. +- **Walker-IK fidelity** — rotation matrix composes yaw-outermost so child pitch axes stay horizontal, `PIECE_XZ / PIECE_Y` return native TA integer units, `XZ_ATAN` is unsigned (so `LegGroups` quadrant checks work), `GROUND_HEIGHT` is stubbed stably so `PositionLegs` converges. +- **Freeze-after-Create viewer mode** — on unit load, after `Create` returns the viewer kills the background threads it spawned so the unit holds its IK pose instead of running a forever-gait over an empty scene. Manual scripts from the "Run script…" menu still run. +- **Camera controls** — scroll / pinch zoom, shift-drag pitch, `=` / `-` / `0` keys. Auto-fits the model on load and on window resize. +- **Mod-aware filesystem** — a dynamic `Mods` menu lists every mod folder under `/mods/` and rebuilds the merged filesystem on selection. Opening a mod folder directly (e.g. `~/tafiles/mods/taesc`) is auto-paired with the vanilla base it lives under. TAESC-style mods with nested `unitsE/` and off-spec `unitpicE/` directories are discovered recursively. +- **Map browser overlays** — per-cell **Heights** tinting and a **Passability** heatmap (slope threshold adjustable, under-sea cells blue, feature-occupied cells orange) for lining up external engine passability logic against the actual TNT heightmap + sea level. +- **Map rendering** — auto-fits on load, pinch/scroll zoom, numbered start-position markers from the OTA schema, and edge-smear fixed via `clamp_to_zero` sampling plus a fragment-shader discard past the map's actual pixel size. Supports maps up to 8192 px on-screen. +- **Weapons browser** — walks every `weapon*/` directory, parses each `.tdf` recursively, and lists every weapon block with a searchable detail pane. +- **Searchable browsers** — live filter fields above the Units, Maps, and Weapons lists. +- **Browser chrome** — compact header strips; SF Symbols sidebar; window size / position persists across launches. +- **Tolerant standalone loading** — `gamedata/sidedata.tdf` is optional; palette lookup falls back through side → standard → neutral; missing `TA_Features_2013.ccx` is logged clearly. +- **HPIView extraction** — the `Extract All` menu item is implemented so you can dump the entire archive to a folder. +- **Script VM hardening** — COB divide-by-zero returns 0 rather than trapping, `Stack.pop(count:)` returns the correct number of elements, `wait-for-turn` / `wait-for-move` wake threads when the matching animation drains, multi-root 3DO trees all render (sibling subtrees no longer get dropped). diff --git a/docs/ORIGINAL_README.md b/docs/ORIGINAL_README.md new file mode 100644 index 0000000..4f9afd8 --- /dev/null +++ b/docs/ORIGINAL_README.md @@ -0,0 +1,66 @@ +# SwiftTA (original README) + +> This file preserves the README from the upstream [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) project. It documents the original game-client experiment (macOS / iOS / Linux) and its Swift 4.2 / Ubuntu 16.04 era build steps. **The `SwiftTA macOS`, `SwiftTA iOS`, and `SwiftTA Linux` game-client source trees referenced below have been removed from this fork** — they weren't built by CI or maintained here. Anyone wanting the original game client can clone [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) directly; this fork focuses on the asset-inspector tooling. +> +> The top-level [README.md](../README.md) covers the asset inspectors; the more recent fork-specific notes are in [FORK_NOTES.md](FORK_NOTES.md). + +I like [Swift](https://swift.org); but I'd like to get to know it better outside of my day job: writing iOS apps and libraries. So I've decided to retrace the steps of an old project I worked on ages ago: [writing a clone of Total Annihilation](https://github.com/loganjones/nTA-Total-Annihilation-Clone). + +Currently, there is a simple game client (macOS, iOS, Linux) that loads up a hardcoded map and displays a single unit. See the [Build](#build) section for information on building and running the client. + +![Screenshot](images/SwiftTA.jpg "SwiftTA Screenshot") + +Additionally, there are a couple of macOS applications, [TAassets](#taassets) and [HPIView](#hpiview), that browse TA archive files (HPI & UFO files) and shows a preview of its contents. + +## Build + +#### macOS & iOS + +Use the SwiftTA workspace (SwiftTA.xcworkspace) to build the macOS and/or the iOS game client. + +#### Linux + +The Linux build was developed using the official Swift 4.2 binaries for Ubuntu 16.04 from Swift.org. Additionally, the following packages are necessary to build: +``` +clang libicu-dev libcurl3 libglfw3-dev libglfw3 libpng-dev +``` + +To build the game target, use a terminal to run `swift build` from the `SwiftTA/SwiftTA Linux` directory. To run the game, use `swift run`. + +#### Windows + +😅 ... yeah, about that. I haven't been able to get a build of the Swift compiler working on my Windows machine. It would be much easier if there were official builds available from Swift.org or even from Microsoft; but that is not a reality yet; maybe after Swift 5 and the ABI work? Another complication would be the lack of a C++ interface. + +## Game Assets + +Running the current game client requires that the Total Annihilation game files be accessible in your current user's Documents directory. More specifically, the game is hardcoded to look in `~/Documents/Total Annihilation` for any .hpi files (or .ufo, .ccx, etc). This is certainly a hack and will be addressed in the future. Note: a symbolic link to another directory is acceptable; though the link must be named `Total Annihilation`. + +#### iOS + +On iOS, this is difficult due to the lack of direct filesystem access. The easiest way to get the files into the right place is to run the game app once; and then use iTunes to copy the `Total Annihilation` directory over to the app's container. Find [device] -> File Sharing -> SwiftTA and just drag-and-drop the entire folder. + +#### Linux + +To run the game, use `swift run` from the `SwiftTA/SwiftTA Linux` directory (this will also build the project if it hasn't been built already). + +Note: You will need an OpenGL 3.0 capable graphics driver to run the game. For development, I've been using the default driver in a VMWare Fusion install. + +## TAassets + +![Screenshot](images/TAassets.gif "TAassets Screenshot") + +A macOS application that browses all of the assets contained in the TA archive files (HPI & UFO files) of a TA install directory. With this you can see the "virtual" file-sytem hierarchy that TA uses to load its assets. Additionally, you can browse specific categories (like units) to see a more complete representation (model + textures + animations). + +You will need a Mac (natch) and a Total Annihilation installation somewhere on your browsable file-system. TAassets will read the files just as TA would; so any downloadable unit (a UFO) or other third-party material should "just work". + +## HPIView + +![Screenshot](images/HpiView.jpg "HpiView Screenshot") + +A macOS application that browses the TA archive files (HPI & UFO files) and shows a preview of its contents. This is similar to an old Windows program (which I believe had the same name). + +You will need a Mac and an HPI file or two. You can find these in Total Annihilation's main install directory. Any downloadable unit (a UFO) will work as well. As a bonus, you can also browse Total Annihilation: Kingdoms HPI files. + +## Next Steps + +Continuous iteration on the game client. Real unit loading. A full object system. UI interaction. So much to do. diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 0000000..d3d44a3 --- /dev/null +++ b/docs/SIGNING.md @@ -0,0 +1,74 @@ +# Code signing & notarization setup + +The CI workflow signs both apps with a Developer ID Application certificate and submits them to Apple's notary service, so downloaders can open them with a double-click — no Gatekeeper right-click-Open dance. + +This is a **one-time setup** that stores five secrets in the GitHub repo. The CI handles everything after that on every build. + +## 1. Create the Developer ID Application certificate + +On a Mac signed in to your Apple Developer account: + +1. **Xcode → Settings → Accounts**, pick your team. +2. Click **Manage Certificates…** +3. Hit the **+** in the bottom-left and choose **Developer ID Application**. +4. Close the sheet. Xcode has dropped the certificate + private key into your login keychain. + +## 2. Export the certificate as a `.p12` + +1. Open **Keychain Access**, select the **login** keychain, category **Certificates**. +2. Find the row named `Developer ID Application: (TEAMID)`. Expand it — there should be a private key underneath. (If there isn't, you're on the wrong Mac or the cert was only issued, not installed.) +3. Select both the certificate and the private key (Cmd-click the key). +4. Right-click → **Export 2 items…** → save as `DeveloperID.p12`. +5. Set a strong password when prompted. You'll need this password in step 4. + +## 3. Collect your notarization credentials + +You need three values: + +- **Apple ID** — the email on your developer account. +- **Team ID** — the 10-character identifier (e.g. `ABCDE12345`). Find it at [developer.apple.com/account](https://developer.apple.com/account) under Membership details, or in your certificate's name (`Developer ID Application: Name (TEAMID)`). +- **App-specific password** — generate one at [appleid.apple.com](https://appleid.apple.com) → Sign-In and Security → App-Specific Passwords → Generate. Name it `SwiftTA CI` or similar. It'll look like `abcd-efgh-ijkl-mnop`. Apple won't show it again, so copy it now. + +## 4. Add the five GitHub secrets + +At `https://github.com/csilvertooth/SwiftTA/settings/secrets/actions`, create these **Repository secrets**: + +| Name | Value | +|---|---| +| `MACOS_CERTIFICATE_P12_BASE64` | `base64 -i DeveloperID.p12` (no newlines) | +| `MACOS_CERTIFICATE_PASSWORD` | The password you set in step 2 | +| `MACOS_NOTARIZATION_APPLE_ID` | Your Apple ID email | +| `MACOS_NOTARIZATION_TEAM_ID` | Your 10-character Team ID | +| `MACOS_NOTARIZATION_PASSWORD` | The app-specific password from step 3 | + +To get the base64 value on macOS without line wrapping: + +``` +base64 -i DeveloperID.p12 | pbcopy +``` + +Paste directly into the secret form. + +## 5. Done — the workflow takes over + +The next push to `main` will: + +1. Import the certificate into a temporary keychain on the runner. +2. Build both apps with `CODE_SIGN_IDENTITY="Developer ID Application"` and `ENABLE_HARDENED_RUNTIME=YES`. +3. Submit each `.app` to `xcrun notarytool` and wait for the ticket. +4. `xcrun stapler staple` the notarization ticket onto the app bundle. +5. Zip and upload both to the `latest` prerelease on the Releases page. + +A downloader unzips, drags to `/Applications`, and double-clicks — no prompts. + +## Troubleshooting + +- **"Code signing is required"** — a secret is missing or empty; the workflow falls back to unsigned builds with a warning in the log. +- **`notarytool` returns `Invalid`** — download the log with `xcrun notarytool log ` to see which binary failed. Usually means a bundled dylib isn't signed or hardened runtime is off. +- **Notarization succeeds but `stapler` fails** — the .app's internal structure is wrong (usually nested .framework without a valid Info.plist). Rare for SwiftPM-based apps. +- **Cert expires** — Developer ID Application certs last 5 years. Regenerate in Xcode, re-export, update the `MACOS_CERTIFICATE_P12_BASE64` and `MACOS_CERTIFICATE_PASSWORD` secrets. + +## Rotating credentials + +- **App-specific passwords**: revoke old ones at [appleid.apple.com](https://appleid.apple.com), generate new, update `MACOS_NOTARIZATION_PASSWORD`. +- **Compromised `.p12`**: revoke the Developer ID cert at [developer.apple.com/account/resources/certificates](https://developer.apple.com/account/resources/certificates) (invalidates existing signed apps!), create a new one, re-export, update both cert secrets. diff --git a/docs/ai/CurrentTask.md b/docs/ai/CurrentTask.md new file mode 100644 index 0000000..57181b3 --- /dev/null +++ b/docs/ai/CurrentTask.md @@ -0,0 +1,32 @@ +# Bootstrap SwiftTA on Apple Silicon for TA Archive and 3DO Viewing + +## Objective +Modernize and validate the SwiftTA workspace on current macOS + Xcode + Apple silicon, using the existing HPIView and TAassets apps as the base for a Total Annihilation archive browser and 3DO/asset viewer. + +## Scope +1. Open and build SwiftTA.xcworkspace on current Xcode/macOS. +2. Make the macOS targets HPIView and TAassets compile and run on Apple silicon. +3. Limit changes to compatibility/build/runtime modernization unless a parser fix is required to restore existing behavior. +4. Verify archive browsing for HPI/UFO and audit existing support for CCX/other TA archives. +5. Add extraction support to HPIView: + - extract selected file + - extract selected folder + - extract entire archive +6. Inspect TAassets and document the extension points for: + - 3DO/model loading + - texture/palette resolution + - piece hierarchy display + - future export to normalized JSON / glTF pipeline + +## Non-Goals +- No broad parser rewrites unless necessary. +- No full editor implementation yet. +- No speculative engine-side schema work in this pass. + +## Deliverables +- Apple silicon build notes +- Compatibility fixes committed in repo +- Running HPIView app +- Running TAassets app +- Extraction features in HPIView +- Architecture note for turning TAassets into a dedicated 3DO inspector diff --git a/HpiView.jpg b/docs/images/HpiView.jpg similarity index 100% rename from HpiView.jpg rename to docs/images/HpiView.jpg diff --git a/SwiftTA.jpg b/docs/images/SwiftTA.jpg similarity index 100% rename from SwiftTA.jpg rename to docs/images/SwiftTA.jpg diff --git a/TAassets.gif b/docs/images/TAassets.gif similarity index 100% rename from TAassets.gif rename to docs/images/TAassets.gif diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md new file mode 100644 index 0000000..56c8e70 --- /dev/null +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -0,0 +1,370 @@ +# SwiftTA Apple Silicon Bootstrap Notes + +Date: 2026-04-21 +Branch: `chore/swiftta-apple-silicon-bootstrap` +Host: macOS 26.3.1 / Xcode 26.4.1, Apple silicon. + +## Build status + +| Target | Scheme | Destination | Result | +|----------|----------|----------------------------------|--------| +| HPIView | HPIView | `platform=macOS,arch=arm64` | ✅ BUILD SUCCEEDED | +| TAassets | TAassets | `platform=macOS,arch=arm64` | ✅ BUILD SUCCEEDED | + +Binaries: `build/DerivedData/Build/Products/Debug/HPIView.app`, `.../TAassets.app` — native arm64 Mach-O. + +Command used: +``` +xcodebuild -workspace SwiftTA.xcworkspace -scheme \ + -destination 'platform=macOS,arch=arm64' -configuration Debug \ + -derivedDataPath build/DerivedData build +``` + +## Environment fixes (one-time, outside the repo) + +1. **Xcode plug-in load failure** — `IDESimulatorFoundation` failed to load because the system copy of `DVTDownloads.framework` was older than Xcode 26.4's expected symbol set. Resolved by updating Xcode to 26.4.1 and running `sudo xcodebuild -runFirstLaunch`. +2. **Metal toolchain missing** — Xcode 26 ships `metal` as a downloadable component. Installed with `xcodebuild -downloadComponent MetalToolchain` (no sudo). +3. **CoreSimulator mismatch warning** — `CoreSimulator is out of date (1051.49.0 vs 1051.50.0)` is printed on every invocation. It only disables iOS Simulator, so macOS builds are unaffected. Will resolve on the next macOS point update. + +## Repo fixes (committed on the bootstrap branch) + +1. **`SwiftTA-Core/Sources/SwiftTA-Core/TdfParser.swift`** — five call sites of `data.withUnsafeBytes { $0[i] }` became ambiguous under current Swift. Closures now explicitly take `(UnsafeRawBufferPointer)` and reference `bytes[i]`. Behavior unchanged. +2. **`HPIView/HPIView.xcodeproj/project.pbxproj`** — `MACOSX_DEPLOYMENT_TARGET` bumped `10.12 → 10.13` in both configurations. Xcode 26 refuses to build below 10.13. +3. **`TAassets/TAassets.xcodeproj/project.pbxproj`** — same bump, four locations (app + tests × Debug/Release). +4. **`HPIView/HPIView/HpiDocument.swift`** — `@IBAction func extractAll` was a stub (opened nothing). Implemented it to enumerate `hpiDocument.filesystem.root.items`, open a directory chooser sheet, and delegate to the existing `extractItems(_:to:)` recursion. + +Remaining non-blocking warnings (left as-is to keep the diff minimal): +- `SwiftTA-Core/.../TextureAtlasPacker.swift:47,50` — tuple label mismatch (`offset`/`element` vs `index`/`texture`) will become an error in a future Swift language mode. +- `SwiftTA-Core/.../GameRenderer.swift:26` — `class` keyword on a protocol is deprecated (use `AnyObject`). +- HPIView / TAassets projects still carry a few pbxproj IDs referencing the old deployment target docs (build output is clean). + +## Archive support audit (deliverable 4) + +Entry points live in `SwiftTA-Core/Sources/SwiftTA-Core/hpi.swift`: + +- `HpiItem.loadFromArchive(contentsOf:)` parses any HPI-format container and returns a `HpiItem.Directory` tree. Format is detected from the header marker + version field: + - `HpiFormat.HpiVersion.ta` — Total Annihilation HPI (extended header path). + - `HpiFormat.HpiVersion.tak` — Kingdoms HPI. + - `HpiFormat.HpiVersion.saveGame` — present in the enum, no loader branch. +- `HpiItem.extract(file:fromHPI:)` returns a single file's bytes, handling encryption and optional per-chunk compression. + +File-extension wiring in `SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift`: + +```swift +public static let weightedArchiveExtensions = ["ufo", "gp3", "ccx", "gpf", "hpi"] +``` + +`FileSystem(mergingHpisIn:)` (used by TAassets) walks `~/Documents/Total Annihilation`, filters by those extensions, and merges every matched archive into one virtual filesystem. Extension is only used as a filter — all files flow through the same `HpiItem.loadFromArchive` code path, which is why UFO/CCX/GP3/GPF all browse correctly as long as their binary format is HPI. + +HPIView uses `FileSystem(hpi:)` (single archive per document). Its `Info.plist` registers UTIs for `com.cavedog.hpi` only; to accept UFO/CCX directly by double-click, additional UTIs would need to be declared. Open-via-menu already works for any file thanks to `NSDocument`'s generic reader. + +No parser changes were required — archive browsing works as-is. + +## Extraction features in HPIView (deliverable 5) + +Implemented via `HPIView/HPIView/HpiDocument.swift`: + +- **Extract selected file(s)** — existing `@IBAction func extract(sender:)` ([HpiDocument.swift:405](HPIView/HPIView/HpiDocument.swift#L405)). Iterates the Finder selection, maps each to `HpiItem`, and writes it next to the chosen directory via `HpiItem.extract(file:fromHPI:)`. +- **Extract selected folder** — same action; when a directory is selected, `extractItems(_:to:)` recurses, creating subdirectories as it goes ([HpiDocument.swift:435-463](HPIView/HPIView/HpiDocument.swift#L435-L463)). +- **Extract entire archive** — `@IBAction func extractAll(sender:)` ([HpiDocument.swift:427](HPIView/HPIView/HpiDocument.swift#L427)). Was a stub before this branch; now enumerates `hpiDocument.filesystem.root.items`, shows an `NSOpenPanel` directory-chooser sheet, and reuses `extractItems(_:to:)`. + +Menu wiring already exists in `HPIView/HPIView/Base.lproj/MainMenu.xib` (`extractWithSender:` and `extractAllWithSender:` first-responder actions). No XIB changes needed. + +Gaps / potential follow-ups (not implemented — out of scope for this pass): +- No progress UI for large archives. +- Errors are `print`-logged; no user-facing dialog. +- `validateMenuItem` only enables `extract` when something is selected — `extractAll` is always enabled; fine, but consider disabling when the archive is empty. +- TAassets has no extraction UI; its filesystem is merged across many archives, so per-item extract would need to carry `archiveURL` (already present on `FileSystem.File`) into the action. + +## 3DO / model viewer extension points (deliverable 6) + +### Parsing + +- `SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift` + - Public struct `UnitModel` — opens a `.3DO` file via `UnitModel(contentsOf:)`. + - Core parse loop: `UnitModel.loadModel(from: UnsafeRawBufferPointer)` walks a queue of piece offsets, reading `TA_3DO_OBJECT`, `TA_3DO_VERTEX`, and `TA_3DO_PRIMITIVE` C-structs (defined in `SwiftTA-Ctypes` via `module.modulemap`). + - Piece hierarchy is built inline: each object's `offsetToChildObject` / `offsetToSiblingObject` drives the traversal; `ModelData.pieces` is a flat array; `nameLookup` maps piece name → index. + - `UnitModel.PieceMap` computes parent chains (`mapParents`) — useful for animation evaluation and for exporting a hierarchical representation (e.g. glTF nodes). + +### Textures / palette + +- `SwiftTA-Core/Sources/SwiftTA-Core/ModelTexturePack.swift` — an index of available model textures across the merged filesystem. `UnitBrowserViewController` holds an instance (`textures`) and hands it to the renderer. +- `SwiftTA-Core/Sources/SwiftTA-Core/UnitTextureAtlas.swift` — packs the textures referenced by a specific model into a single atlas; each primitive carries UV rect keyed off the piece's texture name. +- `SwiftTA-Core/Sources/SwiftTA-Core/TextureAtlasPacker.swift` — the packing algorithm (currently emits two tuple-label warnings, see above). +- `SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift` + `Palette+Files.swift` — 8-bit palette loading (`.PAL`) and RGBA resolution. `HPIView/HPIView/HpiDocument.swift` loads `PALETTE.PAL` from the bundle at preview time; for a proper 3DO inspector the palette should come from the selected side's palette in `SideData`. + +### Rendering entry points + +- TAassets: `TAassets/TAassets/UnitView.swift` delegates to `UnitView+Metal.swift` (macOS default) or `UnitView+Opengl.swift`. Renderer protocols in `UnitViewRenderer+Metal.swift` / `UnitViewRenderer+OpenglCore33.swift` / `UnitViewRenderer+OpenglLegacy.swift`. +- HPIView: `HPIView/HPIView/ModelView.swift` + `ModelView+Metal.swift` / `ModelView+Opengl.swift`, renderer in `ModelViewRenderer+Metal.swift`. +- The Metal pipeline lives in the `SwiftTA-Metal` Swift package; shaders in `HPIView/HPIView/*.metal` (ModelViewRenderer, TntViewRenderer variants). Metal toolchain download is required (see environment fix #2). + +### Suggested shape for a dedicated 3DO inspector + +If the goal is to evolve TAassets into a focused 3DO/asset inspector + exporter: + +1. Re-use `UnitModel.loadModel` unchanged — it already produces the canonical piece tree. +2. Surface piece metadata (name, position, child count, primitive count) in an `NSOutlineView` keyed off `UnitModel.pieces` + `PieceMap.parents`. +3. Wrap the existing Metal renderer in a stand-alone `NSViewController` that takes a `UnitModel` + `ModelTexturePack`/`UnitTextureAtlas` + `Palette` (all already wired up in `UnitBrowser`/`UnitView`). +4. For export, walk `UnitModel.PieceMap` once to emit glTF nodes (one per piece, TRS from `TA_3DO_OBJECT` offsets), and one mesh per piece primitive set. Textures already land in an RGBA atlas via `UnitTextureAtlas` — that's glTF-friendly. +5. Animation (COB scripts) lives in `UnitScript*.swift` (`UnitScript.swift`, `UnitScript+VM.swift`, `UnitScript+Instructions.swift`, `UnitScript+CobDecompile.swift`). Piece transforms are driven at runtime by the VM — for glTF export, either bake to sampled animations or emit the raw bytecode as a side-car and evaluate later. + +## Validate checklist + +- [x] HPIView builds on current Apple silicon macOS +- [x] TAassets builds on current Apple silicon macOS +- [ ] Apps launch without immediate runtime failure — **not verified from CLI** (requires GUI interaction and a valid `~/Documents/Total Annihilation` directory for TAassets; HPIView only needs a `.hpi` file via Open). +- [x] Existing HPI/UFO browsing code audited +- [x] CCX/GP3/GPF support confirmed via shared `HpiItem.loadFromArchive` + `weightedArchiveExtensions` +- [x] Extraction locations identified; `extractAll` stub completed +- [x] 3DO / model viewer extension points identified + +--- + +# Feature work + +Everything below was added on top of the bootstrap. All features live on the `chore/swiftta-apple-silicon-bootstrap` branch. + +## Piece hierarchy inspector + +Both apps surface a live outline of model pieces beside the 3D preview. + +- **TAassets**: [`PieceHierarchyView`](TAassets/TAassets/PieceHierarchyView.swift) sits below the unit's 3D preview inside `UnitDetailViewController`. +- **HPIView**: same view is embedded in a vertical `NSSplitView` with the 3D view; drag the divider to resize. [`HPIView/PieceHierarchyView.swift`](HPIView/HPIView/PieceHierarchyView.swift), layout in [`ModelView.swift`](HPIView/HPIView/ModelView.swift). + +Columns: +- **Piece** — the string baked into each `TA_3DO_OBJECT` (`base`, `pad`, `nano`, `turret`, `flare`, `explode1`, …). Tree structure follows the 3DO parent/child pointers. +- **Prims / Verts / Children** — primitive count for the piece, total vertex indices across its primitives, number of direct children. +- **Script Refs** — each COB module that references this piece plus the set of opcodes used (`Create[dontShade]`, `Activate[turnPieceWithSpeed]`, …). Extracted statically by [`UnitScript.pieceReferences()`](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+PieceReferences.swift). + +Selecting a row tints the piece in gold inside the 3D view. Implemented by a new `highlightedPieceIndex` uniform and a flat interpolant in both renderers' shaders: +- TAassets: [`UnitViewRenderer+MetalShaders.metal`](TAassets/TAassets/UnitViewRenderer+MetalShaders.metal) + [`UnitViewRenderer+MetalShaderTypes.h`](TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h) (the uniform is already in a `pieces[40]` buffer, so the index is straightforward). +- HPIView: [`ModelViewRenderer+MetalShaders.metal`](HPIView/HPIView/ModelViewRenderer+MetalShaders.metal) + [`ModelViewRenderer+MetalShaderTypes.h`](HPIView/HPIView/ModelViewRenderer+MetalShaderTypes.h). Required adding an `int pieceIndex` attribute to `ModelMetalRenderer_ModelVertex`; [`ModelViewRenderer+Metal.swift`](HPIView/HPIView/ModelViewRenderer+Metal.swift) writes it in `append(_:_:_:…)`/`appendLine`. + +## Camera controls + +Same bindings in both apps, applied to either the unit view (TAassets) or 3DO preview (HPIView): + +| Input | Effect | +|---|---| +| Two-finger / mouse scroll | Zoom | +| Trackpad pinch | Zoom | +| `=` / `+` | Zoom in by 1.25× | +| `-` | Zoom out by 1.25× | +| `0` | Reset zoom and camera rotation | +| Mouse drag (no modifier) | Yaw (Z) | +| Shift + drag | Pitch (X) — consumed via a new `rotateX` step in the view matrix | +| Option + drag | Roll (Y) — state exists; wiring trivial | + +Zoom scales the orthographic scene width. Each app maintains its own base width: TAassets derives it from the unit's `footprint.width`; HPIView uses [`UnitModel.maxWorldExtent`](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift) so large buildings fit on load. `viewportChanged` re-fits on window resize. + +## Playback controls (TAassets) + +[`PlaybackControlsView`](TAassets/TAassets/PlaybackControlsView.swift) sits as a thin toolbar between the 3D preview and the piece outline. + +- **Pause / Play** — toggles `viewState.playbackSpeed` between 0 and the last nonzero speed. `UnitViewController.updateAnimatingState` short-circuits script execution while paused. +- **Step** — pauses, then calls `stepOnce(by:)` to advance exactly 1/30 s of script time. Useful for inching through a build yard opening. +- **Speed slider (0×–2×)** — scales deltaTime each frame. +- **Run script…** — pull-down listing every module in the unit's COB (`Create`, `Activate`, `QueryPrimary`, etc.). Selecting one invokes `scriptContext.startScript(name)` so building internals can be observed on demand. + +## Mod support + +[`FileSystem`](SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift) gained a `modDirectory:` parameter. When set, mod archives overlay the base with `overwrite: true`, so mod files replace vanilla when names collide and mod-only files are additive. `weightedArchiveExtensions` order (`ufo, gp3, ccx, gpf, hpi`) controls the load order inside each directory so later archives win. + +### Mods menu + +Dynamic menu in the menubar (installed from [`AppDelegate`](TAassets/TAassets/AppDelegate.swift)). Items populate lazily from `/mods/*/` at `menuWillOpen`. First item reads `Base only: ` to reflect the actual base, not a generic "vanilla" label. The action routes through `AppDelegate.activateModFromMenu(_:)` → `TaassetsDocument.activateMod(_:)` so dispatch is reliable regardless of first-responder state. + +### Mod folder auto-detect + +[`TaassetsDocument.read(from:)`](TAassets/TAassets/TaassetsDocument.swift) checks if the opened folder's parent is named `mods` or `mod`. If so, and the grandparent contains any recognized archive extension, it loads the grandparent as the base with the opened folder as the active mod. This means: +- `File → Open → ~/tafiles` → base only (same as before). +- `File → Open → ~/tafiles/mods/taesc` → `base: tafiles + mod: taesc` automatically, so the mod gets its textures and palettes from the vanilla base without the user stitching it together. + +### Standalone-folder tolerances + +For users who open a mod folder that has no vanilla parent: +- `gamedata/sidedata.tdf` is optional — missing file just logs and uses empty sides. +- [`UnitDetailViewController.resolvePalette`](TAassets/TAassets/UnitBrowser.swift) chains `texturePalette → standardTaPalette → Palette()` so the 3D view still paints something. +- [`Palette.init()`](SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift) now allocates 256 entries instead of 255 (a latent off-by-one that only showed up once the fallback was exercised). + +### Unit discovery + +[`UnitBrowserViewController.viewDidLoad`](TAassets/TAassets/UnitBrowser.swift) walks the entire merged filesystem for `*.fbi` (not just `units/`). TAESC-family archives store their content in `unitsE/` alongside the vanilla `units/`; the broader walk catches them. Duplicates are deduped by lowercased base name so overridden vanilla units appear once. Debug prints at load time expose the root entries and per-directory FBI counts so mod troubleshooting is visible. + +[`UnitBrowserViewController.buildpic(for:)`](TAassets/TAassets/UnitBrowser.swift) iterates every root directory whose name starts with `unitpic` (covering `unitpics/`, `unitpicsE/`, `unitpicE/`) and tries PCX, BMP, PNG, JPG, JPEG, TGA before falling back to `anims/buildpic/*.{jpg,jpeg,png,bmp}`. + +### COB divide-by-zero hardening + +[`UnitScript+Instructions.swift`](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift) — the `.divide` opcode used Swift's `/` which traps on division by zero. Some mod-shipped COB scripts (confirmed in TAESC) hit this when the VM evaluates side effects on large buildings. Replaced with a guarded closure that returns 0 when `rhs == 0`. + +## Gaps / future work + +- HPIView doesn't have mod awareness; it's still a single-archive browser. Probably fine since the app's job is file introspection, not mod switching. +- The OpenGL renderers do not apply the new highlight/pitch; TAassets' default Metal path covers both, and macOS 26 deprecates Apple's OpenGL anyway. +- The `unitsE`, `gamedatE`, `guiE` duplicate root directories from the TAESC archives are still not understood — they look like HPI directory-name parsing corruption rather than intentional English-locale variants. The broader unit/pic scans work around it, but the HPI parser may still be reading one byte past the null terminator in some cases. +- No per-unit texture variant handling for team colors. Units render with side 1's palette only. +- Extraction UI only exists in HPIView. TAassets could carry its own since `FileSystem.File.archiveURL` already tells it which container each file came from. + +--- + +# TAassets UX work + +Collected on branch `chore/taassets-ux` after the initial bootstrap was merged to `main`. Covers browser chrome, map viewer upgrades, mod-unit discovery, a working weapons tab, and several shader fixes to support mod maps. + +## Browser chrome + +- **Detail pane layout** ([TAassets/TAassets/UnitBrowser.swift](TAassets/TAassets/UnitBrowser.swift), [TAassets/TAassets/MapBrowser.swift](TAassets/TAassets/MapBrowser.swift)) — the old 62% golden-ratio content box with a centered 18-pt title has been replaced with a compact header strip. Map pane shows `mapname · planet · N players · wind lo-hi · tidal · gravity` from the OTA. Unit pane shows `objectName · title · description · side · tedclass · footprint · speed`. The 3D or map content fills the remaining pane. +- **Autoresizing fix** ([TAassets/TAassets/UnitBrowser.swift](TAassets/TAassets/UnitBrowser.swift), [TAassets/TAassets/MapBrowser.swift](TAassets/TAassets/MapBrowser.swift)) — the detail controller's view was set to `[.width, .width]` in both browsers, so the detail pane never grew vertically with the window. Fixed to `[.width, .height]`. +- **Sidebar icons** ([TAassets/TAassets/TaassetsDocument.swift](TAassets/TAassets/TaassetsDocument.swift)) — swapped the stock AppKit images for SF Symbols on macOS 11+: `cube.fill` (Units), `scope` (Weapons), `map.fill` (Maps), `folder.fill` (Files). Falls back to the original images on older systems. +- **Sidebar spacing** ([TAassets/TAassets/TaassetsDocument.swift](TAassets/TAassets/TaassetsDocument.swift)) — the Units icon was clipping under the red/yellow/green window buttons; added 28-pt top edge insets on the sidebar stack view. +- **Window sizing & autosave** ([TAassets/TAassets/TaassetsDocument.swift](TAassets/TAassets/TaassetsDocument.swift)) — documents now open at ~70% of screen width / 80% height (capped 1600×1100, floored 1100×750) and persist the frame under `TaassetsMainWindow` so future launches restore the last size and position. Minimum size 900×600 so the browser chrome always fits. +- **Search fields** ([TAassets/TAassets/UnitBrowser.swift](TAassets/TAassets/UnitBrowser.swift), [TAassets/TAassets/MapBrowser.swift](TAassets/TAassets/MapBrowser.swift), [TAassets/TAassets/WeaponsBrowser.swift](TAassets/TAassets/WeaponsBrowser.swift)) — added an `NSSearchField` above the Units, Maps, and Weapons lists. Filters live. Units match on name/title/description/3DO object name; maps match on base name; weapons match on key/name/weapontype/source file. + +## Map viewer + +- **Auto-fit on load** ([TAassets/TAassets/MapView+Metal.swift](TAassets/TAassets/MapView+Metal.swift)) — `MetalMapView.zoomToFit(resolution:)` sets `NSScrollView.magnification` so the full map fits the current viewport on open (previously always 1:1 which made 16k-wide maps look like an opaque tile). `NSScrollView.allowsMagnification` is already on, so pinch/scroll zoom work throughout. +- **Viewport sync every frame** ([TAassets/TAassets/MapView+Metal.swift](TAassets/TAassets/MapView+Metal.swift)) — `draw(in:)` now refreshes `viewState.viewport` from the current clip-view bounds each frame. The bounds-changed notification was occasionally dropped around a map reload, so the second map would stop redrawing while the scrollView's markers kept scrolling. Belt-and-suspenders fix. +- **Start-position markers** ([TAassets/TAassets/MapView+Metal.swift](TAassets/TAassets/MapView+Metal.swift)) — the scroll view's (previously invisible) document view is now a `MapOverlayView` that paints numbered gold/orange circles at every commander start pulled from `MapInfo.schema[0].startPositions`. Flipped coordinate system so positions match OTA directly. Scrolls and zooms with the map. +- **Map size ceiling** ([HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift](HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift)) — `maximumDisplaySize` bumped from `4096×4096` to `8192×8192` (16×16 screen-tile grid, ~256 MB VRAM) so maps render cleanly on 4K-class Retina displays. `computeTileGrid` clamps the visible grid to `maximumGridSize` so a viewport larger than the pool no longer overflows the pre-sized index/slice buffers (previously produced tile fallback artifacts). +- **Past-edge discard** ([HPIView/HPIView/TntViewRenderer+MetalShaders.metal](HPIView/HPIView/TntViewRenderer+MetalShaders.metal), [HPIView/HPIView/TntViewRenderer+MetalShaderTypes.h](HPIView/HPIView/TntViewRenderer+MetalShaderTypes.h)) — map fragment shaders discard pixels outside the map's pixel area. Single-quad checks texCoord against `[0,1]`; tile shader compares world position to a new `mapSize` uniform. Samplers also switched to `clamp_to_zero`. No more vertical smearing of the last terrain column when the viewport or a partial edge tile extends past the map. +- **Missing-features warning** ([TAassets/TAassets/MapView+Metal.swift](TAassets/TAassets/MapView+Metal.swift)) — feature loading errors are no longer swallowed by `try?`; the viewer now logs a clear note pointing at `TA_Features_2013.ccx` so users can tell when that archive is missing from the base. + +## Mod support + +- **Mod-folder auto-detect** ([TAassets/TAassets/TaassetsDocument.swift](TAassets/TAassets/TaassetsDocument.swift)) — File → Open on a folder whose parent is named `mods` or `mod` and whose grandparent has TA archives now loads the grandparent as the base with the opened folder as the active mod. Opening `~/tafiles/mods/taesc` behaves the same as opening `~/tafiles` and choosing `taesc` from the Mods menu — the mod gets its textures and palettes from the vanilla base. +- **Menu routing** ([TAassets/TAassets/AppDelegate.swift](TAassets/TAassets/AppDelegate.swift)) — the Mods-menu action routes through `AppDelegate.activateModFromMenu(_:)` rather than targeting the NSDocument directly, so dispatch works regardless of first-responder state. The first menu entry reads `Base only: ` instead of a generic label. +- **Recursive unit discovery** ([TAassets/TAassets/UnitBrowser.swift](TAassets/TAassets/UnitBrowser.swift)) — `UnitBrowserViewController.viewDidLoad` walks the entire merged filesystem for `*.fbi`, catching TAESC-style archives that stash unit definitions in `unitsE/` alongside `units/`. Deduped by lowercased base name so an overridden vanilla unit appears once. Debug prints expose root entries and per-directory FBI counts. +- **Generalized buildpic search** ([TAassets/TAassets/UnitBrowser.swift](TAassets/TAassets/UnitBrowser.swift)) — iterates every root directory whose name starts with `unitpic` and tries PCX, BMP, PNG, JPG/JPEG, TGA before falling back to `anims/buildpic/` JPG/PNG/BMP. Handles both vanilla and mod naming. + +## Weapons browser ([TAassets/TAassets/WeaponsBrowser.swift](TAassets/TAassets/WeaponsBrowser.swift)) + +New tab wired to the Weapons sidebar button. Walks every top-level directory whose name starts with `weapon` (so `weaponsE/` and `weaponE/` from mod archives are picked up) and parses each `*.tdf` with `TdfParser`. Every block with at least one property is shown in a two-column table (name, range). Container blocks with only subobjects are descended into rather than listed. Selecting a weapon prints its key, source file, weapon type, range, damage table, and full property set in the detail pane. Search field narrows by key, name, weapon type, and source file. + +## COB VM hardening ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +Some mod scripts (confirmed in TAESC) invoke the divide opcode with a zero right-hand side. Swift's `/` traps on integer division by zero and crashed the app the moment a unit was selected. Replaced the `.divide` entry in the opcode dispatch dictionary with a guarded closure that returns `0` on zero divisor so the VM keeps running. + +## Piece-count capacity for complex mod units + +TAassets' Metal uniform struct carried `pieces[40]` ([TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h](TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h)), and the renderer's per-frame copy wrote `transformations.count` matrices into that slot. Mod units with more than 40 pieces (TAESC's `CORMKL` spider, for example) overflowed the uniform buffer, clobbered `highlightedPieceIndex`, and left the shader reading stale/zero matrices for the overflow pieces — so legs, rotor arms, and other appendages rendered collapsed on the base or disappeared entirely. + +- Uniform now carries `pieces[128]`. +- [`BasicMetalUnitViewRenderer`](TAassets/TAassets/UnitViewRenderer+Metal.swift) caps the per-frame copy to `maxPieceMatrices = 128` and logs a one-shot warning when a unit exceeds the cap, so future over-budget units are at least visible and traceable. + +## Unit-view auto-fit + +[`UnitViewController.computeSceneSize`](TAassets/TAassets/UnitView.swift) used to pick a scene width of `max(footprintWidth, extent * 2.3)` and let scene height fall out of `sceneWidth * aspectRatio`. A wide-but-short viewport therefore cropped tall mod models. The new computation fits both axes: + +```swift +let modelDiameter = extent * 2.4 +let sceneWidthNeeded = max(modelDiameter, modelDiameter / aspectRatio) +``` + +So `sceneHeight = sceneWidthNeeded * aspectRatio >= modelDiameter` is guaranteed regardless of the viewport aspect. On load the full unit is always visible before the user zooms in. + +## COB VM stack bug ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift)) + +`UnitScript.Thread.Stack.pop(count: n)` was returning `suffix(from: n - 1)` instead of `suffix(n)`. `suffix(from:)` slices from a start index, so the returned array's length depended on the current stack depth — you only got exactly `n` elements when the stack happened to hold `2·n - 1` items. Everywhere else it returned the wrong count, so `getFunctionResult`, `startScript`, and `callScript` popped whatever random slice the arithmetic landed on. Fixed by using `suffix(n)`. + +## Wait-for-turn / wait-for-move release ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift)) + +`waitForTurn` and `waitForMove` flipped a thread into `.waitingForTurn(piece, axis)` / `.waitingForMove(piece, axis)` but nothing ever woke them again — `Thread.run` just `break runLoop`'d on those states. Every walker loop stalled on its first `wait-for-turn` and kept queuing new `rotation` animations against a frozen piece state. CORMKL's pending-animation queue ballooned past 1100 items while nothing moved on screen. + +`Context.applyAnimations` now iterates the thread list after applying animations and flips any thread back to `.running` when no rotation / spin / translation matching its waited `(piece, axis)` is still in flight. `waitForTurn` / `waitForMove` also short-circuit at the call site when no matching animation is pending, so scripts like `turn X to Y now; wait-for-turn X;` don't stall waiting on something that already happened. + +## Yaw-outermost piece rotation ([SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift) and every renderer-side transform site) + +The original matrix built each piece's local transform as `R_SIMDx(turn.x) · R_SIMDy(turn.z) · R_SIMDz(turn.y)` — pitch outermost. CORMKL's bisection IK turns the shoulder to a non-zero pitch (`turn.x`), and on the next tick that pitch rotates the child knee's local X axis toward vertical. The knee-bend-vs-reach function then becomes unimodal instead of monotonic; bisection freezes at the degenerate end and `local2` drifts to ±105°. + +Changed the matrix to `R_SIMDz(turn.y) · R_SIMDy(turn.z) · R_SIMDx(turn.x)` (yaw outermost). A child's own `turn.x` now rotates around an axis that stays in the horizontal plane regardless of the parent's pitch, so the shoulder pitch no longer contaminates the knee bisection. Six call sites touched — `UnitModel+Bounds.pieceLocalTransform` for script reads, plus the five renderer transform builders in TAassets and the two SwiftTA-Metal/SwiftTA-OpenGL3 drawables. + +## Unsigned TA atan2 ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +`taAtan2` was returning signed TA angles in `[-32768, 32768]`. Walker scripts compare against unsigned `[0, 65536)` constants — `LegGroups` pins its stride-target bisection with `XZ_ATAN(...) > 16384 && < 49152` (the second/third quadrant gate). With signed output, every third-quadrant angle was negative and failed the check, so the bisection couldn't distinguish "in target range" from "past it" and rolled to the ±200 game-unit edge — which drove the stride pieces out past Leg-0 reach. Folding negative turns into `[0, 1)` before the TA-unit conversion re-enables those quadrant checks and the Point pieces settle in sensible positions again. + +## GROUND_HEIGHT for the ground-less viewer ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +CORMKL's `PositionLegs` thread runs every tick of `while (TRUE)` and issues: + +``` +move Point1 to y-axis [get GROUND_HEIGHT(get PIECE_XZ(Point1)) - get PIECE_Y(Point1)] speed [50.0] +``` + +with the same pattern for Point2..Point6. Each `Point` drives one of the six stride targets that the per-leg bisection IK aims for. Stubbing `GROUND_HEIGHT` to `0` made the target evaluate to `-PIECE_Y(Point1)` every tick, which swapped sign as soon as the move landed, and the `Point` piece oscillated 1-2 units around `y=0`. Because `Stride1..6` are parented to the oscillating points, the IK target moved every frame and the bisection could never settle — hence CORMKL's legs looking like they were "anchored at wrong points, moving in every direction". + +Fix: on `GROUND_HEIGHT`, scan the unit's pieces for the one whose current world XZ matches the queried packed coord, and return that piece's own world Y. The script's pattern is always `GROUND_HEIGHT(PIECE_XZ(P))`, so the match is exact and the subtraction collapses to zero — `Point` holds its baseline position and the IK bisection sees a stationary target. In a real game on terrain this becomes the actual terrain sample; the no-terrain unit viewer just needs a value that keeps the walker stable. + +Also disabled the 1-second `StartMoving` auto-kick in [UnitView.swift](TAassets/TAassets/UnitView.swift): the viewer can't translate the unit through a world, so a walk cycle here just cycles leg phases in place and obscures whether the Create-time IK is landing the feet correctly. Pressing `d` dumps every piece's current offset, turn, move, world position, and parent chain for targeted diagnosis. + +## PIECE_XZ / PIECE_Y native TA units ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +An earlier pass scaled packed piece positions by 256 to give the IK bisection sub-pixel precision. That broke every mod walker whose Create script compared `XZ_HYPOT` against fixed integer thresholds (leg-length targets, reach cutoffs): with the output scaled 256× but the constant unchanged, the bisection always picked the same branch and the legs flailed out of the ground plane. Real TA packs `PIECE_XZ` as integer game units and returns `XZ_HYPOT` in the same units, so script constants are sized to that native resolution. Reverted the scaling and now `packXZ` / `PIECE_Y` return the position rounded to integer TA units — bisection loses the 8 fractional bits but now converges where the script author expected. + +## Inline `turn/move now` + world-space piece queries + +TA walker `Create` scripts (CORMKL and every other TAESC spider) do an in-loop binary-search IK: + +``` +while (local8 != 0) { + local5 = local7 + local8; + turn Leg1-2 to x-axis now; + ... + if (get HYPOT(get PIECE_Y(Leg1-0) - get PIECE_Y(End1), …) > local3) { + local7 = local7 + local8; + } + local8 = local8 / 2; +} +``` + +Each iteration turns a leg segment **and** immediately reads the end-effector position via `PIECE_XZ` / `PIECE_Y` to decide the next bisection step. Our VM queued `turn ... now` as a `setAngle` animation that only flushed in the post-`run()` `applyAnimations` step, so the loop always read stale state and never converged. Combined with the earlier `getUnitValue` stubs, legs ended up folded straight under the body. + +Fix: + +- `UnitScript.Context.run` now takes `inout UnitModel.Instance` and threads it through via `UnsafeMutablePointer` so every thread in one tick mutates the same state. +- `turnPieceNow` / `movePieceNow` in [UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift) write directly to `pieces[i].turn` / `pieces[i].move`. Animated `... with speed X` turns still go through `Context.animations` and time-based `applyAnimations`. +- New `UnitModel.pieceWorldTransform` / `pieceWorldPosition` in [UnitModel+Bounds.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift) walk the ancestor chain multiplying local transform matrices (same math as the renderer) so the IK getters observe the cumulative effect of every `turn ... now` issued earlier in the same tick. + +## COB stack arg order ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +With `Stack.pop(count: n)` fixed to return items in push order, the stale `.reversed()` in `getFunctionResult`, `startScript`, and `callScript` inverted the param slots. For `get(PIECE_XZ, piece, 0, 0, 0)` the piece index was ending up in `params[3]` while `params[0]` carried a trailing zero, so every `PIECE_XZ` call resolved to piece zero and the bisection angles matched for every leg. Removing `.reversed()` lines up with the decompiler's convention: `params[0]` is the first-written argument. + +## COB script IK getters ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +`getUnitValue` (opcode `0x10042000`) and `getFunctionResult` (opcode `0x10043000`) were both stubbed to push `0` regardless of what the COB script asked for. TA spider-class units calculate leg rotations at `Create` time via: + +``` +x = get(PIECE_XZ, Leg5-0) +atan = get(XZ_ATAN, x) +turn Leg5-0 to y-axis atan now +``` + +With `PIECE_XZ` returning `0`, `XZ_ATAN` read zero, and every leg that relied on this IK pattern rotated to 0° — collapsing onto the body origin. That's why CORMKL's side legs (Leg5/Leg6) disappeared while front legs (Leg1/Leg2 that use fixed offsets) rendered fine. + +Fix: + +- [`UnitModel.parents`](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift) is now populated during load — each piece knows its ancestor chain. +- [`UnitModel.pieceStaticOffset(_:)`](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift) sums a piece's ancestor offsets, giving the world-space offset adequate for the Create-time IK queries (moves are still zero at that point). +- `UnitScript.Context` now carries a `UnitModel` reference so instructions can reach the piece tree. +- `getUnitValue` returns sensible defaults for `activation`, `health`, `standingFireOrders`, `armored`, and the position queries. +- `getFunctionResult` implements `PIECE_XZ`, `PIECE_Y`, `XZ_ATAN`, `XZ_HYPOT`, `ATAN`, `HYPOT`, and zero-returning fallbacks for unit/ground queries. +- TA angle encoding (65536 units per full turn) and the packed-xz representation (`(x << 16) | (z & 0xFFFF)`) are implemented as `taAtan2`, `taHypot`, `packXZ`, `unpackXZ`. + +## Multi-root 3DOs ([SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift)) + +The 3DO parser already accumulates every sibling offset starting at byte 0 into `ModelData.roots`, but `UnitModel.init` only kept the first: + +```swift +root = model.roots.first! +``` + +Everything downstream — vertex collection, piece transforms, piece-hierarchy outline, the `maxWorldExtent` auto-fit — walked from `model.root` and therefore ignored every other root's subtree. Several TAESC mod units (confirmed with `CORMKL`, the Core mechanical spider) keep their legs on sibling roots, so the body would render cleanly while the legs vanished. + +Fix: expose `roots: [Pieces.Index]` on `UnitModel` and have each renderer iterate it instead of the single `root`. Specifically: + +- TAassets' [`MetalModel`](TAassets/TAassets/UnitViewRenderer+Metal.swift) walks every root during vertex, outline, and transform collection. +- HPIView's [`MetalModel`](HPIView/HPIView/ModelViewRenderer+Metal.swift) walks every root during vertex and outline collection. +- The [piece-hierarchy outline](TAassets/TAassets/PieceHierarchyView.swift) creates one top-level node per root. +- [`UnitModel.maxWorldExtent`](SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift) accumulates bounds across every root so the auto-fit considers pieces on secondary subtrees. + +Also adds a startup print of per-unit piece/primitive/script-module counts and the full piece name list in [TAassets/TAassets/UnitBrowser.swift](TAassets/TAassets/UnitBrowser.swift). Future "X is missing its Y" reports can be diagnosed directly from `/tmp/taassets.log`.