From 771346546bf1f4695355b6f8b2c5695a5e0da8e2 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 18:41:39 -0700 Subject: [PATCH 01/54] Updates the project to build on current Xcode and Apple silicon. Bumps MACOSX_DEPLOYMENT_TARGET from 10.12 to 10.13 (the new floor for Xcode 26) on both HPIView and TAassets. Disambiguates five Data.withUnsafeBytes call sites in TdfParser now that Swift requires an explicit buffer-pointer type. Implements the empty extractAll action in HPIView so the archive root is enumerated and handed to the existing extractItems recursion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + HPIView/HPIView.xcodeproj/project.pbxproj | 4 +- HPIView/HPIView/HpiDocument.swift | 19 ++- .../Sources/SwiftTA-Core/TdfParser.swift | 20 +-- TAassets/TAassets.xcodeproj/project.pbxproj | 4 +- docs/ai/CurrentTask.md | 32 +++++ notes/SwiftTA_Apple_Silicon_Bootstrap.md | 120 ++++++++++++++++++ 7 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 docs/ai/CurrentTask.md create mode 100644 notes/SwiftTA_Apple_Silicon_Bootstrap.md 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/HPIView/HPIView.xcodeproj/project.pbxproj b/HPIView/HPIView.xcodeproj/project.pbxproj index 86dddf5..fdb7c16 100644 --- a/HPIView/HPIView.xcodeproj/project.pbxproj +++ b/HPIView/HPIView.xcodeproj/project.pbxproj @@ -341,7 +341,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 +395,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..477c2d6 100644 --- a/HPIView/HPIView/HpiDocument.swift +++ b/HPIView/HPIView/HpiDocument.swift @@ -425,11 +425,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) { 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.. \ + -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 From 98cbf37373bf08763cadc5c59d3c166bbf976a82 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 19:55:06 -0700 Subject: [PATCH 02/54] Adds a piece inspector, mod loader, and camera controls for 3DO viewing. Introduces PieceHierarchyView (outline of piece names, primitive and vertex counts, and per-piece script references) in both HPIView and TAassets. Piece references are decoded from each unit's COB via a new UnitScript.pieceReferences walk. Selecting a piece tints it in the Metal renderer: a new highlightedPieceIndex uniform and a flat piece index interpolant drive a yellow mix in the fragment shader in both targets. Adds mod support. FileSystem.init gains a modDirectory argument that overlays mod archives on top of the base. TaassetsDocument scans /mods for mod subdirectories and exposes them through a dynamic top-level Mods menu that rebuilds the filesystem and reloads the current browser on selection. Adds camera controls. Scroll-wheel and trackpad pinch adjust zoom; = / - / 0 act as keyboard zoom and reset. Reset also restores rotation. Shift-drag pitches the camera via a new rotateX step in the Metal view matrix. The HPIView preview container no longer wastes its lower third on a centered title/size block: the filename and byte count now sit as a compact footer so the 3D view and piece outline get the rest of the pane. Adds auto-fit. UnitModel.maxWorldExtent walks the piece tree once on load and the view sizes the scene around it, so large buildings like armasy no longer open zoomed past the viewport. Window resizes and piece changes re-run the fit. Co-Authored-By: Claude Opus 4.7 (1M context) --- HPIView/HPIView.xcodeproj/project.pbxproj | 4 + HPIView/HPIView/HpiDocument.swift | 37 ++-- HPIView/HPIView/ModelView.swift | 119 +++++++++--- HPIView/HPIView/ModelViewRenderer+Metal.swift | 22 ++- .../ModelViewRenderer+MetalShaderTypes.h | 9 +- .../ModelViewRenderer+MetalShaders.metal | 5 + HPIView/HPIView/PieceHierarchyView.swift | 173 ++++++++++++++++++ .../Sources/SwiftTA-Core/Filesystem.swift | 23 ++- .../SwiftTA-Core/UnitModel+Bounds.swift | 37 ++++ .../UnitScript+PieceReferences.swift | 97 ++++++++++ TAassets/TAassets.xcodeproj/project.pbxproj | 4 + TAassets/TAassets/AppDelegate.swift | 55 +++++- TAassets/TAassets/PieceHierarchyView.swift | 173 ++++++++++++++++++ TAassets/TAassets/TaassetsDocument.swift | 70 ++++++- TAassets/TAassets/UnitBrowser.swift | 59 +++--- TAassets/TAassets/UnitView.swift | 54 +++++- .../TAassets/UnitViewRenderer+Metal.swift | 8 +- .../UnitViewRenderer+MetalShaderTypes.h | 1 + .../UnitViewRenderer+MetalShaders.metal | 10 +- 19 files changed, 871 insertions(+), 89 deletions(-) create mode 100644 HPIView/HPIView/PieceHierarchyView.swift create mode 100644 SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift create mode 100644 SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+PieceReferences.swift create mode 100644 TAassets/TAassets/PieceHierarchyView.swift diff --git a/HPIView/HPIView.xcodeproj/project.pbxproj b/HPIView/HPIView.xcodeproj/project.pbxproj index fdb7c16..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 */, diff --git a/HPIView/HPIView/HpiDocument.swift b/HPIView/HPIView/HpiDocument.swift index 477c2d6..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) @@ -561,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..fee7c6b 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 } @@ -389,15 +396,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 +417,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..45f5de0 --- /dev/null +++ b/HPIView/HPIView/PieceHierarchyView.swift @@ -0,0 +1,173 @@ +// +// 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: " ") + } + } + nodes = [Node(index: model.root, 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/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift b/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift index 8b4fd9f..affa50e 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) 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..02207de --- /dev/null +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift @@ -0,0 +1,37 @@ +// +// UnitModel+Bounds.swift +// SwiftTA-Core +// + +import Foundation + +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 + accumulate(pieceIndex: root, 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/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/TAassets/TAassets.xcodeproj/project.pbxproj b/TAassets/TAassets.xcodeproj/project.pbxproj index a2cd4f6..44b540d 100644 --- a/TAassets/TAassets.xcodeproj/project.pbxproj +++ b/TAassets/TAassets.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 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 */; }; 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 +86,7 @@ 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 = ""; }; 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 +175,7 @@ B5C556491E2C86A2001BEFAB /* AppDelegate.swift */, B5C5564D1E2C86A2001BEFAB /* TaassetsDocument.swift */, B5C556901E353FC1001BEFAB /* UnitBrowser.swift */, + FACE0001000001000000FACE /* PieceHierarchyView.swift */, B5C5567F1E2F1262001BEFAB /* FileBrowser.swift */, B5E26F4F1EE4813A006C329B /* MapBrowser.swift */, B5C556811E2F1747001BEFAB /* FinderView.swift */, @@ -360,6 +363,7 @@ F08A641D20EEAA47001E5982 /* ModelView+Opengl.swift in Sources */, F0CD87CB20FFB5D60012B1C8 /* MapView+Cocoa.swift in Sources */, B5C556911E353FC1001BEFAB /* UnitBrowser.swift in Sources */, + FACE0002000001000000FACE /* PieceHierarchyView.swift in Sources */, F015C79820AE261400873642 /* UnitViewRenderer+OpenglLegacy.swift in Sources */, B5E26F441EE3745F006C329B /* UnitView+Opengl.swift in Sources */, F08A641920EEAA47001E5982 /* ModelViewRenderer+OpenglLegacy.swift in Sources */, diff --git a/TAassets/TAassets/AppDelegate.swift b/TAassets/TAassets/AppDelegate.swift index 8579cf1..3e37600 100644 --- a/TAassets/TAassets/AppDelegate.swift +++ b/TAassets/TAassets/AppDelegate.swift @@ -11,19 +11,70 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { + private let modsMenu = NSMenu(title: "Mods") + override init() { super.init() 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 vanilla = NSMenuItem(title: "Vanilla (no mod)", + action: #selector(TaassetsDocument.activateMod(_:)), + keyEquivalent: "") + vanilla.target = document + vanilla.representedObject = nil + vanilla.state = (document.currentModURL == nil) ? .on : .off + menu.addItem(vanilla) + + let mods = document.availableMods + if mods.isEmpty { + menu.addItem(NSMenuItem.separator()) + let none = NSMenuItem(title: "No mods found in /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(TaassetsDocument.activateMod(_:)), + keyEquivalent: "") + item.target = document + item.representedObject = modURL + item.state = (document.currentModURL == modURL) ? .on : .off + menu.addItem(item) + } + } } diff --git a/TAassets/TAassets/PieceHierarchyView.swift b/TAassets/TAassets/PieceHierarchyView.swift new file mode 100644 index 0000000..259aa8e --- /dev/null +++ b/TAassets/TAassets/PieceHierarchyView.swift @@ -0,0 +1,173 @@ +// +// 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: " ") + } + } + nodes = [Node(index: model.root, 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/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index 80a8ab7..74925ed 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -13,32 +13,78 @@ 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) } - + 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) } - + + baseURL = directoryURL + currentModURL = nil + try loadFilesystem() + } + + 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 label = currentModURL.map { "\(baseURL.lastPathComponent) + mod:\($0.lastPathComponent)" } ?? baseURL.lastPathComponent + Swift.print("\(label) filesystem load time: \(end.timeIntervalSince(begin)) seconds") + let sidedata = try filesystem.openFile(at: "gamedata/sidedata.tdf") sides = try SideInfo.load(contentsOf: sidedata) } + @IBAction func activateMod(_ sender: NSMenuItem) { + let newMod = sender.representedObject as? URL + guard newMod != currentModURL else { return } + let previous = currentModURL + currentModURL = newMod + do { + try loadFilesystem() + for wc in windowControllers { + if let vc = wc.contentViewController as? TaassetsViewController { + vc.shared = TaassetsSharedState(filesystem: filesystem, sides: sides) + vc.reloadCurrentContent() + } + } + } catch { + currentModURL = previous + NSAlert(error: error).runModal() + } + } + } class TaassetsDocumentController: NSDocumentController { @@ -102,18 +148,24 @@ class TaassetsViewController: NSViewController { } @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() { + if let button = selectedButton { + showSelectedContent(for: button) + } + } func showSelectedContent(for button: NSButton) { switch button { diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index fac136a..5f17a83 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -228,11 +228,16 @@ extension UnitBrowserSharedState { } } -class UnitDetailViewController: NSViewController { - +class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate { + var shared = UnitBrowserSharedState.empty let unitView = UnitViewController() - + let pieceView = PieceHierarchyView(frame: .zero) + + func pieceHierarchyView(_ view: PieceHierarchyView, didSelectPieceAt index: UnitModel.Pieces.Index?) { + unitView.setHighlightedPiece(index) + } + func load(_ unit: UnitInfo) throws { unitTitle = unit.object let modelFile = try shared.filesystem.openFile(at: "objects3d/" + unit.object + ".3DO") @@ -242,12 +247,14 @@ class UnitDetailViewController: NSViewController { let atlas = UnitTextureAtlas(for: model.textures, from: shared.textures) let palette = try Palette.texturePalette(for: unit, in: shared.sides, from: shared.filesystem) try unitView.load(unit, model, script, atlas, shared.filesystem, palette) - + pieceView.apply(model: model, script: script) + //try tempSaveAtlasToFile(atlas, palette) } - + func clear() { unitView.clear() + pieceView.clear() } private func tempSaveAtlasToFile(_ atlas: UnitTextureAtlas, _ palette: Palette) throws { @@ -286,10 +293,11 @@ class UnitDetailViewController: NSViewController { } private class ContainerView: NSView { - + unowned let titleLabel: NSTextField let emptyContentView: NSView - + let pieceAccessory: NSView + weak var contentView: NSView? { didSet { guard contentView != oldValue else { return } @@ -306,51 +314,60 @@ class UnitDetailViewController: NSViewController { } } } - - override init(frame frameRect: NSRect) { + + init(frame frameRect: NSRect, pieceAccessory: NSView) { let titleLabel = NSTextField(labelWithString: "Title") titleLabel.font = NSFont.systemFont(ofSize: 18) titleLabel.textColor = NSColor.labelColor let contentBox = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: 32)) - + self.titleLabel = titleLabel self.emptyContentView = contentBox + self.pieceAccessory = pieceAccessory super.init(frame: frameRect) - + addSubview(contentBox) addSubview(titleLabel) - + pieceAccessory.translatesAutoresizingMaskIntoConstraints = false + addSubview(pieceAccessory) + contentBox.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false - + addContentViewConstraints(contentBox) NSLayoutConstraint.activate([ titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), - ]) + pieceAccessory.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), + pieceAccessory.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + pieceAccessory.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, 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), + contentBox.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.55), titleLabel.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 8), ]) } - + } - + override func loadView() { - let container = ContainerView(frame: NSRect(x: 0, y: 0, width: 256, height: 256)) + let container = ContainerView(frame: NSRect(x: 0, y: 0, width: 256, height: 512), + pieceAccessory: pieceView) self.view = container - + addChild(unitView) container.contentView = unitView.view + pieceView.selectionDelegate = self } } diff --git a/TAassets/TAassets/UnitView.swift b/TAassets/TAassets/UnitView.swift index 9932005..ddd6c73 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -40,7 +40,12 @@ 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 + let newUnit = UnitInstance( info: info, model: model, @@ -67,11 +72,20 @@ 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 + } 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 + let extentFit = extent * 2.3 + let baseWidth = max(footprintWidth, extentFit) + let w = (baseWidth > 0 ? baseWidth : footprintWidth) / GameFloat(viewState.zoom) viewState.sceneSize = Size2f(width: w, height: w * viewState.aspectRatio) } @@ -102,7 +116,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,6 +146,18 @@ 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() default: () } @@ -183,7 +226,10 @@ struct UnitViewState { var model: UnitModel? var modelInstance: UnitModel.Instance? - + + var zoom: Float = 1.0 + var highlightedPieceIndex: Int32 = -1 + 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..ac5968c 100644 --- a/TAassets/TAassets/UnitViewRenderer+Metal.swift +++ b/TAassets/TAassets/UnitViewRenderer+Metal.swift @@ -61,7 +61,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 +78,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 diff --git a/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h b/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h index d4cd43f..c26f711 100644 --- a/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h +++ b/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h @@ -68,6 +68,7 @@ typedef struct vector_float3 lightPosition; vector_float3 viewPosition; matrix_float4x4 pieces[40]; + 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; } From cb163b8e15faa0ccb19b31c9fa7c57c87aaed5c6 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 20:04:43 -0700 Subject: [PATCH 03/54] Adds slow-motion playback with pause, step, and script triggers. Introduces PlaybackControlsView below the 3D preview in the unit detail pane. A speed slider scales the script's deltaTime from 0x to 2x, a Pause toggle freezes the timeline without touching the model state, Step advances one thirtieth of a second of script execution while paused, and a pull-down menu launches any COB module by name so building internals like the Activate sequence can be observed piece by piece. UnitViewController gains the matching API (setPlaybackSpeed, stepOnce, startScript, availableScriptFunctions) and updateAnimatingState scales deltaTime accordingly, short- circuiting when speed is zero so Metal still renders the frozen frame. Fixes a long-standing layout bug where the detail controller's view was given autoresizingMask = [.width, .width] instead of [.width, .height], so the right pane didn't grow vertically with the window; that kept the 3D viewport small and pushed the piece outline into a cramped strip at the bottom. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets.xcodeproj/project.pbxproj | 4 + TAassets/TAassets/PlaybackControlsView.swift | 147 +++++++++++++++++++ TAassets/TAassets/UnitBrowser.swift | 33 ++++- TAassets/TAassets/UnitView.swift | 60 ++++++-- 4 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 TAassets/TAassets/PlaybackControlsView.swift diff --git a/TAassets/TAassets.xcodeproj/project.pbxproj b/TAassets/TAassets.xcodeproj/project.pbxproj index 44b540d..8c4687a 100644 --- a/TAassets/TAassets.xcodeproj/project.pbxproj +++ b/TAassets/TAassets.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 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 */; }; 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 */; }; @@ -87,6 +88,7 @@ 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 = ""; }; 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 = ""; }; @@ -176,6 +178,7 @@ B5C5564D1E2C86A2001BEFAB /* TaassetsDocument.swift */, B5C556901E353FC1001BEFAB /* UnitBrowser.swift */, FACE0001000001000000FACE /* PieceHierarchyView.swift */, + FACE0005000003000000FACE /* PlaybackControlsView.swift */, B5C5567F1E2F1262001BEFAB /* FileBrowser.swift */, B5E26F4F1EE4813A006C329B /* MapBrowser.swift */, B5C556811E2F1747001BEFAB /* FinderView.swift */, @@ -364,6 +367,7 @@ F0CD87CB20FFB5D60012B1C8 /* MapView+Cocoa.swift in Sources */, B5C556911E353FC1001BEFAB /* UnitBrowser.swift in Sources */, FACE0002000001000000FACE /* PieceHierarchyView.swift in Sources */, + FACE0006000003000000FACE /* PlaybackControlsView.swift in Sources */, F015C79820AE261400873642 /* UnitViewRenderer+OpenglLegacy.swift in Sources */, B5E26F441EE3745F006C329B /* UnitView+Opengl.swift in Sources */, F08A641920EEAA47001E5982 /* ModelViewRenderer+OpenglLegacy.swift in Sources */, 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/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index 5f17a83..b54c905 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -126,7 +126,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,16 +228,27 @@ extension UnitBrowserSharedState { } } -class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate { +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 let modelFile = try shared.filesystem.openFile(at: "objects3d/" + unit.object + ".3DO") @@ -248,6 +259,7 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate { let palette = try Palette.texturePalette(for: unit, in: shared.sides, from: shared.filesystem) 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) } @@ -255,6 +267,7 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate { func clear() { unitView.clear() pieceView.clear() + playbackControls.reset(scriptFunctions: []) } private func tempSaveAtlasToFile(_ atlas: UnitTextureAtlas, _ palette: Palette) throws { @@ -361,13 +374,27 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate { } override func loadView() { + 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: pieceView) + 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 ddd6c73..3533562 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -45,6 +45,7 @@ class UnitViewController: NSViewController { viewState.rotateY = 0 viewState.rotateZ = 160 viewState.highlightedPieceIndex = -1 + viewState.playbackSpeed = 1.0 let newUnit = UnitInstance( info: info, @@ -79,6 +80,30 @@ class UnitViewController: NSViewController { 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 { return } + unit.scriptContext.startScript(name) + 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 footprintWidth = GameFloat( ((unit?.info.footprint.width ?? 2) + 8) * ModelViewState.gridSize ) @@ -165,35 +190,43 @@ extension UnitViewController: UnitViewStateProvider { 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) + 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.applyAnimations(to: &unit.modelInstance, for: GameFloat(scaledDelta)) + 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 } @@ -229,6 +262,7 @@ struct UnitViewState { var zoom: Float = 1.0 var highlightedPieceIndex: Int32 = -1 + var playbackSpeed: Float = 1.0 var isMoving = false var speed: GameFloat = 0 From 57db1f1a1cdf075b74a8642082d7885f0f3a237b Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 20:14:24 -0700 Subject: [PATCH 04/54] Finds unit FBIs recursively so mod-supplied units show up. Mods like TAESC and UH nest additional unit definitions under units//*.fbi, but FileSystem.Directory.files(withExtension:) only walked the top level, so those units never reached the unit browser. Adds an allFiles(withExtension:) walker that recurses into every subdirectory and switches both UnitBrowser and UnitInfo.collectUnits to use it. Deduplicates by base name so a mod override of a vanilla unit still appears exactly once, and logs the resolved unit count so mod troubleshooting from the console is obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/SwiftTA-Core/Filesystem.swift | 16 ++++++++++++++- .../Sources/SwiftTA-Core/UnitInfo.swift | 20 +++++++++---------- TAassets/TAassets/UnitBrowser.swift | 10 +++++----- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift b/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift index affa50e..4553492 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/Filesystem.swift @@ -320,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/UnitInfo.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitInfo.swift index d661da5..8fd2121 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitInfo.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitInfo.swift @@ -133,27 +133,27 @@ public extension UnitInfo { public extension UnitInfo { static func collectUnits(from filesystem: FileSystem) -> [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/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index b54c905..587db4f 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -60,16 +60,16 @@ class UnitBrowserViewController: NSViewController, ContentViewController { 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") } + var seenNames = Set() + let units = unitsDirectory.allFiles(withExtension: "fbi") .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.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)") + textures = ModelTexturePack(loadFrom: shared.filesystem) } From 44cb1223fba0b167eef61ba9017d0d22acfd91a0 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 20:32:56 -0700 Subject: [PATCH 05/54] Lets TAassets open folders that only contain mod archives. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously File → Open required the selected directory to supply gamedata/sidedata.tdf, so a standalone folder of ufo/gp3/ccx files (e.g. ~/tafiles/mods/taesc on its own) failed to load the document. Now TaassetsDocument treats the sidedata as optional: if the file is missing or malformed, it logs a note and proceeds with an empty side list. Adds a palette fallback in the unit browser that tries Palette.texturePalette first, then Palette.standardTaPalette, and finally a neutral Palette() so the 3D preview still renders when the mod folder does not carry matching palette data. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/TaassetsDocument.swift | 9 +++++++-- TAassets/TAassets/UnitBrowser.swift | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/TAassets/TAassets/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index 74925ed..b6a881a 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -62,8 +62,13 @@ class TaassetsDocument: NSDocument { let label = currentModURL.map { "\(baseURL.lastPathComponent) + mod:\($0.lastPathComponent)" } ?? baseURL.lastPathComponent Swift.print("\(label) filesystem load time: \(end.timeIntervalSince(begin)) seconds") - let sidedata = try filesystem.openFile(at: "gamedata/sidedata.tdf") - sides = try SideInfo.load(contentsOf: sidedata) + 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) { diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index 587db4f..35475e0 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -256,7 +256,7 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl 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) try unitView.load(unit, model, script, atlas, shared.filesystem, palette) pieceView.apply(model: model, script: script) playbackControls.reset(scriptFunctions: unitView.availableScriptFunctions) @@ -269,6 +269,16 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl pieceView.clear() playbackControls.reset(scriptFunctions: []) } + + 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 { let pixelData = atlas.build(from: shared.filesystem, using: palette) From edcf6337639408fcdafeb7ac90824c7a2fda0bf1 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 20:38:58 -0700 Subject: [PATCH 06/54] Fixes out-of-bounds when palette defaults are used. Palette.init() built a 255-entry color array, but palette lookups index from a UInt8 so anything with byte 255 crashed the moment the new mod-folder fallback was exercised (Adv. Aircraft Plant's texture atlas hit it on selection). Bumps the default to a full 256 entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- SwiftTA-Core/Sources/SwiftTA-Core/Palette.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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] From 7d7069cdc54eb78a23b8fc78f6132c8ca8795788 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 20:46:46 -0700 Subject: [PATCH 07/54] Finds buildpics across more file types and paths. Mods regularly ship unit thumbnails outside the vanilla unitpics/.PCX convention: TAESC builds keep JPEGs in anims/buildpic/, other packs use PNG or BMP. Broadens the lookup to try PCX, BMP, PNG, JPG, JPEG, and TGA under unitpics/ and then JPG/JPEG/PNG/BMP under anims/buildpic/ before giving up, so mod units get thumbnails in the list. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/UnitBrowser.swift | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index 35475e0..204f749 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -74,16 +74,31 @@ class UnitBrowserViewController: NSViewController, ContentViewController { } final func buildpic(for unitName: String) -> NSImage? { - if let file = try? shared.filesystem.openFile(at: "unitpics/" + unitName + ".PCX") { - return try? NSImage(pcxContentsOf: file) - } - else if let file = try? shared.filesystem.openFile(at: "anims/buildpic/" + unitName + ".jpg") { - let data = file.readDataToEndOfFile() - return NSImage(data: data) + let fs = shared.filesystem + + if let unitpics = fs.root[directory: "unitpics"] { + if let file = unitpics[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 = unitpics[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 } } From 67ad8378824d468020805cb092d074d2b072d2ec Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 20:54:41 -0700 Subject: [PATCH 08/54] Finds units anywhere in the merged filesystem and labels the base. Some mod archives stash their unit FBIs outside a units/ directory. When nothing shows up under units/, UnitBrowser now falls back to walking the entire filesystem for *.fbi so mod-supplied units still surface in the list. Adds startup logs listing the root directory entries and the resolved unit count, which is the quickest way to eyeball whether the selected mod actually overlaid. Relabels the first Mods-menu entry from "Vanilla (no mod)" to "Base only: ". When the user opens a mod folder directly the label now matches what is actually loaded instead of implying a vanilla install. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/AppDelegate.swift | 18 ++++++++++-------- TAassets/TAassets/UnitBrowser.swift | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/TAassets/TAassets/AppDelegate.swift b/TAassets/TAassets/AppDelegate.swift index 3e37600..82b20d1 100644 --- a/TAassets/TAassets/AppDelegate.swift +++ b/TAassets/TAassets/AppDelegate.swift @@ -47,18 +47,20 @@ extension AppDelegate: NSMenuDelegate { return } - let vanilla = NSMenuItem(title: "Vanilla (no mod)", - action: #selector(TaassetsDocument.activateMod(_:)), - keyEquivalent: "") - vanilla.target = document - vanilla.representedObject = nil - vanilla.state = (document.currentModURL == nil) ? .on : .off - menu.addItem(vanilla) + let baseName = document.baseURL?.lastPathComponent ?? "Base" + let baseTitle = "Base only: \(baseName)" + let baseItem = NSMenuItem(title: baseTitle, + action: #selector(TaassetsDocument.activateMod(_:)), + keyEquivalent: "") + baseItem.target = document + 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 /mods", + let none = NSMenuItem(title: "No mods found in \(baseName)/mods", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index 204f749..efad73b 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -59,9 +59,20 @@ class UnitBrowserViewController: NSViewController, ContentViewController { override func viewDidLoad() { let begin = Date() - let unitsDirectory = shared.filesystem.root[directory: "units"] ?? FileSystem.Directory() + + 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 fbiFiles = (shared.filesystem.root[directory: "units"] ?? FileSystem.Directory()) + .allFiles(withExtension: "fbi") + + if fbiFiles.isEmpty { + print("No FBIs under units/ — scanning entire filesystem") + fbiFiles = shared.filesystem.root.allFiles(withExtension: "fbi") + } + var seenNames = Set() - let units = unitsDirectory.allFiles(withExtension: "fbi") + let units = fbiFiles .sorted { FileSystem.sortNames($0.name, $1.name) } .filter { seenNames.insert($0.baseName.lowercased()).inserted } .compactMap { try? shared.filesystem.openFile($0) } From 95ef043b29d33cda5f98245836af80b4daea0262 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 21:32:46 -0700 Subject: [PATCH 09/54] Auto-pairs mod folders with a parent base and guards COB divide. TaassetsDocument.read now inspects the opened folder's location. If the folder sits directly under a "mods" directory whose grandparent contains TA archives, the grandparent loads as the base and the opened folder is treated as the active mod. Opening ~/tafiles/mods/taesc directly therefore behaves the same as opening ~/tafiles and choosing taesc from the Mods menu, so the mod can resolve textures and palettes that live in the vanilla archives. Hardens the COB VM against division by zero. Some mod-supplied scripts (confirmed in TAESC) exercise the divide opcode against a zero right-hand side, which trapped Swift's / operator and crashed the app the moment the unit was selected. The operator is now guarded to return 0 on zero divisor so the animation VM keeps running. Expands unit and buildpic discovery so mod-only folders populate their lists. UnitBrowser walks the entire merged filesystem for *.fbi instead of just units/, catching TAESC's unitsE/ tree. The buildpic search iterates every root directory whose name starts with unitpic (unitpics, unitpicE, unitpicsE) with PCX plus common image formats, falling back to anims/buildpic/. AppDelegate routes the Mods-menu action through its own IBAction so dispatch no longer depends on NSDocument's validator chain, and the first menu entry now reads "Base only: " instead of a generic "Vanilla" label. Extends notes/SwiftTA_Apple_Silicon_Bootstrap.md with a feature-work section documenting the piece inspector, camera controls, playback controls, mod support, and outstanding follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitScript+Instructions.swift | 2 +- TAassets/TAassets/AppDelegate.swift | 19 +++- TAassets/TAassets/TaassetsDocument.swift | 31 ++++++- TAassets/TAassets/UnitBrowser.swift | 32 ++++--- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 87 +++++++++++++++++++ 5 files changed, 153 insertions(+), 18 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 326490a..bcf94c1 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -63,7 +63,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, diff --git a/TAassets/TAassets/AppDelegate.swift b/TAassets/TAassets/AppDelegate.swift index 82b20d1..bf9466c 100644 --- a/TAassets/TAassets/AppDelegate.swift +++ b/TAassets/TAassets/AppDelegate.swift @@ -15,6 +15,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { override init() { super.init() + setbuf(stdout, nil) + setbuf(stderr, nil) let _ = TaassetsDocumentController.shared } @@ -50,9 +52,9 @@ extension AppDelegate: NSMenuDelegate { let baseName = document.baseURL?.lastPathComponent ?? "Base" let baseTitle = "Base only: \(baseName)" let baseItem = NSMenuItem(title: baseTitle, - action: #selector(TaassetsDocument.activateMod(_:)), + action: #selector(activateModFromMenu(_:)), keyEquivalent: "") - baseItem.target = document + baseItem.target = self baseItem.representedObject = nil baseItem.state = (document.currentModURL == nil) ? .on : .off menu.addItem(baseItem) @@ -69,14 +71,23 @@ extension AppDelegate: NSMenuDelegate { menu.addItem(NSMenuItem.separator()) for modURL in mods { let item = NSMenuItem(title: modURL.lastPathComponent, - action: #selector(TaassetsDocument.activateMod(_:)), + action: #selector(activateModFromMenu(_:)), keyEquivalent: "") - item.target = document + 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/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index b6a881a..db3b0c3 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -50,11 +50,27 @@ class TaassetsDocument: NSDocument { guard directoryURL.isFileURL, fm.fileExists(atPath: directoryURL.path, isDirectory: &dirCheck), dirCheck.boolValue else { throw NSError(domain: NSOSStatusErrorDomain, code: readErr, userInfo: nil) } - baseURL = directoryURL - currentModURL = 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: baseURL, modDirectory: currentModURL) @@ -73,18 +89,26 @@ class TaassetsDocument: NSDocument { @IBAction func activateMod(_ sender: NSMenuItem) { let newMod = sender.representedObject as? URL - guard newMod != currentModURL else { return } + 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() } @@ -167,6 +191,7 @@ class TaassetsViewController: NSViewController { } func reloadCurrentContent() { + Swift.print(" reloadCurrentContent called; selectedButton=\(String(describing: selectedButton?.identifier?.rawValue))") if let button = selectedButton { showSelectedContent(for: button) } diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index efad73b..3d1c041 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -63,14 +63,21 @@ class UnitBrowserViewController: NSViewController, ContentViewController { 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 fbiFiles = (shared.filesystem.root[directory: "units"] ?? FileSystem.Directory()) - .allFiles(withExtension: "fbi") - - if fbiFiles.isEmpty { - print("No FBIs under units/ — scanning entire filesystem") - fbiFiles = shared.filesystem.root.allFiles(withExtension: "fbi") + 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) } @@ -79,7 +86,7 @@ class UnitBrowserViewController: NSViewController, ContentViewController { .compactMap { try? UnitInfo(contentsOf: $0) } self.units = units let end = Date() - print("UnitInfo list load time: \(end.timeIntervalSince(begin)) seconds; units found: \(units.count)") + print("UnitInfo list load time: \(end.timeIntervalSince(begin)) seconds; units found: \(units.count) (from \(fbiFiles.count) FBI files)") textures = ModelTexturePack(loadFrom: shared.filesystem) } @@ -87,14 +94,19 @@ class UnitBrowserViewController: NSViewController, ContentViewController { final func buildpic(for unitName: String) -> NSImage? { let fs = shared.filesystem - if let unitpics = fs.root[directory: "unitpics"] { - if let file = unitpics[file: unitName + ".pcx"], + 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 + } + + 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 = unitpics[file: unitName + "." + ext], + 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 } diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 427d19c..8b27d00 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -118,3 +118,90 @@ If the goal is to evolve TAassets into a focused 3DO/asset inspector + exporter: - [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. From 883edbbd4011fde5b999d8e256125d2812b2678c Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Tue, 21 Apr 2026 21:42:16 -0700 Subject: [PATCH 10/54] Adds a fork callout documenting the Apple-silicon and mod-browsing work. Surfaces the new TAassets and HPIView features at the top of the README so the fork's intent is obvious from the repo landing page. Points at notes/SwiftTA_Apple_Silicon_Bootstrap.md for the full write-up, summarizes the piece inspector, COB playback, camera controls, and mod-aware filesystem, and gives copy-paste build and usage instructions for both apps. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 026c456..29c91c2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,38 @@ # SwiftTA +> **Fork notes (Apple silicon + mod browsing):** This branch of the original [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) focuses on making **TAassets** and **HPIView** useful asset inspectors on current Xcode / macOS with Apple silicon, and extends TAassets with a piece-level 3DO inspector, COB playback controls, and a mod-aware filesystem loader. Full write-up with code paths and troubleshooting is in [notes/SwiftTA_Apple_Silicon_Bootstrap.md](notes/SwiftTA_Apple_Silicon_Bootstrap.md). +> +> **What's new in this fork** +> - **Builds 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 (new Metal uniform + flat piece-index interpolant). `Script Refs` column lists every COB module that manipulates each piece, extracted statically from the bytecode. +> - **COB playback controls** (TAassets) — pause / step / 0×–2× speed slider, plus a "Run script…" pull-down for every module in the unit's COB so you can trigger `Activate`, `QueryPrimary`, etc. on demand and watch building internals animate piece by piece. +> - **Camera controls** — scroll / pinch zoom, shift-drag pitch, `=` / `-` / `0` keys. Auto-fits the model on load so large buildings don't open zoomed past the viewport; re-fits 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. +> - **Tolerant standalone loading** — `gamedata/sidedata.tdf` is optional; palette lookup falls back through side → standard → neutral. +> - **HPIView extraction** — the previously-stub `Extract All` menu item is implemented, so you can now dump the entire archive to a folder. +> - **Script VM hardening** — the COB `divide` opcode no longer traps on divide-by-zero (observed in TAESC scripts). +> +> **Using the prebuilt TAassets** +> 1. Clone this repo, open `SwiftTA.xcworkspace` in Xcode 26+, or build from CLI: +> ``` +> xcodebuild -workspace SwiftTA.xcworkspace -scheme TAassets \ +> -destination 'platform=macOS,arch=arm64' \ +> -configuration Release build +> ``` +> 2. Copy `build/.../Release/TAassets.app` anywhere you like (e.g. `~/Applications`). +> 3. First launch: right-click the app → Open (ad-hoc signed, so Gatekeeper asks once). +> 4. `File → Open…` → pick any directory of TA archives. Unit browser, file browser, and map browser all populate. +> - Switch mods via the **Mods** menu. +> - Open `/mods/` directly — it's treated as `base + mod` automatically. +> +> **Using HPIView** +> 1. Same build command with `-scheme HPIView`. +> 2. `File → Open…` on any `.hpi`, `.ufo`, `.ccx`, `.gp3`, or `.gpf`. +> 3. Drill into `objects3d/` → click a `.3DO` → browse the piece tree and script references on the right. Split divider resizes the outline. +> 4. Extract single files, folders, or the whole archive from the **File** menu. +> +> --- + 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. From 3b7214dfc4b3737b23dfcc9108bf7824b61cc162 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 07:22:20 -0700 Subject: [PATCH 11/54] Tightens up the TAassets browser chrome and map viewer. Reworks the map detail pane so the selected map fills the available viewport. The map name moves from a giant centered label sitting below a 62% content box to a compact header strip that also shows planet, player count, wind, tidal, and gravity from the OTA. The detail container pins content to the full remaining height and the sidebar view's autoresizing mask is corrected ([.width, .width] was a typo mirroring the one already fixed on the unit browser). Swaps the sidebar icons from stock AppKit images to SF Symbols where available: cube.fill for Units, scope for Weapons, map.fill for Maps, folder.fill for Files. Guarded by #available(macOS 11) so the 10.13 deployment target still builds on older systems. Adds a filter search field above the unit and map lists. The unit search matches name, title, description, and object; the map search matches the file base name. Typing filters live. MapView now auto-fits the loaded map to the viewport instead of always opening at 1:1 magnification (some maps are 16k pixels wide, and a 500px pane at 1:1 was useless). Wraps the scroll view's document view in a new MapOverlayView that draws numbered, numbered start-position markers from the OTA schema. Markers scroll and zoom with the map and expose a showMarkers toggle for future UI wiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/MapBrowser.swift | 148 ++++++++++++++++------- TAassets/TAassets/MapView+Metal.swift | 116 +++++++++++++----- TAassets/TAassets/TaassetsDocument.swift | 22 +++- TAassets/TAassets/UnitBrowser.swift | 61 ++++++++-- 4 files changed, 260 insertions(+), 87 deletions(-) diff --git a/TAassets/TAassets/MapBrowser.swift b/TAassets/TAassets/MapBrowser.swift index cce19c0..3072f29 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,58 @@ class MapInfoCell: NSTableCellView { } class MapDetailViewController: NSViewController { - + let mapView = MapViewController() - + func loadMap(in otaFile: FileSystem.File, from filesystem: FileSystem) throws { let name = otaFile.baseName try mapView.load(name, from: filesystem) mapTitle = name + + 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 = "" } - + 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 { - + unowned let titleLabel: NSTextField + unowned let detailLabel: NSTextField let emptyContentView: NSView - + weak var contentView: NSView? { didSet { guard contentView != oldValue else { return } @@ -195,42 +244,55 @@ class MapDetailViewController: NSViewController { } } } - + 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 contentBox = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: 32)) - + self.titleLabel = titleLabel + self.detailLabel = detailLabel self.emptyContentView = contentBox super.init(frame: frameRect) - + addSubview(contentBox) addSubview(titleLabel) - + addSubview(detailLabel) + contentBox.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false - + detailLabel.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(equalTo: self.trailingAnchor, constant: -8), + detailLabel.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor), ]) } - + 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: titleLabel.bottomAnchor, constant: 6), + contentBox.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -4), ]) } - + } override func loadView() { diff --git a/TAassets/TAassets/MapView+Metal.swift b/TAassets/TAassets/MapView+Metal.swift index 3d63e04..b16f5ab 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 @@ -132,46 +142,53 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { } try? renderer.load(map, using: palette) + mapResolution = map.resolution emptyView.frame = NSRect(size: map.resolution) - - scrollView.magnification = 1.0 + 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 + 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 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 = [] } func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { @@ -218,12 +235,51 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { } } - private class Dummy: NSView { - override var isFlipped: Bool { - return true +} + +class MapOverlayView: NSView { + + override var isFlipped: Bool { true } + + var startPositions: [Point2] = [] { didSet { needsDisplay = true } } + var showMarkers: Bool = true { didSet { needsDisplay = true } } + + 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 showMarkers, !startPositions.isEmpty, let ctx = NSGraphicsContext.current?.cgContext else { return } + + 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/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index db3b0c3..5e4f4b2 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -167,7 +167,9 @@ class TaassetsViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - + + applySidebarIcons() + // There will be nothing selected the first time this view appears. // Select a default in this case. if selectedButton == nil { @@ -175,6 +177,24 @@ class TaassetsViewController: NSViewController { didChangeSelection(unitsButton) } } + + 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) { diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index 3d1c041..faeed0f 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -10,51 +10,85 @@ 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() { @@ -84,6 +118,7 @@ class UnitBrowserViewController: NSViewController, ContentViewController { .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; units found: \(units.count) (from \(fbiFiles.count) FBI files)") From 97aab0a1b0fbe09b9fbdeb8aaba86e464e0278de Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 07:30:54 -0700 Subject: [PATCH 12/54] Shifts the sidebar below the traffic lights and adds unit-info headers. Applies 28pt of top padding to the sidebar stack so the Units icon no longer overlaps the red/yellow/green window controls. Bottom inset is also bumped so spacing stays balanced. Reworks UnitDetailViewController's title strip to match the map detail pane: the object name sits as a small header alongside a secondary detail line listing unit.title, description, side, tedclass, footprint, and maxVelocity when present. The 3D preview now pins directly under the header instead of floating below an oversized title. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/TaassetsDocument.swift | 7 ++++ TAassets/TAassets/UnitBrowser.swift | 44 ++++++++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/TAassets/TAassets/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index 5e4f4b2..43c9afa 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -169,6 +169,7 @@ class TaassetsViewController: NSViewController { super.viewWillAppear() applySidebarIcons() + applySidebarSpacing() // There will be nothing selected the first time this view appears. // Select a default in this case. @@ -178,6 +179,12 @@ class TaassetsViewController: NSViewController { } } + 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)] = [ diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index faeed0f..855f975 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -323,7 +323,8 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl } 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") @@ -341,6 +342,21 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl 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 { @@ -391,6 +407,7 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl private class ContainerView: NSView { unowned let titleLabel: NSTextField + unowned let detailLabel: NSTextField let emptyContentView: NSView let pieceAccessory: NSView @@ -412,30 +429,43 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl } init(frame frameRect: NSRect, pieceAccessory: NSView) { - 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 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.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), pieceAccessory.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8), ]) } @@ -448,9 +478,9 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl 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.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), contentBox.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.55), - titleLabel.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 8), + pieceAccessory.topAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: 6), ]) } From 26fef51371001f6e82be5fbdda8cff95f3016a97 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 07:41:44 -0700 Subject: [PATCH 13/54] Lifts the map-view size ceiling and wires up a weapons browser. Raises DynamicTileMetalTntViewRenderer's maximumDisplaySize from 4096 to 8192 so maps like [ESC] Dark Prime render cleanly on 4K-class Retina displays. Bigger maps (any resolution) still work fine; they simply scroll through the 8192-pixel working window. The grid clamping in computeTileGrid also caps the requested tile area at maximumGridSize so a viewport larger than the pool no longer overflows the pre-sized index or slice buffers. Replaces the empty Weapons sidebar pane with a working browser that walks every weapons/*.tdf recursively, parses each top-level block with TdfParser, and lists the discovered weapons in a searchable table. Selecting a weapon shows its key, source file, weapon type, range, damage table, and full property set. Matches the UX of the unit and map browsers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TntViewRenderer+MetalDynamicTiles.swift | 15 +- TAassets/TAassets.xcodeproj/project.pbxproj | 4 + TAassets/TAassets/TaassetsDocument.swift | 2 +- TAassets/TAassets/WeaponsBrowser.swift | 232 ++++++++++++++++++ 4 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 TAassets/TAassets/WeaponsBrowser.swift diff --git a/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift b/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift index 831e22d..243558b 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 @@ -473,7 +477,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/TAassets/TAassets.xcodeproj/project.pbxproj b/TAassets/TAassets.xcodeproj/project.pbxproj index 8c4687a..0031a53 100644 --- a/TAassets/TAassets.xcodeproj/project.pbxproj +++ b/TAassets/TAassets.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; }; @@ -89,6 +90,7 @@ 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 = ""; }; @@ -179,6 +181,7 @@ B5C556901E353FC1001BEFAB /* UnitBrowser.swift */, FACE0001000001000000FACE /* PieceHierarchyView.swift */, FACE0005000003000000FACE /* PlaybackControlsView.swift */, + FACE0007000004000000FACE /* WeaponsBrowser.swift */, B5C5567F1E2F1262001BEFAB /* FileBrowser.swift */, B5E26F4F1EE4813A006C329B /* MapBrowser.swift */, B5C556811E2F1747001BEFAB /* FinderView.swift */, @@ -368,6 +371,7 @@ 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 */, diff --git a/TAassets/TAassets/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index 43c9afa..ef9505a 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -229,7 +229,7 @@ class TaassetsViewController: NSViewController { 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/WeaponsBrowser.swift b/TAassets/TAassets/WeaponsBrowser.swift new file mode 100644 index 0000000..261f34f --- /dev/null +++ b/TAassets/TAassets/WeaponsBrowser.swift @@ -0,0 +1,232 @@ +// +// 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() + let weaponsDir = shared.filesystem.root[directory: "weapons"] ?? FileSystem.Directory() + let tdfFiles = weaponsDir.allFiles(withExtension: "tdf") + 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 entries = parser.extractObject(normalizeKeys: true) + for (key, object) in entries.subobjects { + let lowerKey = key.lowercased() + guard seen.insert(lowerKey).inserted else { continue } + all.append(WeaponInfo(from: object, key: key, sourceFile: file.name)) + } + } + 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") + } + + @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") + } +} From 08f6c5eb82335761336fb7c00c988a6a199aa8c8 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 07:53:27 -0700 Subject: [PATCH 14/54] Keeps the map viewport in sync and finds more weapons. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Metal map view only refreshed its viewport when the scrollView posted a bounds-changed notification, which would occasionally get dropped around a reload so the second map stopped redrawing even as the overlay markers scrolled. Each draw() now pulls the current clip view bounds directly so scrolling and magnification stay consistent across map swaps. Surfaces a console warning when feature loading throws (commonly due to a missing TA_Features_2013.ccx alongside the base archives) instead of swallowing the error with try?. Broadens the weapons browser to any top-level directory whose name starts with "weapon" (covers the weaponE/weaponsE mod variants that already caused trouble in the unit and unitpic paths) and walks the parsed TDF recursively, identifying weapon blocks by characteristic properties (weapontype, range, reloadtime, damage subobject, …) instead of assuming a flat list. Logs the directories and TDF count on load so empty results are easier to diagnose. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/MapView+Metal.swift | 14 ++++-- TAassets/TAassets/WeaponsBrowser.swift | 63 ++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/TAassets/TAassets/MapView+Metal.swift b/TAassets/TAassets/MapView+Metal.swift index b16f5ab..444903b 100644 --- a/TAassets/TAassets/MapView+Metal.swift +++ b/TAassets/TAassets/MapView+Metal.swift @@ -112,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() @@ -196,10 +200,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) diff --git a/TAassets/TAassets/WeaponsBrowser.swift b/TAassets/TAassets/WeaponsBrowser.swift index 261f34f..6a56be2 100644 --- a/TAassets/TAassets/WeaponsBrowser.swift +++ b/TAassets/TAassets/WeaponsBrowser.swift @@ -97,25 +97,70 @@ class WeaponsBrowserViewController: NSViewController, ContentViewController { override func viewDidLoad() { let begin = Date() - let weaponsDir = shared.filesystem.root[directory: "weapons"] ?? FileSystem.Directory() - let tdfFiles = weaponsDir.allFiles(withExtension: "tdf") + + // 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 entries = parser.extractObject(normalizeKeys: true) - for (key, object) in entries.subobjects { - let lowerKey = key.lowercased() - guard seen.insert(lowerKey).inserted else { continue } - all.append(WeaponInfo(from: object, key: key, sourceFile: file.name)) - } + 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") + 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 looking for blocks that look like weapon definitions. + /// A block is treated as a weapon if it has any of the hallmark properties + /// (`weapontype`, `range`, `weaponvelocity`, `name` alongside damage data, etc.) + /// or if it contains a `damage` subobject. Otherwise the walker descends into + /// nested subobjects so container blocks like `[WEAPONDEFS]` don't hide content. + private static func collectWeapons(from object: TdfParser.Object, + sourceFile: String, + into results: inout [WeaponInfo], + seen: inout Set) { + for (key, sub) in object.subobjects { + let lowerKey = key.lowercased() + if looksLikeWeapon(sub) { + guard seen.insert(lowerKey).inserted else { continue } + results.append(WeaponInfo(from: sub, key: key, sourceFile: sourceFile)) + } else if !sub.subobjects.isEmpty { + collectWeapons(from: sub, sourceFile: sourceFile, into: &results, seen: &seen) + } + } + } + + private static func looksLikeWeapon(_ object: TdfParser.Object) -> Bool { + let weaponishKeys: Set = [ + "weapontype", "range", "weaponvelocity", "weaponlaserdef", + "reloadtime", "accuracy", "areaofeffect", "energypershot", + "metalpershot", "explosiongaf", "startvelocity", "lineofsight" + ] + for key in object.properties.keys where weaponishKeys.contains(key.lowercased()) { + return true + } + if object.subobjects.keys.contains(where: { $0.lowercased() == "damage" }) { + return true + } + return false } @objc private func searchFieldChanged(_ sender: NSSearchField) { From 8b43050f64cdf31fbcec86e67c0901bf38792abb Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 08:03:40 -0700 Subject: [PATCH 15/54] Hides past-edge map pixels, persists window size, and widens weapons. The map fragment shaders used the default clamp_to_edge sampler, so any time the viewport or a partial tile extended beyond the map's pixel area the last row/column of terrain smeared to fill the rest. Both shaders now discard fragments that fall outside the map: the single-quad variant checks texCoord against [0,1], the dynamic tile variant compares world-space position against a new mapSize uniform supplied by both renderers. Samplers also switch to clamp_to_zero so nothing bleeds in if the bounds check misses. TaassetsDocument now opens its window at a reasonable default size (70% of screen width, 80% height, capped at 1600x1100, floored at 1100x750) and persists future size and position under the "TaassetsMainWindow" frame autosave name. A 900x600 minimum stops it from collapsing smaller than the asset browsers can use. The Weapons browser no longer filters blocks through a narrow "looks-like-a-weapon" heuristic. Every top-level block with properties is shown, and the user can narrow the list with the existing search field. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TntViewRenderer+MetalDynamicTiles.swift | 1 + .../TntViewRenderer+MetalShaderTypes.h | 2 + .../TntViewRenderer+MetalShaders.metal | 28 +++++++++++-- .../TntViewRenderer+MetalSingleQuad.swift | 1 + .../TntViewRenderer+MetalStaticGrid.swift | 1 + TAassets/TAassets/TaassetsDocument.swift | 21 ++++++++++ TAassets/TAassets/WeaponsBrowser.swift | 39 +++++++------------ 7 files changed, 65 insertions(+), 28 deletions(-) diff --git a/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift b/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift index 243558b..071b6db 100644 --- a/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift +++ b/HPIView/HPIView/TntViewRenderer+MetalDynamicTiles.swift @@ -157,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 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/TAassets/TAassets/TaassetsDocument.swift b/TAassets/TAassets/TaassetsDocument.swift index ef9505a..15eab49 100644 --- a/TAassets/TAassets/TaassetsDocument.swift +++ b/TAassets/TAassets/TaassetsDocument.swift @@ -41,6 +41,27 @@ class TaassetsDocument: NSDocument { 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 { diff --git a/TAassets/TAassets/WeaponsBrowser.swift b/TAassets/TAassets/WeaponsBrowser.swift index 6a56be2..b06ab24 100644 --- a/TAassets/TAassets/WeaponsBrowser.swift +++ b/TAassets/TAassets/WeaponsBrowser.swift @@ -128,41 +128,30 @@ class WeaponsBrowserViewController: NSViewController, ContentViewController { 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 looking for blocks that look like weapon definitions. - /// A block is treated as a weapon if it has any of the hallmark properties - /// (`weapontype`, `range`, `weaponvelocity`, `name` alongside damage data, etc.) - /// or if it contains a `damage` subobject. Otherwise the walker descends into - /// nested subobjects so container blocks like `[WEAPONDEFS]` don't hide content. + /// 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 lowerKey = key.lowercased() - if looksLikeWeapon(sub) { - guard seen.insert(lowerKey).inserted else { continue } - results.append(WeaponInfo(from: sub, key: key, sourceFile: sourceFile)) - } else if !sub.subobjects.isEmpty { + 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) } } } - private static func looksLikeWeapon(_ object: TdfParser.Object) -> Bool { - let weaponishKeys: Set = [ - "weapontype", "range", "weaponvelocity", "weaponlaserdef", - "reloadtime", "accuracy", "areaofeffect", "energypershot", - "metalpershot", "explosiongaf", "startvelocity", "lineofsight" - ] - for key in object.properties.keys where weaponishKeys.contains(key.lowercased()) { - return true - } - if object.subobjects.keys.contains(where: { $0.lowercased() == "damage" }) { - return true - } - return false - } - @objc private func searchFieldChanged(_ sender: NSSearchField) { searchTerm = sender.stringValue.trimmingCharacters(in: .whitespaces) applyFilter() From 95fb685f227f5be3049d59a280d6d64ec50e3e1c Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 08:07:40 -0700 Subject: [PATCH 16/54] Documents the TAassets UX work in the fork notes and README. Extends notes/SwiftTA_Apple_Silicon_Bootstrap.md with a new section covering the browser chrome changes, map viewer improvements, mod-unit discovery, the weapons browser, and the COB divide-by-zero fix, all with links to the relevant source files so future spelunking is fast. Mirrors the highlights into the README fork callout so the GitHub landing page reflects the current feature set. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 39 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 29c91c2..354b7cf 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,11 @@ > - **COB playback controls** (TAassets) — pause / step / 0×–2× speed slider, plus a "Run script…" pull-down for every module in the unit's COB so you can trigger `Activate`, `QueryPrimary`, etc. on demand and watch building internals animate piece by piece. > - **Camera controls** — scroll / pinch zoom, shift-drag pitch, `=` / `-` / `0` keys. Auto-fits the model on load so large buildings don't open zoomed past the viewport; re-fits 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. -> - **Tolerant standalone loading** — `gamedata/sidedata.tdf` is optional; palette lookup falls back through side → standard → neutral. +> - **Map viewer** — auto-fits the map to the viewport on load, pinch/scroll zoom, numbered start-position markers pulled 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 in the on-screen render budget. +> - **Weapons browser** — walks every `weapon*/` directory, parses each `.tdf` recursively, and lists every weapon block with a searchable detail pane (key, source file, weapon type, range, damage table, raw properties). +> - **Searchable browsers** — live filter fields above the Units, Maps, and Weapons lists. +> - **Browser chrome** — compact header strips carry unit/map details instead of centered oversized titles; the sidebar uses SF Symbols (cube, scope, map, folder) shifted clear of the traffic-light controls; TAassets 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 previously-stub `Extract All` menu item is implemented, so you can now dump the entire archive to a folder. > - **Script VM hardening** — the COB `divide` opcode no longer traps on divide-by-zero (observed in TAESC scripts). > diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 8b27d00..6d05ee0 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -205,3 +205,42 @@ For users who open a mod folder that has no vanilla parent: - 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. From dfe9ce746e17edc4618beac1f5e79077c4f62a00 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:16:21 -0700 Subject: [PATCH 17/54] Fits the whole unit on load and stops complex models from losing pieces. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Metal uniform only carried room for 40 piece matrices, but mod-supplied units (TAESC's CORMKL spider among them) have more, so the extra transforms overflowed the uniform buffer, clobbering later fields and leaving the shader reading zeros for those pieces. Result: legs and other limbs rendered collapsed on the base or vanished. The uniform now carries 128 slots, and the Swift side caps the per-frame copy to that capacity with a one-shot warning if a future unit exceeds the limit. Reworks the unit-view auto-fit to consider the current viewport aspect ratio. The previous implementation multiplied the model extent by 2.3 to pick a scene width, but scene height = sceneWidth · aspectRatio so a wide-but-short window would crop tall mod units. The new computation picks whichever of modelDiameter and modelDiameter / aspectRatio is larger, guaranteeing both axes show the full 2.4·extent box. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/UnitView.swift | 10 ++++++++-- TAassets/TAassets/UnitViewRenderer+Metal.swift | 17 +++++++++++++++-- .../UnitViewRenderer+MetalShaderTypes.h | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/TAassets/TAassets/UnitView.swift b/TAassets/TAassets/UnitView.swift index 3533562..cb720f7 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -108,8 +108,14 @@ class UnitViewController: NSViewController { private func computeSceneSize() { let footprintWidth = GameFloat( ((unit?.info.footprint.width ?? 2) + 8) * ModelViewState.gridSize ) let extent = viewState.model?.maxWorldExtent ?? 0 - let extentFit = extent * 2.3 - let baseWidth = max(footprintWidth, extentFit) + // 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) } diff --git a/TAassets/TAassets/UnitViewRenderer+Metal.swift b/TAassets/TAassets/UnitViewRenderer+Metal.swift index ac5968c..fa81c12 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 @@ -88,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) } diff --git a/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h b/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h index c26f711..31da079 100644 --- a/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h +++ b/TAassets/TAassets/UnitViewRenderer+MetalShaderTypes.h @@ -67,7 +67,7 @@ typedef struct vector_float4 objectColor; vector_float3 lightPosition; vector_float3 viewPosition; - matrix_float4x4 pieces[40]; + matrix_float4x4 pieces[128]; int highlightedPieceIndex; } UnitMetalRenderer_ModelUniforms; From 5279b25cd5d55e1a4225f7d2750960f54a691a1c Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:18:19 -0700 Subject: [PATCH 18/54] Documents the piece-count capacity bump and auto-fit rework. Extends notes/SwiftTA_Apple_Silicon_Bootstrap.md with why the pieces[40] uniform was biting TAESC's CORMKL and what changed on the renderer side, plus the revised computeSceneSize math that now guarantees the full unit fits the viewport in either orientation. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 6d05ee0..12f1585 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -244,3 +244,21 @@ New tab wired to the Weapons sidebar button. Walks every top-level directory who ## 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. From 6d56dc45cdfaf6c77729f19993ad8f711ac1b25e Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:28:28 -0700 Subject: [PATCH 19/54] Traverses every 3DO root so sibling piece trees stop going missing. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnitModel.init always threw away all but the first sibling of the root node (root = model.roots.first!) while UnitModel.roots was kept internal. The BFS parser correctly accumulates every top-level sibling from offset 0 in the 3DO, but the renderers only ever walked from model.root, so any piece that lived on a sibling tree — a pattern several TAESC mod units use for extra appendages — never reached the vertex buffer or the transformation list. CORMKL in particular kept its legs on sibling roots, which is why the body rendered cleanly while the legs disappeared. Exposes the roots array on UnitModel and walks every root in: - TAassets' Metal unit renderer (vertex + outline + transform passes) - HPIView's Metal model renderer (vertex + outline passes) - the piece-hierarchy outline in both apps - UnitModel.maxWorldExtent so the auto-fit camera accounts for pieces on secondary roots Adds a startup print of per-unit piece/primitive/script-module counts plus the full piece name list, so future "X is missing its Y" reports can be diagnosed straight from /tmp/taassets.log. Co-Authored-By: Claude Opus 4.7 (1M context) --- HPIView/HPIView/ModelViewRenderer+Metal.swift | 9 +++++++-- HPIView/HPIView/PieceHierarchyView.swift | 3 ++- .../Sources/SwiftTA-Core/UnitModel+Bounds.swift | 5 ++++- SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift | 2 ++ TAassets/TAassets/PieceHierarchyView.swift | 3 ++- TAassets/TAassets/UnitBrowser.swift | 5 +++++ TAassets/TAassets/UnitViewRenderer+Metal.swift | 14 +++++++++++--- 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/HPIView/HPIView/ModelViewRenderer+Metal.swift b/HPIView/HPIView/ModelViewRenderer+Metal.swift index fee7c6b..92b8f83 100644 --- a/HPIView/HPIView/ModelViewRenderer+Metal.swift +++ b/HPIView/HPIView/ModelViewRenderer+Metal.swift @@ -244,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 diff --git a/HPIView/HPIView/PieceHierarchyView.swift b/HPIView/HPIView/PieceHierarchyView.swift index 45f5de0..88eb506 100644 --- a/HPIView/HPIView/PieceHierarchyView.swift +++ b/HPIView/HPIView/PieceHierarchyView.swift @@ -85,7 +85,8 @@ final class PieceHierarchyView: NSView { refsByModelIndex[modelIdx] = byModule.joined(separator: " ") } } - nodes = [Node(index: model.root, model: model, refs: refsByModelIndex)] + 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) } diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift index 02207de..d67bd9c 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift @@ -11,7 +11,10 @@ public extension UnitModel { /// each piece's local offset is added to the accumulated parent offset. var maxWorldExtent: GameFloat { var extent: GameFloat = 0 - accumulate(pieceIndex: root, parentOffset: .zero, into: &extent) + let rootsToVisit: [Pieces.Index] = roots.isEmpty ? [root] : roots + for rootIndex in rootsToVisit { + accumulate(pieceIndex: rootIndex, parentOffset: .zero, into: &extent) + } return extent } diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift index 54899d5..4eb75ff 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift @@ -17,6 +17,7 @@ public struct UnitModel { public typealias Textures = Array public var pieces: Pieces + public var roots: [Pieces.Index] = [] public var primitives: Primitives public var vertices: Vertices public var textures: Textures @@ -40,6 +41,7 @@ public struct UnitModel { textures = model.textures root = model.roots.first! + roots = model.roots groundPlate = model.groundPlate var names: [String: Pieces.Index] = [:] diff --git a/TAassets/TAassets/PieceHierarchyView.swift b/TAassets/TAassets/PieceHierarchyView.swift index 259aa8e..e270c7d 100644 --- a/TAassets/TAassets/PieceHierarchyView.swift +++ b/TAassets/TAassets/PieceHierarchyView.swift @@ -85,7 +85,8 @@ final class PieceHierarchyView: NSView { refsByModelIndex[modelIdx] = byModule.joined(separator: " ") } } - nodes = [Node(index: model.root, model: model, refs: refsByModelIndex)] + 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) } diff --git a/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index 855f975..a7e3ef3 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -331,6 +331,11 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl let script = try UnitScript(contentsOf: scriptFile) let atlas = UnitTextureAtlas(for: model.textures, from: shared.textures) 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)") + try unitView.load(unit, model, script, atlas, shared.filesystem, palette) pieceView.apply(model: model, script: script) playbackControls.reset(scriptFunctions: unitView.availableScriptFunctions) diff --git a/TAassets/TAassets/UnitViewRenderer+Metal.swift b/TAassets/TAassets/UnitViewRenderer+Metal.swift index fa81c12..cb5a103 100644 --- a/TAassets/TAassets/UnitViewRenderer+Metal.swift +++ b/TAassets/TAassets/UnitViewRenderer+Metal.swift @@ -312,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 @@ -496,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]) { From 33da953fc4232db5b2e0710f995a9db81e31ec9f Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:29:07 -0700 Subject: [PATCH 20/54] Documents the multi-root 3DO fix. Records why CORMKL's legs were missing (UnitModel.init dropped every root past the first) and lists the call sites that were updated to walk model.roots so future mod units with sibling trees render completely. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 12f1585..d9d1dca 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -262,3 +262,22 @@ 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. + +## 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`. From ef2cad0b93b610c8fda807bca10055596a4d3292 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:37:44 -0700 Subject: [PATCH 21/54] Implements the script-side IK getters that were stubbed to zero. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUnitValue and getFunctionResult both just pushed 0 regardless of what the COB script asked for. TA spider units (confirmed with TAESC's CORMKL) lean heavily on the IK trio — PIECE_XZ to query a leg's geometric offset, XZ_ATAN to derive a rotation angle from that packed offset, and XZ_HYPOT for the matching distance — and then turn-now the leg into its starting pose during Create. With everything returning 0 the atan was 0 and every leg that needed this path collapsed onto the body origin, matching the "side legs missing" symptom. Adds UnitModel.parents (ancestor chain per piece) and UnitModel.pieceStaticOffset so the VM can sum a piece's offsets without re-evaluating the whole transform chain. Stores the UnitModel on UnitScript.Context so Instructions can reach the piece tree. Implements packXZ / unpackXZ along with taAtan2 and taHypot (TA encodes a full turn as 65536 angle units). getUnitValue now returns sane defaults for activation, health, standing orders, and position queries; getFunctionResult handles PIECE_XZ, PIECE_Y, XZ_ATAN, XZ_HYPOT, ATAN, HYPOT, and the zero-returning unit/ground queries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/SwiftTA-Core/UnitModel.swift | 32 ++++++- .../UnitScript+Instructions.swift | 96 +++++++++++++++---- .../Sources/SwiftTA-Core/UnitScript+VM.swift | 6 +- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift index 4eb75ff..a0c521f 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel.swift @@ -18,6 +18,7 @@ public struct UnitModel { 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 @@ -43,12 +44,41 @@ public struct UnitModel { 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+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index bcf94c1..197f363 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -657,22 +657,25 @@ 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 + 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 +694,76 @@ private func getUnitValue(execution: ScriptExecutionContext) throws { */ private func getFunctionResult(execution: ScriptExecutionContext) throws { - + let params: [_StackValue] = try execution.thread.stack.pop(count: 4).reversed() 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 offset = pieceStaticOffset(scriptPiece: params[0], execution: execution) { + result = packXZ(x: offset.x, z: offset.z) + } + case .pieceY: + if let offset = pieceStaticOffset(scriptPiece: params[0], execution: execution) { + result = _StackValue(offset.y) + } + 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 .unitXZ, .unitY, .unitHeight, .groundHeight: + result = 0 + default: + () + } + } + execution.thread.stack.push(result) execution.thread.instructionPointer += 1 } +private func pieceStaticOffset(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.parents.count else { return nil } + return model.pieceStaticOffset(modelIndex) +} + +private func packXZ(x: GameFloat, z: GameFloat) -> 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. Returns atan2(z, x) remapped to that range. +private func taAtan2(z: GameFloat, x: GameFloat) -> UnitScript.CodeUnit { + guard x != 0 || z != 0 else { return 0 } + let radians = atan2(z, x) + let turns = radians / (2 * .pi) + 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 diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift index 127708e..1a82e1d 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 { From ec22f7a0ef677fd15c42146b803db83d6828fac7 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:38:16 -0700 Subject: [PATCH 22/54] Documents the IK getter implementation. Records why CORMKL's side legs collapsed (PIECE_XZ / XZ_ATAN / XZ_HYPOT all returning 0) and the VM changes that now handle the packed-xz encoding, TA's 65536-unit angle space, and the piece ancestor chain used to resolve world-space piece offsets. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index d9d1dca..5cc12fc 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -263,6 +263,27 @@ 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 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: From 3a2bfc56aaca4131da86ef9521fe22c651b4b078 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:45:53 -0700 Subject: [PATCH 23/54] Fixes Stack.pop(count:) returning the wrong number of elements. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack.pop(count: n) used suffix(from: n - 1) where it should have used suffix(n). suffix(from:) returns everything from that index to the end, so the returned array count varied with the current stack depth — you only got exactly n elements when the stack happened to hold 2·n - 1 items. Everywhere else it either truncated or over-read the stack, making getFunctionResult, startScript, and callScript pop a buggy mix of params from the wrong positions. Switching to suffix(n) fixes getFunctionResult for real — CORMKL's Create IK (get PIECE_XZ / XZ_ATAN) now receives the piece index at params[0] instead of whatever silent garbage the old implementation returned. Also fixes start-script / call-script parameter passing for any script that supplies more than one argument. Also routes PIECE_XZ / PIECE_Y through the UnitModel's SIMD axis convention: UnitModel remaps 3DO (x, y, z) -> SIMD (x, z, y), so offset.y is TA's Z (horizontal depth) and offset.z is TA's Y (vertical height). The getter now packs (offset.x, offset.y) for PIECE_XZ and returns offset.z for PIECE_Y. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/SwiftTA-Core/UnitScript+Instructions.swift | 6 ++++-- SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 197f363..3d701a2 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -703,11 +703,13 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { switch uv { case .pieceXZ: if let offset = pieceStaticOffset(scriptPiece: params[0], execution: execution) { - result = packXZ(x: offset.x, z: offset.z) + // UnitModel remaps 3DO (x, y, z) → SIMD (x, z, y) so offset.y is TA's Z + // (horizontal depth) and offset.z is TA's Y (vertical height). + result = packXZ(x: offset.x, z: offset.y) } case .pieceY: if let offset = pieceStaticOffset(scriptPiece: params[0], execution: execution) { - result = _StackValue(offset.y) + result = _StackValue(offset.z) } case .xzAtan: let (x, z) = unpackXZ(params[0]) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift index 1a82e1d..29255e6 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift @@ -286,7 +286,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 } } From 49a428a83855ffa1d45bbb78df68965114fbf30b Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 09:46:15 -0700 Subject: [PATCH 24/54] Documents the Stack.pop(count:) fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 5cc12fc..fb71c88 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -263,6 +263,10 @@ 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)`. + ## 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: From 2bc3c7b35acb1bf1674b54aa25bf584c548d89fa Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 10:16:40 -0700 Subject: [PATCH 25/54] Stops inverting script argument order in getFunctionResult. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Stack.pop(count:) now returning items in push order (oldest first) — matching how TA's compiler lays parameters on the stack — the .reversed() in getFunctionResult, startScript, and callScript was flipping params[0] to the last argument instead of the first. For calls like get(PIECE_XZ, piece, 0, 0, 0) that meant the piece index landed in params[3] and params[0] carried a trailing zero, so every PIECE_XZ resolved to whatever piece the script happened to pad with first (typically 0). CORMKL's Create leg-positioning math always computed the same angle for every leg as a result. Dropping .reversed() lines up with the decompiler's existing convention where params[0] is the first written argument. Also dumps each selected unit's decompiled COB to /tmp/taassets-last-cob.txt so future script issues can be compared against the bytecode directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftTA-Core/UnitScript+Instructions.swift | 13 ++++++++----- TAassets/TAassets/UnitBrowser.swift | 7 +++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 3d701a2..174109f 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -695,7 +695,10 @@ 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() var result: _StackValue = 0 @@ -784,8 +787,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 } @@ -807,9 +810,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/TAassets/TAassets/UnitBrowser.swift b/TAassets/TAassets/UnitBrowser.swift index a7e3ef3..8d78735 100644 --- a/TAassets/TAassets/UnitBrowser.swift +++ b/TAassets/TAassets/UnitBrowser.swift @@ -335,6 +335,13 @@ class UnitDetailViewController: NSViewController, PieceHierarchyViewDelegate, Pl 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) From ee2b24feb974087106b9204024464e3d143b2b60 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 10:34:08 -0700 Subject: [PATCH 26/54] Applies turn/move-now inline and queries piece world transforms. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TA Create scripts (CORMKL and every other walker in TAESC) do a binary-search IK where each iteration does `turn Leg1-2 now;` and immediately reads back the resulting end-effector position via get PIECE_XZ / PIECE_Y / HYPOT. Our VM was appending `setAngle` animations to Context.animations that only flushed in the post-run applyAnimations step, so every iteration read the same stale state and the bisection never converged — leaving the legs folded straight under the body. Adds UnitModel.pieceWorldTransform and pieceWorldPosition that walk the piece's ancestor chain from the root, multiplying each piece's local translation + Euler-rotation matrix. Mirrors the renderer's matrix convention so the VM and display agree on where a piece is. Threads the instance through run() as inout / UnsafeMutablePointer so turnPieceNow and movePieceNow update piece.turn / piece.move directly on the model. Subsequent PIECE_XZ / PIECE_Y queries in the same script tick now observe the just-applied angles. Animated (with-speed) turns and moves still flow through Context.animations and the per-frame applyAnimations step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/SwiftTA-Core/GameManager.swift | 2 +- .../SwiftTA-Core/UnitModel+Bounds.swift | 54 +++++++++++++++ .../UnitScript+Instructions.swift | 67 +++++++++++-------- .../Sources/SwiftTA-Core/UnitScript+VM.swift | 12 ++-- TAassets/TAassets/UnitView.swift | 4 +- 5 files changed, 103 insertions(+), 36 deletions(-) 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/UnitModel+Bounds.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift index d67bd9c..3210c43 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift @@ -4,6 +4,60 @@ // 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) + return matrix_float4x4(columns: ( + vector_float4(cy * cz, + (sy * cx) + (sx * cy * sz), + (sx * sy) - (cx * cy * sz), + 0), + vector_float4(-sy * cz, + (cx * cy) - (sx * sy * sz), + (sx * cy) + (cx * sy * sz), + 0), + vector_float4(sz, + -sx * cz, + cx * cz, + 0), + vector_float4(offset.x - move.x, + offset.y - move.z, + offset.z + move.y, + 1) + )) + } +} public extension UnitModel { diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 174109f..3c1313c 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 } @@ -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 } @@ -705,14 +714,14 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { if let uv = UnitScript.UnitValue(rawValue: what) { switch uv { case .pieceXZ: - if let offset = pieceStaticOffset(scriptPiece: params[0], execution: execution) { - // UnitModel remaps 3DO (x, y, z) → SIMD (x, z, y) so offset.y is TA's Z - // (horizontal depth) and offset.z is TA's Y (vertical height). - result = packXZ(x: offset.x, z: offset.y) + 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). + result = packXZ(x: pos.x, z: pos.y) } case .pieceY: - if let offset = pieceStaticOffset(scriptPiece: params[0], execution: execution) { - result = _StackValue(offset.z) + if let pos = pieceWorldPosition(scriptPiece: params[0], execution: execution) { + result = _StackValue(pos.z) } case .xzAtan: let (x, z) = unpackXZ(params[0]) @@ -734,11 +743,11 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { execution.thread.instructionPointer += 1 } -private func pieceStaticOffset(scriptPiece: UnitScript.CodeUnit, execution: ScriptExecutionContext) -> Vertex3f? { +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.parents.count else { return nil } - return model.pieceStaticOffset(modelIndex) + guard modelIndex < model.pieces.count else { return nil } + return model.pieceWorldPosition(modelIndex, instance: execution.model.pointee) } private func packXZ(x: GameFloat, z: GameFloat) -> UnitScript.CodeUnit { diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift index 29255e6..0177a47 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift @@ -81,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 } } @@ -219,7 +223,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 { diff --git a/TAassets/TAassets/UnitView.swift b/TAassets/TAassets/UnitView.swift index cb720f7..62a60c8 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -99,7 +99,7 @@ class UnitViewController: NSViewController { 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.run(for: &unit.modelInstance, on: self) unit.scriptContext.applyAnimations(to: &unit.modelInstance, for: GameFloat(duration)) viewState.modelInstance = unit.modelInstance self.unit = unit @@ -212,7 +212,7 @@ extension UnitViewController: UnitViewStateProvider { viewState.speed = 0 } - unit.scriptContext.run(for: unit.modelInstance, on: self) + unit.scriptContext.run(for: &unit.modelInstance, on: self) unit.scriptContext.applyAnimations(to: &unit.modelInstance, for: GameFloat(scaledDelta)) if viewState.isMoving { From 5bbb4856d9d515d53872c2d83397c1629c01b1f1 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 10:41:29 -0700 Subject: [PATCH 27/54] Documents the inline-now + world-position fix and the arg-order fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index fb71c88..9d12fe9 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -267,6 +267,34 @@ So `sceneHeight = sceneWidthNeeded * aspectRatio >= modelDiameter` is guaranteed `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)`. +## 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: From feaec9d59cc191b7b2664a44d4dbad923c749427 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 11:01:52 -0700 Subject: [PATCH 28/54] Wakes threads waiting on a turn or move when the animation completes. waitForTurn and waitForMove parked the thread in a .waitingForTurn / .waitingForMove status but nothing ever released them, so every walker loop stalled on its first synchronization point and kept queueing animations against a frozen piece state. CORMKL's walklegs queue visibly ballooned past 1000 pending rotations while nothing on screen moved. applyAnimations now checks each waiting thread after processing the animation list: if no rotation / spin / translation matching the waited piece and axis is still in flight, the thread flips back to .running for the next run() tick. waitForTurn / waitForMove also shortcircuit when no matching animation is pending at the call site so scripts that wait on something already complete don't stall. Adds startScript + per-second heartbeat logs (module found, thread count, pending animations) so future regressions are easy to eyeball from /tmp/taassets.log. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitScript+Instructions.swift | 24 +++++++---- .../Sources/SwiftTA-Core/UnitScript+VM.swift | 41 +++++++++++++++++++ TAassets/TAassets/UnitView.swift | 19 ++++++++- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 3c1313c..08d6ada 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -383,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) + } } /** @@ -398,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) + } } /** diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift index 0177a47..cd7aedc 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+VM.swift @@ -133,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)? { diff --git a/TAassets/TAassets/UnitView.swift b/TAassets/TAassets/UnitView.swift index 62a60c8..6771d0a 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -13,10 +13,11 @@ 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 override func loadView() { let defaultFrame = NSRect(x: 0, y: 0, width: 640, height: 480) @@ -92,8 +93,14 @@ class UnitViewController: NSViewController { var playbackSpeed: Float { viewState.playbackSpeed } func startScript(_ name: String) { - guard var unit = unit else { return } + 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 } @@ -205,6 +212,14 @@ extension UnitViewController: UnitViewStateProvider { } 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 From 785890d2066c9e84bf55a53904c17754eca94548 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 14:44:18 -0700 Subject: [PATCH 29/54] Gives PIECE_XZ / PIECE_Y sub-pixel precision for IK bisection. Create's binary-search IK (CORMKL and every other TAESC walker) bisects the leg-segment angle by comparing the resulting end-effector distance against a target distance. With PIECE_XZ / PIECE_Y packing integer pixels, single-degree increments often produced the same rounded hypot so consecutive bisection iterations made no progress and the loop halved down to local8 = 0 having never moved off the starting angle. Scales piece world positions by 256 before packing, giving the IK eight bits of sub-pixel precision while staying well within 16-bit signed int range for typical unit footprints. XZ_ATAN and XZ_HYPOT only care about the ratio and scale consistency of the packed arguments, so the script comparisons keep working. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftTA-Core/UnitScript+Instructions.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index 08d6ada..d230de2 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -724,12 +724,17 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { 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). - result = packXZ(x: pos.x, z: pos.y) + // (horizontal depth) and pos.z is TA's Y (vertical height). The IK + // bisection loops need sub-pixel precision to discriminate between + // small angle increments, so positions are scaled by + // IKPositionScale before being packed. XZ_ATAN / XZ_HYPOT only + // care about ratios and scale consistency, so the unit-free + // scaling is safe as long as PIECE_Y matches. + result = packXZ(x: pos.x * IKPositionScale, z: pos.y * IKPositionScale) } case .pieceY: if let pos = pieceWorldPosition(scriptPiece: params[0], execution: execution) { - result = _StackValue(pos.z) + result = _StackValue(truncatingIfNeeded: Int((pos.z * IKPositionScale).rounded())) } case .xzAtan: let (x, z) = unpackXZ(params[0]) @@ -758,6 +763,11 @@ private func pieceWorldPosition(scriptPiece: UnitScript.CodeUnit, execution: Scr return model.pieceWorldPosition(modelIndex, instance: execution.model.pointee) } +/// Fixed-point scale applied to piece world positions before they are packed +/// into PIECE_XZ / PIECE_Y integers. 256 gives 8 bits of sub-pixel precision, +/// enough for a binary-search IK to discriminate single-degree increments. +private let IKPositionScale: GameFloat = 256 + private func packXZ(x: GameFloat, z: GameFloat) -> 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. From f0466e21ab539226a47b3d4894b850e25f328d47 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Wed, 22 Apr 2026 14:44:54 -0700 Subject: [PATCH 30/54] Documents the wait-release and IK-precision fixes. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 9d12fe9..3980551 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -267,6 +267,16 @@ So `sceneHeight = sceneWidthNeeded * aspectRatio >= modelDiameter` is guaranteed `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. + +## IK precision ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) + +Create's bisection IK needs to discriminate a single-degree change in the end-effector's hypotenuse distance from the shoulder. Packing piece positions as integer pixels lost that resolution — small angle increments produced the same rounded hypot and the bisection couldn't decide which half to take. Scaled the packed positions by 256 (8 bits of sub-pixel precision) before `packXZ` / `PIECE_Y`. `XZ_ATAN` and `XZ_HYPOT` only care about the ratio and scale consistency of their arguments, so the script comparisons stay correct. + ## 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: From 197f224f7ba5a3f56a50a3e3aac8996c527e03af Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 07:27:22 -0700 Subject: [PATCH 31/54] Rotates pieces yaw-outermost so child pitch axes stay horizontal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With pitch-outermost (R_x·R_y·R_z), a non-zero shoulder turn.x rotated the knee's local x-axis toward vertical, and CORMKL's bisection IK became unimodal — the "bend more → shorter reach" relationship broke and the search froze at the degenerate near-zero angle. Switching the composition to R_z(turn.y)·R_y(turn.z)·R_x(turn.x) keeps a child's pitch axis in the horizontal plane regardless of the parent's pitch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftTA-Core/UnitModel+Bounds.swift | 24 ++++++++++++------- .../SwiftTA-Metal/MetalUnitDrawable.swift | 23 +++++++++--------- .../OpenglCore3UnitDrawable.swift | 23 +++++++++--------- .../TAassets/UnitViewRenderer+Metal.swift | 24 ++++++++++--------- .../UnitViewRenderer+OpenglCore33.swift | 23 +++++++++--------- .../UnitViewRenderer+OpenglLegacy.swift | 21 ++++++++-------- 6 files changed, 76 insertions(+), 62 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift index 3210c43..5777035 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitModel+Bounds.swift @@ -38,18 +38,26 @@ public extension UnitModel { 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 * cx) + (sx * cy * sz), - (sx * sy) - (cx * cy * sz), + sy * cz, + -sz, 0), - vector_float4(-sy * cz, - (cx * cy) - (sx * sy * sz), - (sx * cy) + (cx * sy * sz), + vector_float4((cy * sz * sx) - (sy * cx), + (sy * sz * sx) + (cy * cx), + cz * sx, 0), - vector_float4(sz, - -sx * cz, - cx * cz, + 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, 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/TAassets/TAassets/UnitViewRenderer+Metal.swift b/TAassets/TAassets/UnitViewRenderer+Metal.swift index cb5a103..c7f232c 100644 --- a/TAassets/TAassets/UnitViewRenderer+Metal.swift +++ b/TAassets/TAassets/UnitViewRenderer+Metal.swift @@ -523,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+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 } From a71e37fc39a0fa549ec7a03b478820275d9fea1c Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 07:27:41 -0700 Subject: [PATCH 32/54] Fixes COB walker-IK semantics in getFunctionResult. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes that together let CORMKL's Create-time bisection converge: - PIECE_XZ / PIECE_Y return native TA integer units again; the 256× sub-pixel scaling from 785890d broke every walker that compared XZ_HYPOT against fixed integer thresholds. - taAtan2 returns unsigned [0, 65536) instead of signed [-32768, 32768]. LegGroups' quadrant checks — `XZ_ATAN(...) > 0 && < 32768` and `> 16384 && < 49152` — relied on the full unsigned range; with signed output every third-quadrant angle failed both checks and the stride-target bisection rolled to the ±200 game-unit edge. - GROUND_HEIGHT returns the world Y of the piece whose current world XZ matches the query (falling through to 0 otherwise). The zero stub made PositionLegs' `move Point to y-axis [GROUND_HEIGHT(PIECE_XZ(Point)) - PIECE_Y(Point)] speed [...]` oscillate every tick; returning the queried piece's own Y collapses the expression to zero so the Point targets sit still and the per-leg IK has a stationary aim. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitScript+Instructions.swift | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift index d230de2..e84ef2d 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -724,17 +724,17 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { 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). The IK - // bisection loops need sub-pixel precision to discriminate between - // small angle increments, so positions are scaled by - // IKPositionScale before being packed. XZ_ATAN / XZ_HYPOT only - // care about ratios and scale consistency, so the unit-free - // scaling is safe as long as PIECE_Y matches. - result = packXZ(x: pos.x * IKPositionScale, z: pos.y * IKPositionScale) + // (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 * IKPositionScale).rounded())) + result = _StackValue(truncatingIfNeeded: Int(pos.z.rounded())) } case .xzAtan: let (x, z) = unpackXZ(params[0]) @@ -746,7 +746,23 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { result = taAtan2(z: GameFloat(params[1]), x: GameFloat(params[0])) case .hypot: result = taHypot(x: GameFloat(params[0]), z: GameFloat(params[1])) - case .unitXZ, .unitY, .unitHeight, .groundHeight: + 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 default: () @@ -763,10 +779,23 @@ private func pieceWorldPosition(scriptPiece: UnitScript.CodeUnit, execution: Scr return model.pieceWorldPosition(modelIndex, instance: execution.model.pointee) } -/// Fixed-point scale applied to piece world positions before they are packed -/// into PIECE_XZ / PIECE_Y integers. 256 gives 8 bits of sub-pixel precision, -/// enough for a binary-search IK to discriminate single-degree increments. -private let IKPositionScale: GameFloat = 256 +/// 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 @@ -783,11 +812,18 @@ private func unpackXZ(_ packed: UnitScript.CodeUnit) -> (GameFloat, GameFloat) { return (GameFloat(xRaw), GameFloat(zRaw)) } -/// TA represents a full turn as 65536 angle units. Returns atan2(z, x) remapped to that range. +/// 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) - let turns = radians / (2 * .pi) + var turns = radians / (2 * .pi) + if turns < 0 { turns += 1 } return UnitScript.CodeUnit(truncatingIfNeeded: Int((turns * 65536).rounded())) } From 4d7fa963e6ec2d1b9f33fa2dc6e12ace53efaba2 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 07:27:50 -0700 Subject: [PATCH 33/54] Freezes background threads after Create so the viewer holds its IK pose. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORMKL's Create() spawns PositionLegs/LegGroups/SmokeUnit threads with no signal mask, so nothing can kill them and they run a forever-gait over the viewer's empty scene. Once the Create thread returns we drop every remaining thread; user-triggered scripts still work because they create new threads after the freeze point. Also drops the 1-second auto-StartMoving and adds `d` to dump each piece's offset/turn/move/world position/parent chain — needed to diagnose which legs Create's bisection failed to converge. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/UnitView.swift | 58 +++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/TAassets/TAassets/UnitView.swift b/TAassets/TAassets/UnitView.swift index 6771d0a..3318cd0 100644 --- a/TAassets/TAassets/UnitView.swift +++ b/TAassets/TAassets/UnitView.swift @@ -18,6 +18,13 @@ class UnitViewController: NSViewController { 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) @@ -59,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 @@ -196,10 +211,37 @@ extension UnitViewController: UnitViewStateProvider { 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 } @@ -230,6 +272,20 @@ extension UnitViewController: UnitViewStateProvider { 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(scaledDelta * 10) let acceleration = unit.info.acceleration From 4243a3c7904385eabbac39e43a7beec764aab724 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 07:27:57 -0700 Subject: [PATCH 34/54] Documents the walker-IK fixes and the post-Create freeze. Co-Authored-By: Claude Opus 4.7 (1M context) --- notes/SwiftTA_Apple_Silicon_Bootstrap.md | 28 ++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/notes/SwiftTA_Apple_Silicon_Bootstrap.md b/notes/SwiftTA_Apple_Silicon_Bootstrap.md index 3980551..56c8e70 100644 --- a/notes/SwiftTA_Apple_Silicon_Bootstrap.md +++ b/notes/SwiftTA_Apple_Silicon_Bootstrap.md @@ -273,9 +273,33 @@ So `sceneHeight = sceneWidthNeeded * aspectRatio >= modelDiameter` is guaranteed `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. -## IK precision ([SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift](SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift)) +## 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) -Create's bisection IK needs to discriminate a single-degree change in the end-effector's hypotenuse distance from the shoulder. Packing piece positions as integer pixels lost that resolution — small angle increments produced the same rounded hypot and the bisection couldn't decide which half to take. Scaled the packed positions by 256 (8 bits of sub-pixel precision) before `packXZ` / `PIECE_Y`. `XZ_ATAN` and `XZ_HYPOT` only care about the ratio and scale consistency of their arguments, so the script comparisons stay correct. +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 From 2e506866fddb9f02b4442772f27ef52ee6e170a0 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 07:51:53 -0700 Subject: [PATCH 35/54] Builds HPIView and TAassets on macOS via GitHub Actions. Runs on pushes to main, pull requests against main, version tags (v*), and manual dispatch. Both apps build Release-unsigned on a macos-14 runner and the .app bundles ship as zipped workflow artifacts. Pushing a v-prefixed tag additionally attaches them to a draft GitHub release so non-Xcode users can download the build. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2d2318b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,81 @@ +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 + steps: + - uses: actions/checkout@v4 + + - name: Xcode version + run: xcodebuild -version + + - name: Resolve Swift packages + run: | + xcodebuild -workspace SwiftTA.xcworkspace \ + -scheme TAassets \ + -resolvePackageDependencies + + - name: Build TAassets (Release) + run: | + 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 + + - name: Build HPIView (Release) + run: | + 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 + + - name: Package .app bundles + run: | + mkdir -p dist + PRODUCTS=build/DerivedData/Build/Products/Release + ( cd "$PRODUCTS" && zip -ry "$GITHUB_WORKSPACE/dist/TAassets-macOS.zip" TAassets.app ) + ( cd "$PRODUCTS" && zip -ry "$GITHUB_WORKSPACE/dist/HPIView-macOS.zip" HPIView.app ) + 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 + + - name: Attach to release (tags only) + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: dist/*.zip + draft: true + generate_release_notes: true From e13fcff7469e60b3eefa0764781b2312c4b2e940 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 07:57:44 -0700 Subject: [PATCH 36/54] Adds height and passability overlays to the map browser. A new segmented control in the map detail header toggles between no overlay, per-cell elevation tinting, and a passability heatmap (green-to-yellow for passable terrain, red where the max slope to any neighbor exceeds a threshold, blue for under-sea cells, and orange where a feature occupies the cell). The threshold slider appears only in passability mode. Intended to help line up AEX's passability computation against the actual TNT heightmap and sea level for each map. Co-Authored-By: Claude Opus 4.7 (1M context) --- TAassets/TAassets/MapBrowser.swift | 81 +++++++++++++++- TAassets/TAassets/MapView+Metal.swift | 130 +++++++++++++++++++++++++- TAassets/TAassets/MapView.swift | 18 +++- 3 files changed, 219 insertions(+), 10 deletions(-) diff --git a/TAassets/TAassets/MapBrowser.swift b/TAassets/TAassets/MapBrowser.swift index 3072f29..f872210 100644 --- a/TAassets/TAassets/MapBrowser.swift +++ b/TAassets/TAassets/MapBrowser.swift @@ -178,11 +178,15 @@ 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) @@ -197,6 +201,20 @@ class MapDetailViewController: NSViewController { 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 } @@ -222,10 +240,13 @@ class MapDetailViewController: NSViewController { 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? { @@ -245,6 +266,15 @@ 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: "") titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) @@ -256,27 +286,63 @@ class MapDetailViewController: NSViewController { 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.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.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), ]) } @@ -288,17 +354,22 @@ class MapDetailViewController: NSViewController { NSLayoutConstraint.activate([ contentBox.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4), contentBox.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4), - contentBox.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), + 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 444903b..63b5306 100644 --- a/TAassets/TAassets/MapView+Metal.swift +++ b/TAassets/TAassets/MapView+Metal.swift @@ -134,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") @@ -144,10 +144,11 @@ 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) + configureOverlay(heightMap: map.heightMap, featureMap: map.featureMap, seaLevel: map.seaLevel, mapSize: map.mapSize) emptyView.needsDisplay = true scrollView.magnification = zoomToFit(resolution: map.resolution) @@ -168,6 +169,7 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { try? renderer.load(map, from: filesystem) mapResolution = map.resolution emptyView.frame = NSRect(size: map.resolution) + configureOverlay(heightMap: map.heightMap, featureMap: map.featureMap, seaLevel: map.seaLevel, mapSize: map.mapSize) emptyView.needsDisplay = true scrollView.magnification = zoomToFit(resolution: map.resolution) @@ -181,6 +183,21 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { 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 } @@ -193,6 +210,10 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { tntRenderer = nil mapInfo = nil emptyView.startPositions = [] + emptyView.heightMap = nil + emptyView.featureMap = [] + emptyView.seaLevel = 0 + emptyView.mapSize = .zero } func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { @@ -245,6 +266,20 @@ class MetalMapView: NSView, MapViewLoader, MTKViewDelegate { } +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 } @@ -252,6 +287,15 @@ class MapOverlayView: NSView { 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 @@ -262,8 +306,88 @@ class MapOverlayView: NSView { } override func draw(_ dirtyRect: NSRect) { - guard showMarkers, !startPositions.isEmpty, let ctx = NSGraphicsContext.current?.cgContext else { return } + 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 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) {} } From de6b11f42126657cfc746f3bd8efa100f0a0e961 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 08:08:15 -0700 Subject: [PATCH 37/54] Publishes a rolling latest release and versioned tag releases. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main-branch pushes now update a single 'latest' prerelease with the current TAassets and HPIView app zips attached, so anyone can grab an up-to-date build from the Releases page without a GitHub login or waiting on tag cadence. Version tags (v*) still cut their own permanent entries, and the draft step is gone — tagged releases publish immediately now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d2318b..bec0570 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,10 +72,35 @@ jobs: if-no-files-found: error retention-days: 30 - - name: Attach to release (tags only) + # 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: | + SHORT=$(git rev-parse --short HEAD) + NOTES="Rolling build from main.\n\nCommit: ${{ github.sha }}\nBuilt: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + # Delete prior latest release + tag (ignore failure if none exists). + gh release delete latest --yes --cleanup-tag 2>/dev/null || true + gh release create latest \ + dist/TAassets-macOS.zip \ + dist/HPIView-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') - uses: softprops/action-gh-release@v2 - with: - files: dist/*.zip - draft: true - generate_release_notes: true + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + gh release create "$TAG" \ + dist/TAassets-macOS.zip \ + dist/HPIView-macOS.zip \ + --title "$TAG" \ + --generate-notes From 0e35a8de1f984b25269829fc453d7ba8e6677c93 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 08:11:45 -0700 Subject: [PATCH 38/54] Rewrites the README around downloading and using the two apps. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous README mixed three audiences (fork maintainers, the original game-client experiment, and end users trying to run TAassets / HPIView) into one wall of bullets. Split them: - README.md is now a download-first user guide — where to get the apps from the Releases page, first-launch Gatekeeper steps, what TA files the apps need, and how each browser / overlay works. - docs/FORK_NOTES.md keeps the summary of fork additions. - docs/ORIGINAL_README.md preserves the upstream loganjones/SwiftTA README verbatim (Swift 4.2 / Ubuntu 16.04 era game-client notes). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 193 ++++++++++++++++++++++++---------------- docs/FORK_NOTES.md | 23 +++++ docs/ORIGINAL_README.md | 64 +++++++++++++ 3 files changed, 203 insertions(+), 77 deletions(-) create mode 100644 docs/FORK_NOTES.md create mode 100644 docs/ORIGINAL_README.md diff --git a/README.md b/README.md index 354b7cf..ff801e9 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,138 @@ -# SwiftTA - -> **Fork notes (Apple silicon + mod browsing):** This branch of the original [loganjones/SwiftTA](https://github.com/loganjones/SwiftTA) focuses on making **TAassets** and **HPIView** useful asset inspectors on current Xcode / macOS with Apple silicon, and extends TAassets with a piece-level 3DO inspector, COB playback controls, and a mod-aware filesystem loader. Full write-up with code paths and troubleshooting is in [notes/SwiftTA_Apple_Silicon_Bootstrap.md](notes/SwiftTA_Apple_Silicon_Bootstrap.md). -> -> **What's new in this fork** -> - **Builds 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 (new Metal uniform + flat piece-index interpolant). `Script Refs` column lists every COB module that manipulates each piece, extracted statically from the bytecode. -> - **COB playback controls** (TAassets) — pause / step / 0×–2× speed slider, plus a "Run script…" pull-down for every module in the unit's COB so you can trigger `Activate`, `QueryPrimary`, etc. on demand and watch building internals animate piece by piece. -> - **Camera controls** — scroll / pinch zoom, shift-drag pitch, `=` / `-` / `0` keys. Auto-fits the model on load so large buildings don't open zoomed past the viewport; re-fits 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 viewer** — auto-fits the map to the viewport on load, pinch/scroll zoom, numbered start-position markers pulled 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 in the on-screen render budget. -> - **Weapons browser** — walks every `weapon*/` directory, parses each `.tdf` recursively, and lists every weapon block with a searchable detail pane (key, source file, weapon type, range, damage table, raw properties). -> - **Searchable browsers** — live filter fields above the Units, Maps, and Weapons lists. -> - **Browser chrome** — compact header strips carry unit/map details instead of centered oversized titles; the sidebar uses SF Symbols (cube, scope, map, folder) shifted clear of the traffic-light controls; TAassets 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 previously-stub `Extract All` menu item is implemented, so you can now dump the entire archive to a folder. -> - **Script VM hardening** — the COB `divide` opcode no longer traps on divide-by-zero (observed in TAESC scripts). -> -> **Using the prebuilt TAassets** -> 1. Clone this repo, open `SwiftTA.xcworkspace` in Xcode 26+, or build from CLI: -> ``` -> xcodebuild -workspace SwiftTA.xcworkspace -scheme TAassets \ -> -destination 'platform=macOS,arch=arm64' \ -> -configuration Release build -> ``` -> 2. Copy `build/.../Release/TAassets.app` anywhere you like (e.g. `~/Applications`). -> 3. First launch: right-click the app → Open (ad-hoc signed, so Gatekeeper asks once). -> 4. `File → Open…` → pick any directory of TA archives. Unit browser, file browser, and map browser all populate. -> - Switch mods via the **Mods** menu. -> - Open `/mods/` directly — it's treated as `base + mod` automatically. -> -> **Using HPIView** -> 1. Same build command with `-scheme HPIView`. -> 2. `File → Open…` on any `.hpi`, `.ufo`, `.ccx`, `.gp3`, or `.gpf`. -> 3. Drill into `objects3d/` → click a `.3DO` → browse the piece tree and script references on the right. Split divider resizes the outline. -> 4. Extract single files, folders, or the whole archive from the **File** menu. -> -> --- - -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](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: +# SwiftTA — Asset Inspectors for Total Annihilation + +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: + +- **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. + +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. + +## Download + +Grab the latest build from the [Releases page](https://github.com/csilvertooth/SwiftTA/releases): + +- **`TAassets-macOS.zip`** — the full asset browser +- **`HPIView-macOS.zip`** — archive viewer only + +The `Latest main` prerelease is refreshed on every push to `main`. Versioned releases (e.g. `v0.1.0`) are posted when they're cut. + +### 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: + ``` -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. + +## Using TAassets -#### Windows +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**. -😅 ... 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. +### Units browser -## Game Assets +- 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. -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`. +### Maps browser -#### iOS +- 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. -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. +### Weapons browser -#### Linux +- 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. -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). +### Files browser -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. +- 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. -## TAassets +### Using mods -![Screenshot](TAassets.gif "TAassets Screenshot") +TAassets automatically discovers mod folders under `/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). +- 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. -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". +## 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/docs/FORK_NOTES.md b/docs/FORK_NOTES.md new file mode 100644 index 0000000..ab64717 --- /dev/null +++ b/docs/FORK_NOTES.md @@ -0,0 +1,23 @@ +# 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). + +## 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..3e9e8b6 --- /dev/null +++ b/docs/ORIGINAL_README.md @@ -0,0 +1,64 @@ +# 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 top-level [README.md](../README.md) covers the asset-inspector tooling that this fork focuses on; 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](../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](../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](../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. From 02c5057f405a0d7e5ee527c6bfc1622eb8540b5b Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 08:23:41 -0700 Subject: [PATCH 39/54] Signs and notarizes the released apps when secrets are configured. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a conditional signing + notarization pipeline so Apple-blessed builds land on the Releases page and downloaders can open the apps with a double-click instead of right-click-Open. When MACOS_CERTIFICATE_P12_BASE64 and the four notarization secrets are present, the workflow imports the Developer ID cert into a temporary keychain, builds with hardened runtime and --timestamp --options=runtime flags, submits each .app to xcrun notarytool, and staples the ticket onto the bundle before zipping for release. The release notes mention whether the drop is signed/notarized. PR builds from forks and any run without secrets still produce unsigned artifacts instead of failing — useful for contributors who can't see the repository secrets. docs/SIGNING.md walks a maintainer through the one-time Developer ID certificate export, Team ID and app-specific password lookup, and the five secrets the workflow needs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 164 ++++++++++++++++++++++++++++++------ docs/SIGNING.md | 74 ++++++++++++++++ 2 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 docs/SIGNING.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bec0570..e6e5306 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,12 +18,56 @@ jobs: 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" + + 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 not found in imported keychain" + exit 1 + fi + echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + echo "Imported signing identity: $IDENTITY" + - name: Resolve Swift packages run: | xcodebuild -workspace SwiftTA.xcworkspace \ @@ -32,36 +76,93 @@ jobs: - name: Build TAassets (Release) run: | - 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 + 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" \ + 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: | - 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 + 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" \ + 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: 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; 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" + xcrun notarytool submit "$ZIP_FOR_NOTARY" \ + --apple-id "$NOTARY_APPLE_ID" \ + --team-id "$NOTARY_TEAM_ID" \ + --password "$NOTARY_PASSWORD" \ + --wait + 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 - ( cd "$PRODUCTS" && zip -ry "$GITHUB_WORKSPACE/dist/TAassets-macOS.zip" TAassets.app ) - ( cd "$PRODUCTS" && zip -ry "$GITHUB_WORKSPACE/dist/HPIView-macOS.zip" HPIView.app ) + # 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 ls -lh dist - name: Upload build artifacts @@ -81,9 +182,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail SHORT=$(git rev-parse --short HEAD) - NOTES="Rolling build from main.\n\nCommit: ${{ github.sha }}\nBuilt: $(date -u +%Y-%m-%dT%H:%M:%SZ)" - # Delete prior latest release + tag (ignore failure if none exists). + 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 \ @@ -98,9 +206,17 @@ jobs: 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 \ --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/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. From 8b4759c47d90539864d8f65e57a2fc19f010179c Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 08:43:02 -0700 Subject: [PATCH 40/54] Dumps all keychain identities before matching so CI cert failures are diagnosable. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous failure mode printed only "Developer ID Application identity not found" with no indication of what was actually imported — a .p12 with just the certificate (no private key) looked identical in the log to a .p12 with the wrong certificate type. Log now prints every codesigning identity and every certificate label present in the keychain before the match, and the error message explicitly calls out the "export both the cert and its key" gotcha. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e6e5306..25a9cf6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,10 +58,18 @@ jobs: "$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 not found in imported keychain" + 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 echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" From ae139321d8b43fa26ff1e2b58f49793794d10fae Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 08:52:42 -0700 Subject: [PATCH 41/54] Overrides DEVELOPMENT_TEAM on the command line so inherited xcodeproj settings don't pin signing to the upstream maintainer's team. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HPIView.xcodeproj carries DEVELOPMENT_TEAM=544Z2URGY9 from the upstream loganjones fork, so xcodebuild refused to sign with our Developer ID cert: "No certificate for team 544Z2URGY9 matching 'Developer ID Application: Azimuth Systems LLC (D96…)' found." TAassets happened to sign because its xcodeproj had no team baked in. Parse the team ID out of the imported identity's Common Name and pass DEVELOPMENT_TEAM= on every xcodebuild invocation so both apps sign under the team that owns the cert. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25a9cf6..901296c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,9 +72,20 @@ jobs: 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" + echo "Imported signing identity: $IDENTITY (team $TEAM_ID)" - name: Resolve Swift packages run: | @@ -93,6 +104,7 @@ jobs: -derivedDataPath build/DerivedData \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + DEVELOPMENT_TEAM="$SIGN_TEAM_ID" \ ENABLE_HARDENED_RUNTIME=YES \ OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ build @@ -120,6 +132,7 @@ jobs: -derivedDataPath build/DerivedData \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + DEVELOPMENT_TEAM="$SIGN_TEAM_ID" \ ENABLE_HARDENED_RUNTIME=YES \ OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ build From 90b6f95880745e119d3f979444f0557b953f7b3d Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 09:15:58 -0700 Subject: [PATCH 42/54] Dumps the notarytool log after every submission so Invalid results are diagnosable. Notarization for TAassets landed with status Invalid and the stapler step then failed with no further information. notarytool returns exit code 0 when Apple accepts then rejects a submission, so the workflow believed it had succeeded until stapler complained; and it never pulled the log that would have said *why* Apple rejected it. Switched to --output-format json, parsed the status, ran notarytool log unconditionally so Invalid + Accepted both show their log in CI output, and error-out on anything other than Accepted before stapling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 901296c..9ed6654 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,11 +165,36 @@ jobs: echo "::group::Notarize $APP" ditto -c -k --keepParent "$APP_PATH" "$ZIP_FOR_NOTARY" - xcrun notarytool submit "$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" \ - --wait + 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" From df37254014b3131de25ec677b9fc68089c871ccb Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 09:40:44 -0700 Subject: [PATCH 43/54] Ships a release entitlements plist so notarization stops rejecting get-task-allow. Apple's notary service returned Invalid with: "The executable requests the com.apple.security.get-task-allow entitlement." When xcodebuild signs a target that has no explicit CODE_SIGN_ENTITLEMENTS set, it injects a default plist that includes get-task-allow=true (so debuggers can attach). That flag is never permitted in notarized binaries, so every submission failed validation. ci/release.entitlements is a minimal plist that pins get-task-allow to false. Both TAassets and HPIView release builds now pass CODE_SIGN_ENTITLEMENTS pointing at it, so Xcode stops injecting the debug-only entitlement and notarization has a clean signature to validate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 2 ++ ci/release.entitlements | 13 +++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 ci/release.entitlements diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ed6654..bc9724a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,6 +105,7 @@ jobs: 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 @@ -133,6 +134,7 @@ jobs: 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 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 + + + From 110b317cedc073856ab88bc9ea9c95684f9f3a05 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Thu, 23 Apr 2026 10:09:39 -0700 Subject: [PATCH 44/54] Stubs the TADR COB extensions that mod target-scan loops call. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORMKL (and most TAESC units) iterate over nearby units in Detect() with the TA Recorder / Unofficial Patch 3.9 extensions: for id := get MIN_ID() to get MAX_ID() do if not get UNIT_ALLIED(id) then … check height, distance, … Our VM fell through on all the extended IDs and returned 0, which meant MAX_ID also returned 0, the loop ran once with id=0, and UNIT_ALLIED(0) returning 0 classified id=0 as hostile — so every mod unit thought it was surrounded by enemies and flipped armor / shield state accordingly on load. Adds the six IDs CORMKL actually hits (MIN_ID=69, MAX_ID=70, MY_ID=71, UNIT_BUILD_PERCENT_LEFT=73, UNIT_ALLIED=74, UNIT_IS_ON_THIS_COMP=75) to UnitScript.UnitValue, wires them into both getUnitValue and getFunctionResult dispatchers, and teaches the decompiler to render them by name. In the no-sim viewer: - MIN_ID = 1, MAX_ID = 0 → iteration loop exits without entering - MY_ID = 0 - UNIT_BUILD_PERCENT_LEFT = 0 (fully built) - UNIT_ALLIED = 1 (always allied — "skip" branch wins) - UNIT_IS_ON_THIS_COMP = 1 The full TADR extension set (~150 IDs) is the reference spec for a real sim like AEX; SwiftTA only implements enough to keep mod target-scan logic from misfiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitScript+CobDecompile.swift | 7 +++++ .../UnitScript+Instructions.swift | 23 +++++++++++++++ .../Sources/SwiftTA-Core/UnitScript.swift | 28 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) 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 e84ef2d..935dc45 100644 --- a/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift +++ b/SwiftTA-Core/Sources/SwiftTA-Core/UnitScript+Instructions.swift @@ -687,6 +687,17 @@ private func getUnitValue(execution: ScriptExecutionContext) throws { 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: () } @@ -764,6 +775,18 @@ private func getFunctionResult(execution: ScriptExecutionContext) throws { } 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: () } 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 From e0bda08c581f0d2592ebf9828f6d2aeabe07796b Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 07:52:09 -0700 Subject: [PATCH 45/54] =?UTF-8?q?Drops=20the=20upstream=20game-client=20so?= =?UTF-8?q?urce=20trees=20=E2=80=94=20this=20fork=20only=20maintains=20the?= =?UTF-8?q?=20asset=20inspectors.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the SwiftTA macOS, SwiftTA iOS, and SwiftTA Linux source trees, their xcodeproj/Package.swift entries, and the workspace references that pointed at them. CI never built them; they weren't imported by TAassets or HPIView; they were just clutter inherited from the original loganjones/SwiftTA. Anyone who wants the game client can clone upstream directly. The Swift packages the game client once shared with the inspectors (SwiftTA-Core, SwiftTA-Ctypes, SwiftTA-Metal, SwiftTA-OpenGL3) all stay — TAassets and HPIView depend on them. Also relocates the legacy screenshots (SwiftTA.jpg, TAassets.gif, HpiView.jpg) under docs/images/ and fixes the references in docs/ORIGINAL_README.md, so the preserved upstream README still renders intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- SwiftTA Linux/Cgl/Package.swift | 10 - SwiftTA Linux/Cgl/README.md | 3 - SwiftTA Linux/Cgl/module.modulemap | 5 - SwiftTA Linux/Cgl/shim.h | 4 - SwiftTA Linux/Cglfw/Package.swift | 14 - SwiftTA Linux/Cglfw/README.md | 3 - SwiftTA Linux/Cglfw/module.modulemap | 5 - SwiftTA Linux/Cglfw/shim.h | 3 - SwiftTA Linux/Ctypes/Package.swift | 10 - SwiftTA Linux/Ctypes/README.md | 3 - SwiftTA Linux/Ctypes/module.modulemap | 4 - SwiftTA Linux/Ctypes/shim.h | 8 - SwiftTA Linux/Czlib/Package.swift | 12 - SwiftTA Linux/Czlib/README.md | 3 - SwiftTA Linux/Czlib/module.modulemap | 5 - SwiftTA Linux/Czlib/shim.h | 3 - SwiftTA Linux/SwiftTA/Package.resolved | 8 - SwiftTA Linux/SwiftTA/Package.swift | 31 - SwiftTA Linux/SwiftTA/main.swift | 193 ----- .../SwiftTA iOS.xcodeproj/project.pbxproj | 371 --------- .../xcschemes/SwiftTA iOS.xcscheme | 87 -- SwiftTA iOS/SwiftTA iOS/AppDelegate.swift | 46 -- .../AppIcon.appiconset/Contents.json | 98 --- .../SwiftTA iOS/Assets.xcassets/Contents.json | 6 - .../Base.lproj/LaunchScreen.storyboard | 41 - .../SwiftTA iOS/Base.lproj/Main.storyboard | 45 -- .../SwiftTA iOS/GameViewController.swift | 84 -- SwiftTA iOS/SwiftTA iOS/Info.plist | 47 -- .../SwiftTA macOS.xcodeproj/project.pbxproj | 372 --------- .../xcschemes/SwiftTA macOS.xcscheme | 87 -- SwiftTA macOS/SwiftTA macOS/AppDelegate.swift | 72 -- .../AppIcon.appiconset/Contents.json | 58 -- .../Assets.xcassets/Contents.json | 6 - .../SwiftTA macOS/Base.lproj/Main.storyboard | 742 ------------------ .../SwiftTA macOS/GameViewController.swift | 229 ------ SwiftTA macOS/SwiftTA macOS/Info.plist | 32 - .../SwiftTA macOS/SwiftTA_macOS.entitlements | 5 - SwiftTA.xcworkspace/contents.xcworkspacedata | 6 - docs/FORK_NOTES.md | 2 + docs/ORIGINAL_README.md | 10 +- HpiView.jpg => docs/images/HpiView.jpg | Bin SwiftTA.jpg => docs/images/SwiftTA.jpg | Bin TAassets.gif => docs/images/TAassets.gif | Bin 43 files changed, 8 insertions(+), 2765 deletions(-) delete mode 100644 SwiftTA Linux/Cgl/Package.swift delete mode 100644 SwiftTA Linux/Cgl/README.md delete mode 100644 SwiftTA Linux/Cgl/module.modulemap delete mode 100644 SwiftTA Linux/Cgl/shim.h delete mode 100644 SwiftTA Linux/Cglfw/Package.swift delete mode 100644 SwiftTA Linux/Cglfw/README.md delete mode 100644 SwiftTA Linux/Cglfw/module.modulemap delete mode 100644 SwiftTA Linux/Cglfw/shim.h delete mode 100644 SwiftTA Linux/Ctypes/Package.swift delete mode 100644 SwiftTA Linux/Ctypes/README.md delete mode 100644 SwiftTA Linux/Ctypes/module.modulemap delete mode 100644 SwiftTA Linux/Ctypes/shim.h delete mode 100644 SwiftTA Linux/Czlib/Package.swift delete mode 100644 SwiftTA Linux/Czlib/README.md delete mode 100644 SwiftTA Linux/Czlib/module.modulemap delete mode 100644 SwiftTA Linux/Czlib/shim.h delete mode 100644 SwiftTA Linux/SwiftTA/Package.resolved delete mode 100644 SwiftTA Linux/SwiftTA/Package.swift delete mode 100644 SwiftTA Linux/SwiftTA/main.swift delete mode 100644 SwiftTA iOS/SwiftTA iOS.xcodeproj/project.pbxproj delete mode 100644 SwiftTA iOS/SwiftTA iOS.xcodeproj/xcshareddata/xcschemes/SwiftTA iOS.xcscheme delete mode 100644 SwiftTA iOS/SwiftTA iOS/AppDelegate.swift delete mode 100644 SwiftTA iOS/SwiftTA iOS/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 SwiftTA iOS/SwiftTA iOS/Assets.xcassets/Contents.json delete mode 100644 SwiftTA iOS/SwiftTA iOS/Base.lproj/LaunchScreen.storyboard delete mode 100644 SwiftTA iOS/SwiftTA iOS/Base.lproj/Main.storyboard delete mode 100644 SwiftTA iOS/SwiftTA iOS/GameViewController.swift delete mode 100644 SwiftTA iOS/SwiftTA iOS/Info.plist delete mode 100644 SwiftTA macOS/SwiftTA macOS.xcodeproj/project.pbxproj delete mode 100644 SwiftTA macOS/SwiftTA macOS.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme delete mode 100644 SwiftTA macOS/SwiftTA macOS/AppDelegate.swift delete mode 100644 SwiftTA macOS/SwiftTA macOS/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 SwiftTA macOS/SwiftTA macOS/Assets.xcassets/Contents.json delete mode 100644 SwiftTA macOS/SwiftTA macOS/Base.lproj/Main.storyboard delete mode 100644 SwiftTA macOS/SwiftTA macOS/GameViewController.swift delete mode 100644 SwiftTA macOS/SwiftTA macOS/Info.plist delete mode 100644 SwiftTA macOS/SwiftTA macOS/SwiftTA_macOS.entitlements rename HpiView.jpg => docs/images/HpiView.jpg (100%) rename SwiftTA.jpg => docs/images/SwiftTA.jpg (100%) rename TAassets.gif => docs/images/TAassets.gif (100%) 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.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme b/SwiftTA macOS/SwiftTA macOS.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme deleted file mode 100644 index 1845a2b..0000000 --- a/SwiftTA macOS/SwiftTA macOS.xcodeproj/xcshareddata/xcschemes/SwiftTA macOS.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/AppIcon.appiconset/Contents.json b/SwiftTA macOS/SwiftTA macOS/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 2db2b1c..0000000 --- a/SwiftTA macOS/SwiftTA macOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "size" : "16x16", - "scale" : "1x" - }, - { - "idiom" : "mac", - "size" : "16x16", - "scale" : "2x" - }, - { - "idiom" : "mac", - "size" : "32x32", - "scale" : "1x" - }, - { - "idiom" : "mac", - "size" : "32x32", - "scale" : "2x" - }, - { - "idiom" : "mac", - "size" : "128x128", - "scale" : "1x" - }, - { - "idiom" : "mac", - "size" : "128x128", - "scale" : "2x" - }, - { - "idiom" : "mac", - "size" : "256x256", - "scale" : "1x" - }, - { - "idiom" : "mac", - "size" : "256x256", - "scale" : "2x" - }, - { - "idiom" : "mac", - "size" : "512x512", - "scale" : "1x" - }, - { - "idiom" : "mac", - "size" : "512x512", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file 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/Info.plist b/SwiftTA macOS/SwiftTA macOS/Info.plist deleted file mode 100644 index 24c73d5..0000000 --- a/SwiftTA macOS/SwiftTA macOS/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - Copyright © 2018 Logan Jones. All rights reserved. - NSMainStoryboardFile - Main - NSPrincipalClass - NSApplication - - 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.xcworkspace/contents.xcworkspacedata b/SwiftTA.xcworkspace/contents.xcworkspacedata index 49f4a1e..869acd1 100644 --- a/SwiftTA.xcworkspace/contents.xcworkspacedata +++ b/SwiftTA.xcworkspace/contents.xcworkspacedata @@ -13,12 +13,6 @@ - - - - diff --git a/docs/FORK_NOTES.md b/docs/FORK_NOTES.md index ab64717..c180bf3 100644 --- a/docs/FORK_NOTES.md +++ b/docs/FORK_NOTES.md @@ -4,6 +4,8 @@ This repository is a fork of [loganjones/SwiftTA](https://github.com/loganjones/ 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. diff --git a/docs/ORIGINAL_README.md b/docs/ORIGINAL_README.md index 3e9e8b6..4f9afd8 100644 --- a/docs/ORIGINAL_README.md +++ b/docs/ORIGINAL_README.md @@ -1,12 +1,14 @@ # 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 top-level [README.md](../README.md) covers the asset-inspector tooling that this fork focuses on; the more recent fork-specific notes are in [FORK_NOTES.md](FORK_NOTES.md). +> 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](../SwiftTA.jpg "SwiftTA Screenshot") +![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. @@ -45,7 +47,7 @@ Note: You will need an OpenGL 3.0 capable graphics driver to run the game. For d ## TAassets -![Screenshot](../TAassets.gif "TAassets Screenshot") +![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). @@ -53,7 +55,7 @@ You will need a Mac (natch) and a Total Annihilation installation somewhere on y ## HPIView -![Screenshot](../HpiView.jpg "HpiView Screenshot") +![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). 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 From b9884a0b3a139ea28ecb9ad0e7870e1741587e27 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 09:24:41 -0700 Subject: [PATCH 46/54] Adds Phase 1 of the AEX-MapEditor groundwork: TNT and TDF writers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core adds: - TaMapModel.writeTnt() — serializes a TA-version TNT map to a binary blob byte-equivalent at the section level to what Cavedog writes (header/ext-header/tile-index/map-info/tile-array/features/minimap). Validates invariants (sample counts, tile sizes, feature indices) before emission. - TdfParser.Object.serializeAsTdf() and a Dictionary extension for the top-level .ota / .fbi / .tdf shape, so OTA round-trips happen at the lossless parser-object level instead of through the MapInfo view. Keys are sorted for determinism; the emitter is stable under repeated round-trips. Tests cover both writers with synthetic maps, no-feature edge cases, error paths (oversized feature names, mismatched sample counts), mutation-then-round-trip, and an env-gated real-map round-trip (SWIFTTA_TEST_MAPS_DIR) that stays skipped until someone points it at loose .tnt files. CI now runs `swift test` before building the apps so writer regressions fail fast instead of getting masked by a clean app build. No UI changes yet — that's Phase 2, which creates the AEX-MapEditor app target and the height-brush MVP. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 8 + .../SwiftTA-Core/MapModel+Writer.swift | 201 ++++++++++++ .../SwiftTA-Core/TdfParser+Writer.swift | 90 ++++++ .../SwiftTA-CoreTests/SwiftTA_CoreTests.swift | 18 +- .../TaMapModelWriterTests.swift | 285 ++++++++++++++++++ .../SwiftTA-CoreTests/TdfWriterTests.swift | 169 +++++++++++ 6 files changed, 761 insertions(+), 10 deletions(-) create mode 100644 SwiftTA-Core/Sources/SwiftTA-Core/MapModel+Writer.swift create mode 100644 SwiftTA-Core/Sources/SwiftTA-Core/TdfParser+Writer.swift create mode 100644 SwiftTA-Core/Tests/SwiftTA-CoreTests/TaMapModelWriterTests.swift create mode 100644 SwiftTA-Core/Tests/SwiftTA-CoreTests/TdfWriterTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc9724a..a273e54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,6 +87,14 @@ jobs: 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 \ 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/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/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 + } +} From 0c5791b37a173857848b402c2d9fd63a8f068807 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 09:37:08 -0700 Subject: [PATCH 47/54] =?UTF-8?q?Adds=20AEX-MapEditor=20Phase=202=20MVP=20?= =?UTF-8?q?=E2=80=94=20a=20standalone=20heightmap=20editor.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor is a new macOS app target, linked against SwiftTA-Core to reuse Phase 1's TNT writer. Open a loose .tnt file, left-drag to raise heights, right-click to toggle erase (lower), Cmd-Z/Cmd-Shift-Z undo/redo, Cmd-S saves back to the file with a .tnt.bak snapshot on first write. Any .ota sibling gets loaded and round-tripped through the TDF writer on save so existing metadata doesn't get discarded even though we aren't editing it yet. Rendering in this MVP is Core Graphics grayscale — Phase 4 (tile painting) will promote to the Metal map renderer shared with TAassets. Five source files total: - AppDelegate programmatic menu + File → Open - EditableMap wrapping TaMapModel + OTA with save-with-backup - HeightBrushCommand and HeightBrushStroke for the brush tool + one-stroke-per-undo semantics - MapCanvasView for the raster + brush-footprint overlay + mouse - MapEditorWindowController owning the tool palette + undo manager CI now builds / signs / notarizes the third app and ships AEX-MapEditor-macOS.zip to the rolling Latest release alongside the existing two. README lists it as early access. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 34 +- .../AEX-MapEditor.xcodeproj/project.pbxproj | 321 ++++++++++++++++++ .../xcschemes/AEX-MapEditor.xcscheme | 87 +++++ AEX-MapEditor/AEX-MapEditor/AppDelegate.swift | 165 +++++++++ .../AppIcon.appiconset/Contents.json | 58 ++++ AEX-MapEditor/AEX-MapEditor/EditableMap.swift | 144 ++++++++ .../AEX-MapEditor/HeightBrushCommand.swift | 138 ++++++++ AEX-MapEditor/AEX-MapEditor/Info.plist | 51 +++ .../AEX-MapEditor/MapCanvasView.swift | 206 +++++++++++ .../MapEditorWindowController.swift | 248 ++++++++++++++ README.md | 2 + SwiftTA.xcworkspace/contents.xcworkspacedata | 3 + 12 files changed, 1456 insertions(+), 1 deletion(-) create mode 100644 AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj create mode 100644 AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme create mode 100644 AEX-MapEditor/AEX-MapEditor/AppDelegate.swift create mode 100644 AEX-MapEditor/AEX-MapEditor/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 AEX-MapEditor/AEX-MapEditor/EditableMap.swift create mode 100644 AEX-MapEditor/AEX-MapEditor/HeightBrushCommand.swift create mode 100644 AEX-MapEditor/AEX-MapEditor/Info.plist create mode 100644 AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift create mode 100644 AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a273e54..4c5c635 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,6 +159,35 @@ jobs: 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: @@ -169,7 +198,7 @@ jobs: set -euo pipefail PRODUCTS=build/DerivedData/Build/Products/Release - for APP in TAassets HPIView; do + for APP in TAassets HPIView AEX-MapEditor; do APP_PATH="$PRODUCTS/$APP.app" ZIP_FOR_NOTARY="$RUNNER_TEMP/$APP-notary.zip" @@ -219,6 +248,7 @@ jobs: # 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 @@ -252,6 +282,7 @@ jobs: 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")" \ @@ -267,6 +298,7 @@ jobs: gh release create "$TAG" \ dist/TAassets-macOS.zip \ dist/HPIView-macOS.zip \ + dist/AEX-MapEditor-macOS.zip \ --title "$TAG" \ --generate-notes diff --git a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7ebd131 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj @@ -0,0 +1,321 @@ +// !$*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 */; }; + 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 = ""; }; + 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 = ( + AE0000020000000000000001 /* AppDelegate.swift */, + AE0000040000000000000001 /* EditableMap.swift */, + AE0000060000000000000001 /* HeightBrushCommand.swift */, + AE0000080000000000000001 /* MapCanvasView.swift */, + AE00000A0000000000000001 /* MapEditorWindowController.swift */, + 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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AE00001A0000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AE0000010000000000000001 /* AppDelegate.swift in Sources */, + AE0000030000000000000001 /* EditableMap.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/AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme b/AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme new file mode 100644 index 0000000..2f5aa97 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/xcshareddata/xcschemes/AEX-MapEditor.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift new file mode 100644 index 0000000..c78caef --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -0,0 +1,165 @@ +// +// 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 + + +@main +class AppDelegate: NSObject, NSApplicationDelegate { + + private var windowControllers: [MapEditorWindowController] = [] + + func applicationDidFinishLaunching(_ notification: Notification) { + installMainMenu() + } + + /// 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). + 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() + mainMenu.addItem(fileMenuItem) + let fileMenu = NSMenu(title: "File") + fileMenuItem.submenu = fileMenu + fileMenu.addItem(withTitle: "Open…", action: #selector(openDocument(_:)), keyEquivalent: "o") + let openRecent = fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "") + let recentMenu = NSMenu(title: "Open Recent") + recentMenu.perform(Selector(("_setMenuName:")), with: "NSRecentDocumentsMenu") // So AppKit populates it. + recentMenu.addItem(withTitle: "Clear Menu", action: #selector(NSDocumentController.clearRecentDocuments(_:)), keyEquivalent: "") + openRecent.submenu = recentMenu + 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() + 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() + 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 { + return true + } + + @IBAction func openDocument(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowedFileTypes = ["tnt"] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.message = "Pick a loose .tnt file to edit. (Maps inside .hpi / .ufo / .ccx archives must be exported first.)" + + panel.begin { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.openMap(at: url) + } + } + + func openMap(at url: URL) { + do { + let controller = try MapEditorWindowController(mapURL: url) + windowControllers.append(controller) + controller.showWindow(nil) + 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/Assets.xcassets/AppIcon.appiconset/Contents.json b/AEX-MapEditor/AEX-MapEditor/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2db2b1c --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "32x32", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "32x32", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "128x128", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "128x128", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "256x256", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "256x256", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "512x512", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "512x512", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/AEX-MapEditor/AEX-MapEditor/EditableMap.swift b/AEX-MapEditor/AEX-MapEditor/EditableMap.swift new file mode 100644 index 0000000..8e3a0ff --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/EditableMap.swift @@ -0,0 +1,144 @@ +// +// 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? + + /// 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 + } + + // 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 + } + + /// 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/AEX-MapEditor/AEX-MapEditor/Info.plist b/AEX-MapEditor/AEX-MapEditor/Info.plist new file mode 100644 index 0000000..d355f99 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AEX-MapEditor + CFBundleDisplayName + AEX Map Editor + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + 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..9c30caf --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift @@ -0,0 +1,206 @@ +// +// 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() +} + + +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 { needsDisplay = true } + } + + /// Brush configuration, surfaced to the window's tool palette. + var brushRadius: Int = 3 + var brushStrength: Int = 16 + /// When true, the next stroke lowers heights instead of raising. + var eraseMode: Bool = false + + // MARK: - Internal state + + private var activeStroke: HeightBrushStroke? + private var hoverCell: (col: Int, row: Int)? + + 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 } + drawHeightRaster(of: map, in: ctx) + drawBrushOverlay(in: ctx) + } + + private func drawHeightRaster(of map: EditableMap, in ctx: CGContext) { + let mapSize = map.model.mapSize + guard mapSize.area > 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.. (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..e87f5b4 --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift @@ -0,0 +1,248 @@ +// +// 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 brushRadiusSlider: NSSlider + private let brushStrengthSlider: NSSlider + private let radiusLabel: NSTextField + private let strengthLabel: NSTextField + private let modeSegmented: NSSegmentedControl + 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 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 + + self.mapInfoLabel = mapInfoLabel + self.modeSegmented = modeSegmented + self.brushRadiusSlider = brushRadiusSlider + self.brushStrengthSlider = brushStrengthSlider + self.radiusLabel = radiusLabel + self.strengthLabel = strengthLabel + + 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. + modeSegmented.target = self + modeSegmented.action = #selector(modeChanged(_:)) + brushRadiusSlider.target = self + brushRadiusSlider.action = #selector(radiusChanged(_:)) + brushStrengthSlider.target = self + brushStrengthSlider.action = #selector(strengthChanged(_:)) + + let stack = NSStackView(views: [mapInfoLabel, modeSegmented, radiusLabel, brushRadiusSlider, strengthLabel, brushStrengthSlider]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 10 + 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), + brushRadiusSlider.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + brushStrengthSlider.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + ]) + + 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 modeChanged(_ sender: NSSegmentedControl) { + canvas.eraseMode = sender.selectedSegment == 1 + } + + @objc private func radiusChanged(_ sender: NSSlider) { + canvas.brushRadius = Int(sender.integerValue) + radiusLabel.stringValue = "Radius: \(canvas.brushRadius)" + } + + @objc private func strengthChanged(_ sender: NSSlider) { + canvas.brushStrength = Int(sender.integerValue) + strengthLabel.stringValue = "Strength: \(canvas.brushStrength)" + } + + // MARK: - Save + + func saveMap() { + do { + try map.saveToCurrentLocation() + refreshTitle() + } catch { + presentError(error, contextMessage: "Couldn't save \(map.fileURL.lastPathComponent)") + } + } + + func saveMapAs() { + let panel = NSSavePanel() + panel.allowedFileTypes = ["tnt"] + panel.nameFieldStringValue = map.fileURL.lastPathComponent + panel.begin { [weak self] response in + guard response == .OK, let url = panel.url, let self else { return } + do { + try self.map.save(to: url, otaTo: nil) + self.refreshTitle() + NSDocumentController.shared.noteNewRecentDocumentURL(url) + } catch { + self.presentError(error, contextMessage: "Couldn't save \(url.lastPathComponent)") + } + } + } + + // MARK: - Undo / redo + + func undoLastEdit() { + if undoManagerLocal.canUndo { undoManagerLocal.undo() } + canvas.needsDisplay = true + refreshTitle() + } + + func redoLastEdit() { + if undoManagerLocal.canRedo { undoManagerLocal.redo() } + canvas.needsDisplay = true + refreshTitle() + } + + // MARK: - MapCanvasViewDelegate + + func canvasDidFinishStroke(_ command: MapCommand) { + undoManagerLocal.registerUndo(withTarget: self) { target in + command.revert(on: target.map) + target.canvas.needsDisplay = true + target.refreshTitle() + target.undoManagerLocal.registerUndo(withTarget: target) { redoTarget in + command.apply(to: redoTarget.map) + redoTarget.canvas.needsDisplay = true + redoTarget.refreshTitle() + redoTarget.registerRedo(command) + } + } + refreshTitle() + } + + private func registerRedo(_ command: MapCommand) { + undoManagerLocal.registerUndo(withTarget: self) { target in + command.revert(on: target.map) + target.canvas.needsDisplay = true + target.refreshTitle() + } + } + + func canvasDidModifyMap() { + refreshTitle() + } + + // MARK: - UI plumbing + + 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/README.md b/README.md index ff801e9..25be9f0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Two macOS apps for exploring Total Annihilation's game data. Point them at a fol - **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. 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. @@ -13,6 +14,7 @@ Grab the latest build from the [Releases page](https://github.com/csilvertooth/S - **`TAassets-macOS.zip`** — the full asset browser - **`HPIView-macOS.zip`** — archive viewer only +- **`AEX-MapEditor-macOS.zip`** — early-access heightmap editor The `Latest main` prerelease is refreshed on every push to `main`. Versioned releases (e.g. `v0.1.0`) are posted when they're cut. diff --git a/SwiftTA.xcworkspace/contents.xcworkspacedata b/SwiftTA.xcworkspace/contents.xcworkspacedata index 869acd1..60b6b83 100644 --- a/SwiftTA.xcworkspace/contents.xcworkspacedata +++ b/SwiftTA.xcworkspace/contents.xcworkspacedata @@ -19,4 +19,7 @@ + + From 550eeb9d69125cefa75b214bc9d29679bb58bb79 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:03:03 -0700 Subject: [PATCH 48/54] Gives the top-level menu items explicit titles. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a title on the NSMenu but not the NSMenuItem that contains it left the menu bar showing only the app-name menu — the File, Edit, and Window headers were empty strings. macOS renders the first top-level item using the process name regardless of what we set, but every subsequent item needs its own title string. Co-Authored-By: Claude Opus 4.7 (1M context) --- AEX-MapEditor/AEX-MapEditor/AppDelegate.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift index c78caef..5a22057 100644 --- a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -28,6 +28,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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() @@ -42,7 +46,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { appMenu.addItem(withTitle: "Quit AEX Map Editor", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") // File menu. - let fileMenuItem = NSMenuItem() + let fileMenuItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") mainMenu.addItem(fileMenuItem) let fileMenu = NSMenu(title: "File") fileMenuItem.submenu = fileMenu @@ -59,7 +63,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { saveAs.keyEquivalentModifierMask = [.command, .shift] // Edit menu (undo + redo; rest can go on later). - let editMenuItem = NSMenuItem() + let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") mainMenu.addItem(editMenuItem) let editMenu = NSMenu(title: "Edit") editMenuItem.submenu = editMenu @@ -68,7 +72,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { redoItem.keyEquivalentModifierMask = [.command, .shift] // Window menu. - let windowMenuItem = NSMenuItem() + let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") mainMenu.addItem(windowMenuItem) let windowMenu = NSMenu(title: "Window") windowMenuItem.submenu = windowMenu From 818fb4e72ac9d17cce0083e8315aa154ead75302 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:09:09 -0700 Subject: [PATCH 49/54] Promotes AEX-MapEditor to a foreground-regular app so it owns the menu bar. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without NSMainNibFile and without an @main stub that calls NSApplicationMain explicitly, nothing in the launch path sets the process activation policy to .regular. macOS then kept the previous app's menu bar and AEX-MapEditor ran as an accessory with no UI focus. Setting the policy and activating at launch gives it a proper menu bar and foreground presence. Also drops the private _setMenuName: selector fiddle for the Open Recent menu — AppKit populates that from NSDocumentController automatically for document-typed apps; we can wire a custom recent- documents menu later if needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- AEX-MapEditor/AEX-MapEditor/AppDelegate.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift index 5a22057..b95a387 100644 --- a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -18,6 +18,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var windowControllers: [MapEditorWindowController] = [] func applicationDidFinishLaunching(_ notification: Notification) { + // Without a MainMenu.xib, nothing in the launch path automatically + // promotes the process to a regular foreground app. Without that, + // the system keeps the menu bar of whichever app was active and + // AEX-MapEditor never gets one of its own. + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) installMainMenu() } @@ -51,11 +57,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let fileMenu = NSMenu(title: "File") fileMenuItem.submenu = fileMenu fileMenu.addItem(withTitle: "Open…", action: #selector(openDocument(_:)), keyEquivalent: "o") - let openRecent = fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "") - let recentMenu = NSMenu(title: "Open Recent") - recentMenu.perform(Selector(("_setMenuName:")), with: "NSRecentDocumentsMenu") // So AppKit populates it. - recentMenu.addItem(withTitle: "Clear Menu", action: #selector(NSDocumentController.clearRecentDocuments(_:)), keyEquivalent: "") - openRecent.submenu = recentMenu fileMenu.addItem(NSMenuItem.separator()) fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") fileMenu.addItem(withTitle: "Save", action: #selector(saveDocument(_:)), keyEquivalent: "s") From a222553d4bf74a3a351f1a6bd136913e6ed4b424 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:21:03 -0700 Subject: [PATCH 50/54] Wires up AppDelegate via explicit main.swift instead of @main. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @main on an NSApplicationDelegate calls NSApplicationMain under the hood, which reads NSMainNibFile from Info.plist to locate the delegate. This app ships without a MainMenu.xib and therefore without that key, so NSApplicationMain fell through to the default delegate — nothing. applicationWill/DidFinishLaunching never fired, installMainMenu() never ran, and the menu bar stayed at the minimum two system items (Apple + process name). main.swift now instantiates AppDelegate directly, assigns it to NSApplication.shared, sets the activation policy, and calls run(). Delegate callbacks fire, the programmatic four-menu bar installs cleanly, and the app activates as a regular foreground app. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AEX-MapEditor.xcodeproj/project.pbxproj | 4 ++++ AEX-MapEditor/AEX-MapEditor/AppDelegate.swift | 17 ++++++++++------- AEX-MapEditor/AEX-MapEditor/main.swift | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 AEX-MapEditor/AEX-MapEditor/main.swift diff --git a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj index 7ebd131..5fe9753 100644 --- a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 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 */; }; 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 */ @@ -22,6 +23,7 @@ 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 = ""; }; 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; }; @@ -50,6 +52,7 @@ AE0000150000000000000001 /* AEX-MapEditor */ = { isa = PBXGroup; children = ( + AE0000230000000000000001 /* main.swift */, AE0000020000000000000001 /* AppDelegate.swift */, AE0000040000000000000001 /* EditableMap.swift */, AE0000060000000000000001 /* HeightBrushCommand.swift */, @@ -143,6 +146,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AE0000220000000000000001 /* main.swift in Sources */, AE0000010000000000000001 /* AppDelegate.swift in Sources */, AE0000030000000000000001 /* EditableMap.swift in Sources */, AE0000050000000000000001 /* HeightBrushCommand.swift in Sources */, diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift index b95a387..a2e9191 100644 --- a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -12,19 +12,22 @@ import Cocoa import SwiftTA_Core -@main class AppDelegate: NSObject, NSApplicationDelegate { private var windowControllers: [MapEditorWindowController] = [] - func applicationDidFinishLaunching(_ notification: Notification) { - // Without a MainMenu.xib, nothing in the launch path automatically - // promotes the process to a regular foreground app. Without that, - // the system keeps the menu bar of whichever app was active and - // AEX-MapEditor never gets one of its own. + 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) - NSApp.activate(ignoringOtherApps: true) 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 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() From dc80916d7a6260b90487f73769a5044d5690e6cb Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:29:24 -0700 Subject: [PATCH 51/54] Stops the editor from quitting when the Open panel is dismissed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applicationShouldTerminateAfterLastWindowClosed was returning true unconditionally, and AppKit counts the File → Open panel as a window. So dismissing the panel — cancel OR even the "just opened, haven't picked yet" state — triggered the last-window-closed rule and the app exited before anything useful happened. Gate the terminate-on-close behaviour on "has an actual map window ever been opened" so a freshly-launched editor stays running until the user has opened at least one map and then closed it. Cancelling the open panel now leaves the app idle in the menu bar, as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- AEX-MapEditor/AEX-MapEditor/AppDelegate.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift index a2e9191..cebac38 100644 --- a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -90,9 +90,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + // 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"] @@ -111,6 +123,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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 From a5d19743bb492a4426c7875a0573d8f1a2c3b429 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:33:36 -0700 Subject: [PATCH 52/54] Opens maps directly out of .hpi / .ufo / .ccx / .gp3 / .gpf archives. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most TA-format maps ship inside Cavedog archives, not loose on disk. Requiring users to extract with an external tool before they could edit anything was an onboarding cliff. File → Open now accepts both .tnt and the five archive formats. When the user picks an archive: - ArchiveMapPicker walks the archive's directory tree, collects every .tnt file, and pairs it with its same-basename .ota sidecar when present. - A modal popup picker lets the user choose among the contained maps, sorted by name. - The selected .tnt and (if present) .ota are extracted to a per- archive folder under Application Support/AEX-MapEditor/Extracted/ so future opens of the same archive find the files already staged and the user has a known-writable location for edits. - The editor then opens the extracted .tnt like any loose map. Save writes back to that extracted copy; Save As can move it elsewhere. OTA-extract failures log but don't block the map from opening — they just mean the editor treats the map as having no metadata. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AEX-MapEditor.xcodeproj/project.pbxproj | 4 + AEX-MapEditor/AEX-MapEditor/AppDelegate.swift | 45 ++++- .../AEX-MapEditor/ArchiveMapPicker.swift | 163 ++++++++++++++++++ 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 AEX-MapEditor/AEX-MapEditor/ArchiveMapPicker.swift diff --git a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj index 5fe9753..ba113e7 100644 --- a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 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 */; }; 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 */ @@ -24,6 +25,7 @@ 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 = ""; }; 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; }; @@ -54,6 +56,7 @@ children = ( AE0000230000000000000001 /* main.swift */, AE0000020000000000000001 /* AppDelegate.swift */, + AE0000250000000000000001 /* ArchiveMapPicker.swift */, AE0000040000000000000001 /* EditableMap.swift */, AE0000060000000000000001 /* HeightBrushCommand.swift */, AE0000080000000000000001 /* MapCanvasView.swift */, @@ -148,6 +151,7 @@ files = ( AE0000220000000000000001 /* main.swift in Sources */, AE0000010000000000000001 /* AppDelegate.swift in Sources */, + AE0000240000000000000001 /* ArchiveMapPicker.swift in Sources */, AE0000030000000000000001 /* EditableMap.swift in Sources */, AE0000050000000000000001 /* HeightBrushCommand.swift in Sources */, AE0000070000000000000001 /* MapCanvasView.swift in Sources */, diff --git a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift index cebac38..85a8555 100644 --- a/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift +++ b/AEX-MapEditor/AEX-MapEditor/AppDelegate.swift @@ -107,17 +107,56 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBAction func openDocument(_ sender: Any?) { let panel = NSOpenPanel() - panel.allowedFileTypes = ["tnt"] + panel.allowedFileTypes = ["tnt", "hpi", "ufo", "ccx", "gp3", "gpf"] panel.allowsMultipleSelection = false panel.canChooseDirectories = false - panel.message = "Pick a loose .tnt file to edit. (Maps inside .hpi / .ufo / .ccx archives must be exported first.)" + 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?.openMap(at: url) + 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) 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 + } +} From 1ab6a3d3e4c85aa34ec9de04de91606ae54936d4 Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:40:17 -0700 Subject: [PATCH 53/54] Adds Phase 3 feature placement / removal to the editor. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palette now has a Heights / Features tool switcher. Features mode surfaces a popup of every feature type already declared in the map's feature table plus an "Add feature type…" button for typing a new name (e.g. Tree01, MetalPatch). Left-click assigns the selected feature to a cell, right-click erases whatever feature is at the cell. The map canvas draws an orange square on every cell carrying a feature so the layout is visible at a glance. Every edit — cell assignment, cell erasure, feature-type append — routes through its own MapCommand and lands on the window's undo stack, so Cmd-Z rolls back in the same order the user painted. Undoing an append correctly leaves existing assigns intact unless they pointed at the removed index; future hardening can validate that invariant explicitly. Rendering the actual feature sprite (loading the planet's feature pack GAFs) is Phase 3.x; the orange-cell overlay is enough for laying out obstacles and lining up with AEX passability. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AEX-MapEditor.xcodeproj/project.pbxproj | 4 + .../AEX-MapEditor/FeatureCommand.swift | 82 +++++++++ .../AEX-MapEditor/MapCanvasView.swift | 103 ++++++++++- .../MapEditorWindowController.swift | 162 ++++++++++++++++-- 4 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 AEX-MapEditor/AEX-MapEditor/FeatureCommand.swift diff --git a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj index ba113e7..709e7e9 100644 --- a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; 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 */ @@ -26,6 +27,7 @@ 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 = ""; }; 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; }; @@ -58,6 +60,7 @@ AE0000020000000000000001 /* AppDelegate.swift */, AE0000250000000000000001 /* ArchiveMapPicker.swift */, AE0000040000000000000001 /* EditableMap.swift */, + AE0000270000000000000001 /* FeatureCommand.swift */, AE0000060000000000000001 /* HeightBrushCommand.swift */, AE0000080000000000000001 /* MapCanvasView.swift */, AE00000A0000000000000001 /* MapEditorWindowController.swift */, @@ -153,6 +156,7 @@ AE0000010000000000000001 /* AppDelegate.swift in Sources */, AE0000240000000000000001 /* ArchiveMapPicker.swift in Sources */, AE0000030000000000000001 /* EditableMap.swift in Sources */, + AE0000260000000000000001 /* FeatureCommand.swift in Sources */, AE0000050000000000000001 /* HeightBrushCommand.swift in Sources */, AE0000070000000000000001 /* MapCanvasView.swift in Sources */, AE0000090000000000000001 /* MapEditorWindowController.swift in Sources */, diff --git a/AEX-MapEditor/AEX-MapEditor/FeatureCommand.swift b/AEX-MapEditor/AEX-MapEditor/FeatureCommand.swift new file mode 100644 index 0000000..1486f3f --- /dev/null +++ b/AEX-MapEditor/AEX-MapEditor/FeatureCommand.swift @@ -0,0 +1,82 @@ +// +// FeatureCommand.swift +// AEX-MapEditor +// +// Commands for Phase 3 — feature placement and removal. A feature in +// TA is whatever single item occupies a map cell (rocks, trees, +// wrecks, metal deposits). Each cell of featureMap carries either an +// index into model.features or nil for "nothing there". The editor +// manipulates that array directly; the game engine is responsible for +// rendering the actual sprite at load time. +// + +import Foundation +import SwiftTA_Core + + +/// Sets the feature index at a single cell and records the previous +/// value for undo. If the new index is nil (no feature), this is an +/// erase; if the index refers to a `model.features` entry that doesn't +/// yet exist, the caller is expected to have appended it to the array +/// first — this command doesn't mutate the feature table. +struct FeatureAssignCommand: MapCommand { + let cellIndex: Int + let previous: Int? + let next: Int? + + func apply(to map: EditableMap) { + guard map.model.featureMap.indices.contains(cellIndex) else { return } + map.model.featureMap[cellIndex] = next + map.markModified() + } + + func revert(on map: EditableMap) { + guard map.model.featureMap.indices.contains(cellIndex) else { return } + map.model.featureMap[cellIndex] = previous + map.markModified() + } +} + + +/// Appends a new feature type to the map's feature table. Used when +/// the user types a name that isn't already present; the index of the +/// newly-added entry is returned via the command's `appendedIndex` so +/// a subsequent `FeatureAssignCommand` can place it. Kept as its own +/// command so undoing the assign doesn't leave orphaned entries in +/// the feature table, and undoing the *add* doesn't break assigns. +struct FeatureTypeAppendCommand: MapCommand { + let featureName: String + /// Filled in after the first apply so revert knows what to remove. + /// Mutating inside a value type's method requires inout patterns, + /// so the command is recorded as a reference-backed wrapper in the + /// undo stack — see `FeatureTypeAppendCommand.Record`. + final class Record { + var appendedIndex: Int? + } + let record: Record + + init(featureName: String) { + self.featureName = featureName + self.record = Record() + } + + func apply(to map: EditableMap) { + let newIndex = map.model.features.count + map.model.features.append(FeatureTypeId(named: featureName)) + record.appendedIndex = newIndex + map.markModified() + } + + func revert(on map: EditableMap) { + // Only pop the last entry if it's actually the one we appended; + // a re-ordering done by another command in between would make + // blind popping unsafe. + guard let idx = record.appendedIndex, + idx == map.model.features.count - 1, + map.model.features.indices.contains(idx), + map.model.features[idx].name == featureName + else { return } + map.model.features.removeLast() + map.markModified() + } +} diff --git a/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift index 9c30caf..4f74dfc 100644 --- a/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift +++ b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift @@ -23,6 +23,19 @@ protocol MapCanvasViewDelegate: AnyObject { /// 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 } @@ -41,9 +54,14 @@ final class MapCanvasView: NSView { /// Brush configuration, surfaced to the window's tool palette. var brushRadius: Int = 3 var brushStrength: Int = 16 - /// When true, the next stroke lowers heights instead of raising. + /// 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? @@ -72,9 +90,35 @@ final class MapCanvasView: NSView { override func draw(_ dirtyRect: NSRect) { guard let map = map, let ctx = NSGraphicsContext.current?.cgContext else { return } drawHeightRaster(of: map, in: ctx) + drawFeatureOverlay(of: map, in: ctx) drawBrushOverlay(in: ctx) } + 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 } @@ -139,16 +183,23 @@ final class MapCanvasView: NSView { // MARK: - Mouse interaction override func mouseDown(with event: NSEvent) { - let delta = eraseMode ? -brushStrength : brushStrength - activeStroke = HeightBrushStroke(config: .init(radius: brushRadius, delta: delta)) - applyStamp(at: event.locationInWindow) + switch activeTool { + case .heights: + let delta = eraseMode ? -brushStrength : brushStrength + activeStroke = HeightBrushStroke(config: .init(radius: brushRadius, delta: delta)) + applyStamp(at: event.locationInWindow) + case .features: + handleFeatureClick(at: event.locationInWindow, remove: false) + } } 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) @@ -156,6 +207,35 @@ final class MapCanvasView: NSView { 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) @@ -168,11 +248,16 @@ final class MapCanvasView: NSView { } override func rightMouseDown(with event: NSEvent) { - // Right-click toggles erase mode for the next stroke — same ergonomics - // as most painting apps' "alt to erase" gesture. Shift could go here - // later; keeping it basic for MVP. - eraseMode.toggle() - needsDisplay = true + 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) + } } private func applyStamp(at windowPoint: CGPoint) { diff --git a/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift index e87f5b4..62c6d81 100644 --- a/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift +++ b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift @@ -16,11 +16,16 @@ 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 heightsGroup: NSStackView + private let featuresGroup: NSStackView private let mapInfoLabel: NSTextField private let undoManagerLocal = UndoManager() @@ -49,6 +54,10 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate mapInfoLabel.lineBreakMode = .byWordWrapping mapInfoLabel.maximumNumberOfLines = 3 + let toolSegmented = NSSegmentedControl(labels: ["Heights", "Features"], 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 @@ -61,12 +70,46 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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 + 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 + + 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 + + self.heightsGroup = heightsGroup + self.featuresGroup = featuresGroup canvas = MapCanvasView(frame: NSRect(x: paletteWidth, y: 0, width: windowFrame.width - paletteWidth, height: windowFrame.height)) canvas.autoresizingMask = [.width, .height] @@ -75,17 +118,21 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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(_:)) - let stack = NSStackView(views: [mapInfoLabel, modeSegmented, radiusLabel, brushRadiusSlider, strengthLabel, brushStrengthSlider]) + let stack = NSStackView(views: [mapInfoLabel, toolSegmented, heightsGroup, featuresGroup]) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 10 + stack.spacing = 12 stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) stack.translatesAutoresizingMaskIntoConstraints = false palette.addSubview(stack) @@ -94,8 +141,12 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate stack.topAnchor.constraint(equalTo: palette.topAnchor), stack.leadingAnchor.constraint(equalTo: palette.leadingAnchor), stack.trailingAnchor.constraint(equalTo: palette.trailingAnchor), - brushRadiusSlider.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), - brushStrengthSlider.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -24), + 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), ]) let container = NSView(frame: windowFrame) @@ -139,10 +190,45 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate // MARK: - Tool palette actions + @objc private func toolChanged(_ sender: NSSegmentedControl) { + let tool: MapCanvasTool = sender.selectedSegment == 0 ? .heights : .features + canvas.activeTool = tool + heightsGroup.isHidden = tool != .heights + featuresGroup.isHidden = tool != .features + } + @objc private func modeChanged(_ sender: NSSegmentedControl) { canvas.eraseMode = sender.selectedSegment == 1 } + @objc private func addFeatureTypePrompt(_ sender: Any?) { + let alert = NSAlert() + alert.messageText = "Add a feature type" + alert.informativeText = "Enter the exact feature name (from FBI / features TDF) to add to this map's feature table." + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + textField.placeholderString = "e.g. Tree01, MetalPatch, SmallRock01" + alert.accessoryView = textField + + alert.addButton(withTitle: "Add") + alert.addButton(withTitle: "Cancel") + + // Focus the text field so the user can start typing immediately. + DispatchQueue.main.async { textField.window?.makeFirstResponder(textField) } + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let trimmed = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + // Run the append command through the undo stack so this is + // reversible. We also record the command so future assigns to the + // new index come AFTER this append in the history. + let command = FeatureTypeAppendCommand(featureName: trimmed) + registerNewUndoableCommand(command) + + rebuildFeaturePopup(selecting: map.model.features.count - 1) + } + @objc private func radiusChanged(_ sender: NSSlider) { canvas.brushRadius = Int(sender.integerValue) radiusLabel.stringValue = "Radius: \(canvas.brushRadius)" @@ -197,34 +283,78 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate // MARK: - MapCanvasViewDelegate func canvasDidFinishStroke(_ command: MapCommand) { + // The canvas already applied the command when the stroke ended; + // we only need to record undo here. registerNewUndoableCommand + // handles the cycle for both new actions and clicks from feature + // tool. + registerUndoForAlreadyAppliedCommand(command) + refreshTitle() + rebuildFeaturePopup() + } + + func canvasDidModifyMap() { + refreshTitle() + } + + func canvasWantsFeatureAssignment(forCell index: Int) -> 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.needsDisplay = true + target.rebuildFeaturePopup() target.refreshTitle() - target.undoManagerLocal.registerUndo(withTarget: target) { redoTarget in - command.apply(to: redoTarget.map) - redoTarget.canvas.needsDisplay = true - redoTarget.refreshTitle() - redoTarget.registerRedo(command) - } + target.registerRedo(command) } - refreshTitle() } private func registerRedo(_ command: MapCommand) { undoManagerLocal.registerUndo(withTarget: self) { target in - command.revert(on: target.map) + command.apply(to: target.map) target.canvas.needsDisplay = true + target.rebuildFeaturePopup() target.refreshTitle() + target.registerUndoForAlreadyAppliedCommand(command) } } - func canvasDidModifyMap() { - refreshTitle() - } - // 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)" From f6bae1ab30e336676ba7d2adf83f1e38d576e7ef Mon Sep 17 00:00:00 2001 From: Chris Silvertooth Date: Fri, 24 Apr 2026 10:49:51 -0700 Subject: [PATCH 54/54] Adds Phase 4 tile painting and tile-raster view to the editor. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces land together: - MapRasterizer assembles a full-resolution RGBA image of the map's current tile layout by walking the tile-index map and blitting each referenced tile from the tileset with the supplied palette applied. Cached on the canvas and invalidated whenever a tile command or undo/redo lands. - A Tiles tool joins Heights and Features on the palette. In Tiles mode the canvas renders the actual painted map in colour, a dropdown lists every tile in the map's tileset (with 32×32 previews as menu-item images), and clicking on the map replaces the tile at the clicked 32×32 cell with whatever the dropdown has selected. Each paint is a TilePaintCommand on the undo stack. - The vanilla Cavedog palette (PALETTE.PAL, 256 RGBA entries) ships as a bundled resource and loads lazily when a map opens. EditableMap.palette exposes it so later phases can substitute a per-planet palette when we wire feature-pack loading. Known gaps — no minimap regeneration on tile edits yet (minimap is cached as the author shipped it), no tile-picker grid (dropdown is functional but not a true palette), no bucket-fill. Those stay on the Phase 4.x list. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AEX-MapEditor.xcodeproj/project.pbxproj | 12 ++ AEX-MapEditor/AEX-MapEditor/EditableMap.swift | 19 +++ .../AEX-MapEditor/MapCanvasView.swift | 92 ++++++++++- .../MapEditorWindowController.swift | 70 ++++++++- .../AEX-MapEditor/MapRasterizer.swift | 145 ++++++++++++++++++ AEX-MapEditor/AEX-MapEditor/PALETTE.PAL | Bin 0 -> 1024 bytes AEX-MapEditor/AEX-MapEditor/TileCommand.swift | 42 +++++ 7 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 AEX-MapEditor/AEX-MapEditor/MapRasterizer.swift create mode 100644 AEX-MapEditor/AEX-MapEditor/PALETTE.PAL create mode 100644 AEX-MapEditor/AEX-MapEditor/TileCommand.swift diff --git a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj index 709e7e9..883a1ff 100644 --- a/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj +++ b/AEX-MapEditor/AEX-MapEditor.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 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 */ @@ -28,6 +31,9 @@ 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; }; @@ -64,6 +70,9 @@ AE0000060000000000000001 /* HeightBrushCommand.swift */, AE0000080000000000000001 /* MapCanvasView.swift */, AE00000A0000000000000001 /* MapEditorWindowController.swift */, + AE0000290000000000000001 /* MapRasterizer.swift */, + AE00002B0000000000000001 /* TileCommand.swift */, + AE00002D0000000000000001 /* PALETTE.PAL */, AE00000C0000000000000001 /* Assets.xcassets */, AE0000110000000000000001 /* Info.plist */, ); @@ -142,6 +151,7 @@ buildActionMask = 2147483647; files = ( AE00000B0000000000000001 /* Assets.xcassets in Resources */, + AE00002C0000000000000001 /* PALETTE.PAL in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -157,6 +167,8 @@ 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 */, diff --git a/AEX-MapEditor/AEX-MapEditor/EditableMap.swift b/AEX-MapEditor/AEX-MapEditor/EditableMap.swift index 8e3a0ff..f80cdcd 100644 --- a/AEX-MapEditor/AEX-MapEditor/EditableMap.swift +++ b/AEX-MapEditor/AEX-MapEditor/EditableMap.swift @@ -26,6 +26,11 @@ final class EditableMap { 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 @@ -43,6 +48,8 @@ final class EditableMap { 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") @@ -76,6 +83,18 @@ final class EditableMap { 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 diff --git a/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift index 4f74dfc..85582d2 100644 --- a/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift +++ b/AEX-MapEditor/AEX-MapEditor/MapCanvasView.swift @@ -36,6 +36,7 @@ protocol MapCanvasViewDelegate: AnyObject { enum MapCanvasTool { case heights case features + case tiles } @@ -48,9 +49,17 @@ final class MapCanvasView: NSView { /// so setting a new map or committing a command both require /// `needsDisplay = true` to repaint. var map: EditableMap? { - didSet { needsDisplay = true } + 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 @@ -66,6 +75,16 @@ final class MapCanvasView: NSView { 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 } @@ -89,11 +108,30 @@ final class MapCanvasView: NSView { override func draw(_ dirtyRect: NSRect) { guard let map = map, let ctx = NSGraphicsContext.current?.cgContext else { return } - drawHeightRaster(of: map, in: ctx) + 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 @@ -190,9 +228,55 @@ final class MapCanvasView: NSView { applyStamp(at: event.locationInWindow) case .features: handleFeatureClick(at: event.locationInWindow, remove: false) + case .tiles: + handleTileClick(at: event.locationInWindow) } } + private func handleTileClick(at windowPoint: CGPoint) { + guard let map = map else { return } + let point = convert(windowPoint, from: nil) + guard let tile = tileMapCellUnder(point) else { return } + + let tileIndexCols = map.model.mapSize.width / 2 + let linear = tile.row * tileIndexCols + tile.col + let indices = map.model.tileIndexMap.indices + guard (linear + 1) * MemoryLayout.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) @@ -257,6 +341,10 @@ final class MapCanvasView: NSView { // 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 } } diff --git a/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift index 62c6d81..d83aee4 100644 --- a/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift +++ b/AEX-MapEditor/AEX-MapEditor/MapEditorWindowController.swift @@ -24,8 +24,11 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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() @@ -54,7 +57,7 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate mapInfoLabel.lineBreakMode = .byWordWrapping mapInfoLabel.maximumNumberOfLines = 3 - let toolSegmented = NSSegmentedControl(labels: ["Heights", "Features"], trackingMode: .selectOne, target: nil, action: nil) + let toolSegmented = NSSegmentedControl(labels: ["Heights", "Features", "Tiles"], trackingMode: .selectOne, target: nil, action: nil) toolSegmented.selectedSegment = 0 toolSegmented.controlSize = .regular @@ -87,6 +90,17 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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 @@ -96,6 +110,8 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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 @@ -108,8 +124,15 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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] @@ -128,8 +151,10 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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]) + let stack = NSStackView(views: [mapInfoLabel, toolSegmented, heightsGroup, featuresGroup, tilesGroup]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 @@ -147,6 +172,10 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate 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) @@ -191,10 +220,41 @@ final class MapEditorWindowController: NSWindowController, MapCanvasViewDelegate // MARK: - Tool palette actions @objc private func toolChanged(_ sender: NSSegmentedControl) { - let tool: MapCanvasTool = sender.selectedSegment == 0 ? .heights : .features + 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.. 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..MoXD-bIX2ws9fAP~G{#R^_B?`+=6dnf%TGbeNA{Qdtk07JpoH2hu+zc)h( z{QCPBKYslCb>g=}-9Na$9XT!5>_;OrFhkqdzhH4AJ5Or7gP@pifK`hH-# zSu&_f@==#gFQt<;XtzSrIG_=Fgn^6i*|?60Wg6(Z#`AgNa@nxo&snWXrqdq%ev2%N zNz;Hhb_oLE>l)a$hGl7(CKys)*OB}u_xqjm`OM*PV7uMQJuBw(Ipgt|qA1AooOZiS zk|cy-i064Yjw5#%+&>hDs}lZ-*;-M|6y1R$%^yVVD?xI?4G&oUD@JXG<`igFCIlKn zz~}m@*qv`wyLZavhJ3Oh?T?8%FZfA^TGYa=H*x$1b}hnk>X^2VVb;(Mhm+{Hy*`*9 zE|i;h^2MHXvL-4P`1uUCJ;F}BbIIElX~Q51b>cuH^fc^$ts#2)Ba9Z@b4`GwP;hI;.size + guard byteOffset + MemoryLayout.size <= data.count else { return } + data.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt16.self) + p[linear] = value + } + } +}