Development - #89
Merged
Merged
Conversation
Replace the glob-based pre-"target" CMake (basics.cmake/torque2d.cmake + libraries/*.cmake) with modern, target-based CMake that lists engine sources EXPLICITLY, so CMake — not the filesystem or a hand-maintained .sln — becomes the source of truth. - cmake/EngineSources.cmake: explicit cross-platform engine source list - cmake/PlatformSources.cmake: Win32 sources (other platforms stubbed) - engine/lib/CMakeLists.txt: libogg/libvorbis/lpng/ljpeg/zlib as static targets (only the real library sources, excluding the standalone tools the old recursive glob wrongly compiled) - GoogleTest built via add_subdirectory and linked for the in-engine tests Reconciles drift vs the VS2022 project: adds 2d/editorToy and math/noise; includes 2d/gui/guiImageButtonCtrl.cc (on disk but never added to the .sln); drops dead sfx/spine; excludes mobile-only bitmapPvr.cc on Windows. Load-bearing Windows settings (documented inline + in CLAUDE.md): static /MT runtime for all configs (avoids _DEBUG -> tinyXML "#define DEBUG" -> Box2D C1017), /Zc:wchar_t- (wchar_t == engine UTF16), C++17, _HAS_STD_BYTE=0; drops the old spurious unconditional DEBUG=1. Verified: configures + builds Debug and Release with the VS2022 generator and the resulting exe launches (Project Manager UI, clean OpenGL init). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Provide double-click "generate the project file" scripts for users who do not
want to learn CMake (they install CMake once; pre-built binaries still cover the
least-advanced users):
- generate-vs2022.bat (tested, builds + runs)
- generate-vs2026.bat (CMake 4.x supports the VS18 2026 generator; needs
VS2026 installed to generate)
- generate-xcode.command / generate-make.sh (scaffolded, verify on platform)
Scaffold the remaining desktop/mobile Apple + Linux platforms in the modern
CMake (ported from the old recipe / the maintained Xcode + Xcode_iOS projects;
the old CMake never actually supported iOS):
- cmake/PlatformSources.cmake: explicit _MACOS, _LINUX, _IOS source lists
- CMakeLists.txt: APPLE (macOS frameworks), UNIX (X11/Xft/OpenGL/FreeType via
find_package, openal, pthread), and a distinct iOS branch (TORQUE_IOS, since
APPLE is also true on iOS) with the UIKit/OpenGLES framework set
- macOS/Linux/iOS are SCAFFOLDED, not yet verified on-platform
Docs and hygiene:
- README: step-by-step VS2022 generation guide for non-CMake users
- cmake/BUILD-PLATFORM-NOTES.md: status board + per-platform checklists and
known gotchas (SDL2 omitted, iOS needs bitmapPvr.cc + GameKit, WSL GUI, etc.)
- .gitattributes: fix a stray pasted line; pin LF for *.sh/*.command and CRLF
for *.bat so the scripts work on every platform
Windows still configures and builds clean (383 TUs); the macOS/Linux/iOS blocks
are inert on Windows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… files
Rewrite PR-builds.yml so each job generates a project file from the CMake
source of truth and builds it:
- Windows: VS2026 (windows-2025-vs2026) and VS2022, each x64 + Win32, via the
CMake VS generators (replaces the retired VS2019 jobs and direct msbuild).
- Linux: x86_64 and 32-bit (-m32 multilib) via the Unix Makefiles generator.
- macOS: the Xcode generator.
- iOS: the Xcode generator with -DCMAKE_SYSTEM_NAME=iOS, built without signing.
Each job pulls a recent CMake (lukka/get-cmake — the VS2026 generator needs
4.x), configures, builds Debug+Release, and uploads the package artifact.
Also: checkout v2->v4, add a concurrency cancel, and drop the now-unneeded
setup-msbuild / windows-sdk-install steps.
Windows is verified; macOS/Linux/iOS are freshly migrated and these jobs are
the verification loop for them (expected to need iteration). Running the unit
tests headlessly is a planned follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Android build was stale/broken: its hand-maintained ndk-build Android.mk
referenced dozens of deleted engine files. Rebuild it on the CMake source of
truth and modernize the Android Studio project.
CMake:
- CMakeLists.txt: Android branch builds the shared library libtorque2d.so
(OUTPUT_NAME torque2d), with the platformAndroid sources, GLES/EGL/OpenSLES/
log/android/z links, and IMPORTED prebuilt freetype(.a)+openal(.so) for
arm64-v8a. Desktop-only bits (gtest, testing/* sources, UNICODE defines,
the Windows .rc) are gated off; every `UNIX AND NOT APPLE` block now also
excludes Android (Android is UNIX).
- cmake/PlatformSources.cmake: explicit TORQUE_PLATFORM_SOURCES_ANDROID.
- engine/lib/CMakeLists.txt: don't build the bundled zlib on Android (lpng
links the NDK system libz instead).
Gradle (engine/compilers/android-studio): AGP 3.5.0 -> 8.6, Gradle 5.4.1 -> 8.7,
jcenter -> google()/mavenCentral(), compileSdk 28 -> 34, add namespace,
externalNativeBuild ndkBuild -> cmake (root CMakeLists), abiFilters arm64-v8a,
ANDROID_STL c++_static + C++17, package libopenal.so via jniLibs, manifest
exported=true. Deleted the stale Android.mk/Application.mk and the committed
.cxx ndk-build cache (now gitignored).
CI: new headless Android job (gradlew assembleDebug on ubuntu with the NDK).
Target is arm64-v8a only (only ABI with prebuilt freetype/openal). macOS/Linux/
iOS/Android remain scaffolded-not-verified; Windows still configures clean (383
TUs). The Android CI job is the verification loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nManagement{})
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The global *.a / *.so gitignore rules were hiding the vendored prebuilt Android libraries, so CI had no libfreetype.a / libopenal.so to link libtorque2d.so against. Add .gitignore exceptions and commit the arm64-v8a binaries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux scaffold was never built; it failed to link on three wrong
assumptions. Fix them in the UNIX (non-Apple) block of the root CMakeLists:
* SDL 1.2 is REQUIRED, not optional. The platformX86UNIX back-end calls
1.2-only APIs (SDL_GetVideoSurface, SDL_WM_*, SDL_*GammaRamp,
SDL_GL_SwapBuffers) and pulls X11_KeyToUnicode out of libSDL. This is
NOT SDL2. Resolve it via find_library/find_path (the latter so the
`#include <SDL/SDL.h>` style headers resolve).
* detectX86CPUInfo comes from platform/platformCPUInfo.asm, which is
32-bit-only NASM (does not assemble for elf64) and is referenced only
when TORQUE_64 is undefined. So define TORQUE_64 on 64-bit (asm unneeded)
and assemble the asm via NASM (elf32) on 32-bit.
* Bitness macros: 64-bit gets TORQUE_64 (__amd64__ is auto); 32-bit gets
`i386` (bare i386 isn't predefined under standard C++ but types.gcc.h's
CPU detection keys off it). Path chosen via CMAKE_SIZEOF_VOID_P.
Verified building, linking, and running both 32- and 64-bit (Ubuntu 22.04 /
WSLg): both launch, init SDL 1.2 + OpenGL, open a window, and run the main
loop.
CI (PR-builds.yml): add libsdl1.2-dev (+ :i386) and nasm; pin both Linux jobs
to ubuntu-22.04. On 24.04, libsdl1.2-dev is the SDL2-based sdl12-compat shim,
which lacks X11_KeyToUnicode and would fail to link; 22.04 still ships genuine
SDL 1.2.15.
Docs: update BUILD-PLATFORM-NOTES.md status + Linux round, and generate-make.sh
dependency/status header.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Android NDK / Linux toolchains define __aarch64__, but types.gcc.h only recognized Apple's __arm64__, so arm64 builds failed CPU/endian detection (Unsupported Target CPU, Endian define not set) and TORQUE_CPU_X64 went undefined (hashTable.h pointer->U32 cast error). Match both spellings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er') The engine uses the C++17-removed 'register' keyword in ~35 files. GCC tolerates it but Clang (Android NDK, macOS/iOS) treats -Wregister as an error. Add -Wno-register for non-MSVC compilers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TORQUE_CPU_X64 means 'any 64-bit CPU' and is now set for arm64 too, so the x86 SSE inline asm in mMathSSE.cc was wrongly compiled on arm64 (invalid 'd' input constraint). Guard on the x86-specific TORQUE_CPU_X86_64 instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
T2DActivity.h includes <android_native_app_glue.h>, which is vendored in engine/source/platformAndroid; add that dir to the Android include path (matches the old Android.mk LOCAL_C_INCLUDES). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unner) The Linux jobs ran `cmake --build --parallel` with no job count. For the Unix Makefiles generator that passes `make -j` with NO limit, which starts every ready translation unit at once — measured at 100+ concurrent g++ processes. On a hosted runner that exhausts RAM and the OOM killer reaps the runner agent, surfacing as "The runner has received a shutdown signal" with no compile error. It reproducibly killed the memory-heavier 64-bit Debug build (-g); the 32-bit Release job happened to stay just under the limit. Pin all three Linux build invocations to `--parallel "$(nproc)"`. Windows (VS), macOS/iOS (Xcode) generators cap at the core count by default, so only the Make-generator Linux jobs were affected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the CMake build produce Apple Silicon binaries for both macOS and
iOS, with both the Unix Makefiles and Xcode generators.
macOS (arm64):
- zlib: define HAVE_UNISTD_H on Unix so zconf.h pulls in <unistd.h>;
modern clang errors on the otherwise-implicit read/write/close/lseek
in gz*.c (the old build's gnu89 C dialect masked this).
- Force-include a Cocoa prefix header (tools/CMake/macOS-Prefix.h) for
the platformOSX .mm back-end, which uses AppKit/Foundation types at
file scope (NSApplicationMain, NSEvent, NSCursor, ...) and relied on
the legacy Xcode prefix header. __OBJC__-guarded, so C/C++ TUs are
unaffected.
- Fix one stray include in osxCocoaUtilities.mm ("fileDialog.h" ->
canonical "platform/nativeDialogs/fileDialog.h").
- Pin CMAKE_OSX_ARCHITECTURES=arm64 and CMAKE_OSX_DEPLOYMENT_TARGET=11.0
(Big Sur floor for arm64; still supports a future Metal renderer).
Both overridable on the command line.
iOS (arm64 simulator):
- Predefine TORQUE_OS_IOS (the engine's OS detection gates its iOS
branch on it but only defines it inside that branch — a chicken-and-egg
that otherwise selects the desktop-GL back-end and fails).
- Force-include a UIKit/Foundation prefix header (tools/CMake/iOS-Prefix.h).
- Define NO_REDEFINE_GL_FUNCS: the debug outline-GL macro
(#define glDrawArrays glDrawArraysProcPtr) leaks into the modern SDK's
gl.h (dragged in via UIKit->CoreImage) and collides the engine's
same-named variable with the SDK's function decl. This is the engine's
own escape hatch; outline/wireframe debug draw becomes a no-op on iOS.
- Add graphics/bitmapPvr.cc (PVR textures, excluded from desktop builds)
and link GameKit for platformiOS/GameCenter.mm. No code-signing for the
simulator build.
Both targets build and link with 0 errors. The desktop binary launches
without crashing; full GUI/GL runtime should be confirmed from an
interactive desktop login (it blocks on a window-server session when run
headless). Docs/status board updated in cmake/BUILD-PLATFORM-NOTES.md,
including a comparison against the legacy Xcode/Xcode_iOS projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zutil.h has an ancient Classic Mac OS branch that does `#define fdopen(fd,mode) NULL` under `MACOS || TARGET_OS_MAC`. But TARGET_OS_MAC is 1 on ALL modern Apple platforms (macOS and iOS), which DO have a real fdopen() — so the macro clobbers the SDK's <stdio.h> fdopen declaration (_stdio.h:318) and fails to compile. This is what broke the macOS and iOS CI jobs (Xcode 16.4, SDK 15.5 / iPhoneOS 18.5): the prior commit's HAVE_UNISTD_H pulls <unistd.h> in early via zconf.h, which defines TARGET_OS_MAC before zutil.h's branch, tripping the stub on the newer SDKs (it was latent on the older local SDK 15.2). Guard the stub with !defined(__APPLE__) so only true Classic Mac OS gets it; modern Apple uses the real fdopen. Verified by reproducing the exact CI commands locally: macOS Debug+Release (Xcode generator) and iOS device Release (CODE_SIGNING_ALLOWED=NO) both build clean (arm64). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same Classic-Mac-OS landmine as the zlib fdopen fix: pngpriv.h includes <fp.h> (Classic Mac OS's floating-point header, which doesn't exist on modern macOS/iOS) whenever TARGET_OS_MAC is defined — and TARGET_OS_MAC is 1 on all modern Apple platforms. This failed the macOS/iOS CI jobs (SDK 15.5 / iPhoneOS 18.5) right after the zlib fix unblocked the build and it reached libpng. Exclude modern Apple (!defined(__APPLE__)) so it uses <math.h>. Verified locally by forcing -DTARGET_OS_MAC=1: the original pngpriv.h fails with "'fp.h' file not found" (the exact CI error) and the patched one compiles clean. The other Apple-compiled vendored libs are clear (ljpeg already handles __APPLE__; libogg/libvorbis branch only on _WIN32). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… returns child dirs
The X11 back-end's Platform::dumpDirectories diverged from the verified Win32
implementation in two ways, both of which made getDirectoryList(path) return
just the path itself instead of its immediate subdirectory names:
1. recurseDumpDirectories was started at currentDepth=0 instead of -1. The
child-recursion guard is `currentDepth < recurseDepth`, so the common
depth==0 call (getDirectoryList) gave `0 < 0` == false and descended into
no children.
2. In noBasePath mode it pushed the base path itself for the empty-subPath
root call, rather than only non-empty subpaths.
Symptom: the in-engine Project Selector calls getDirectoryList(getMainDotCsDir())
to enumerate top-level project folders, so on Linux it found none -- the Toy Box
(and every other project) was missing from the startup list. Affected all Linux
getDirectoryList callers, not just the selector.
Fix matches the Win32 back-end: start the recursion at -1, and in noBasePath
mode store only non-empty subpaths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resizing the window did not update the engine: the SDL_VIDEORESIZE handler only
called Game->refreshWindow() (a repaint at the old size), so the canvas stayed
locked at its original dimensions. Three parts:
* Add SDL_RESIZABLE to the windowed GL flags. Without it SDL 1.2 fixes the
window size and never emits SDL_VIDEORESIZE.
* Handle SDL_VIDEORESIZE by re-establishing the GL surface at the new size.
Unlike the Win32 back-end (where the GL drawable follows the window and
WM_SIZE just calls Platform::setWindowSize), SDL 1.2's drawable does NOT
track the X window, so the extra area rendered as black / drifted off-top.
Re-setting the video mode recreates the surface and updates
Platform::getWindowSize(), which drives the canvas extent and GL viewport.
* Allow arbitrary windowed sizes in OpenGLDevice::setScreenMode: the
resolution-list check now only constrains fullscreen modes (a window-manager
drag is any size).
Re-creating the GL surface forces a texture-manager reload, so it is debounced:
the latest requested size is remembered and the surface is rebuilt only once the
drag settles (~150ms idle), giving one reload per resize gesture instead of one
per frame.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tform notes build-linux.sh is the Linux counterpart to generate-vs2022.bat / generate-vs2026.bat. The Windows scripts open an IDE that builds for you; with no IDE in the loop on Linux this configures AND compiles (bounding --parallel to nproc, matching the CI OOM fix) and leaves a runnable exe at the repo root. generate-make.sh stays the configure-only option. BUILD-PLATFORM-NOTES.md: mark the 64-bit Linux GUI runtime verified under WSLg, document the script, and record the SDL dev-package gotcha (a 32-bit-prepped box has only libsdl1.2-dev:i386, which still provides sdl-config, so the default 64-bit configure fails find_library(SDL12_LIBRARY) until libsdl1.2-dev:amd64 is installed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine writes console.log to the working directory (repo root) on launch via setLogMode. It's a per-run artifact, not source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the macOS generator script up to the robustness of the Windows (generate-vs2022.bat) and Linux (generate-make.sh) helpers, and make "Run" from Xcode actually launch the game: - generate-xcode.command: fall back to /Applications/CMake.app when cmake isn't on PATH (the macOS .dmg installs there but doesn't add it to PATH), check for CMakeLists.txt, keep the window open with a clear message on failure (incl. the full-Xcode-vs-Command-Line-Tools xcode-select hint), and tell the user to pick the Torque2D scheme and Build/Run. - CMakeLists.txt: set XCODE_GENERATE_SCHEME + XCODE_SCHEME_WORKING_DIRECTORY to the repo root for the macOS desktop target, mirroring the existing VS_DEBUGGER_WORKING_DIRECTORY. The exe loads main.cs and the asset trees relative to the cwd, so without this, Run-from-Xcode starts in the wrong directory and the engine can't find main.cs. Verified: running the script with cmake off PATH finds CMake.app, generates build/xcode, the scheme's customWorkingDirectory is the repo root, and the project builds clean (arm64 Debug, 0 errors). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bare (non-bundled) CMake executable showed "running" but never opened a window. main.mm called NSApplicationMain, which installs the app delegate + menu by loading the bundle's MainMenu nib (NSMainNibFile in Info.plist). The CMake build is a plain executable with no bundle/nib, so NSApp got no delegate — applicationDidFinishLaunching: never fired, runTorque2D was never called, and the process sat in an empty run loop with no window (and at 0% CPU, writing no console.log). Bootstrap AppKit by hand when there is no MainNib bundle: become a regular (foreground) GUI app via setActivationPolicy:Regular, install the AppDelegate, activate, and run. The engine already creates its own NSWindow in runTorque2D, so no nib is needed. The legacy .app path is preserved: if Info.plist names an NSMainNibFile, we still call NSApplicationMain. Verified: the bare exe now starts the engine within ~2s — process goes to RN/~20% CPU (was SN/0% idle), OpenGL initializes on the M2 Pro, and the 1024x768 window screen mode is set. Builds clean with both the Makefiles and Xcode generators (arm64, 0 errors). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unch)
GuiListBoxCtrl::compByText/compByID are std::sort predicates but were not
a strict weak ordering:
- compByText used `res <= 0`, so equal strings compared TRUE;
- both reversed the sort by negating the result (`!res`), which makes
equal elements compare TRUE in the descending case.
Both violate the strict-weak-ordering requirement of std::sort. Older
libc++ ran it as undefined behaviour without complaint, but the hardened
std::sort in Xcode 16 / clang 17's libc++ detects it and calls abort() —
so the engine crashed during editor startup (ModuleManager::loadModule
Explicit -> GuiListBoxCtrl::sortByText) as soon as it actually got to run
on macOS.
Fix: compare strictly (`< 0`) and reverse by swapping operands instead of
negating, so equal elements always compare false in both directions.
Verified on macOS (arm64, both generators): the engine now runs past the
sort and loads the editor modules instead of aborting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Platform::getRealMilliseconds() did:
return (U32)([NSDate timeIntervalSinceReferenceDate] * 1000);
timeIntervalSinceReferenceDate is seconds since 2001, so *1000 is ~8e11 ms
in 2026 — far beyond U32_MAX. Converting an out-of-range double to an
unsigned integer is undefined behaviour, and the architectures disagree:
- x86_64 (the old build) truncates/wraps -> a changing value -> the clock
advanced, so it happened to work;
- arm64 (this build) saturates via fcvtzu to 0xFFFFFFFF on EVERY call, so
the time delta was always 0 and the simulation clock never advanced.
Consequence: TimeManager posted elapsedTime=0 every frame, Sim time never
moved, and scheduled events never fired. The engine rendered the editor
background but the Project Manager UI (shown via projectSelector.schedule(
2800,"show")) never appeared, and animations were frozen.
Fix: go through U64 first — a well-defined truncation that wraps mod 2^32
(~49 days, already handled by the engine's unsigned-delta math), matching
how the x86 build behaved. Verified with a standalone arm64 test: the old
cast yields a constant 0xFFFFFFFF (delta 0); the new one advances (delta ~6
over a 5ms sleep). In-engine, the editor now initializes fully (console
trace goes from ~37 lines, idle, to thousands with the loop busy).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running as a bare executable (not a .app bundle), macOS's LaunchServices/
AppleEvent check-in sometimes delivers the launch ("open application")
event more than once. A backtrace at window creation showed the chain:
main -> [NSApplication run] -> _handleAEOpenEvent
-> _sendFinishLaunchingNotification
-> -[AppDelegate applicationDidFinishLaunching:]
-> runTorque2D -> mainInitialize -> ... -> createCanvas
-> Platform::initWindow (a new NSWindow each time)
So applicationDidFinishLaunching: could fire multiple times, and each one
re-ran the entire engine init and spawned another window — intermittently
1 or 3 identical "Torque2D: Rocket Edition" windows depending on how many
launch events arrived (console.log only ever showed one boot because
setLogMode(2) truncates the log each time).
Guard the delegate so the engine boots exactly once per process. Verified
the normal single-boot path is unaffected (one GL init, editor loads).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The macOS target was emitted as com.apple.product-type.tool (a command-line
tool), because MACOSX_BUNDLE wasn't set. When Xcode runs a *tool* that turns
itself into a GUI app (NSApplication / setActivationPolicy:Regular), macOS
relaunches it to grant a GUI session — and relaunching a non-bundle
executable goes through LaunchServices -> Terminal, spawning extra copies of
the app. That was the real cause of the extra "Torque2D: Rocket Edition"
windows (1 from Xcode + 2 relaunched in Terminal = 3), confirmed by process-
tree tracing: the copies were children of Terminal.app/login/zsh and NONE of
the engine's own launch paths (system/NSTask/NSWorkspace) fired.
Set MACOSX_BUNDLE so the macOS desktop target builds a real Torque2D[_DEBUG]
.app with a stable LaunchServices identity (product type ...application) —
launched exactly once, no Terminal relaunch. This is what the legacy Xcode
project did.
Also ad-hoc code sign it ("Sign to Run Locally", CODE_SIGN_IDENTITY="-",
Manual style): a .app must be signed to run on Apple Silicon, but we don't
want to require an Apple developer team for a local build.
No asset repackaging needed: the .app lands at the repo root (the runtime
output dir) next to main.cs, and the engine's getExecutablePath()
(osxFileIO.mm) already searches the bundle's parent directory for main.cs.
NOTE: iOS also outputs Torque2D_DEBUG.app to the repo root but with a FLAT
(non-Contents) layout; building iOS then macOS (or vice versa) into the same
checkout leaves stray files that break codesign ("unsealed contents in the
bundle root"). Delete the stale .app when switching platforms in one tree.
Verified: product type is 'application', the bundle builds clean (arm64,
0 errors) and ad-hoc signs (Signature=adhoc) with both generators. Runtime
(window + boot) must be confirmed from an interactive Xcode session — GUI
apps can't be launched from this headless context (launchd error 153).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion)
FluidColorI::processValue (the per-tick interpolator behind fadeTo/the
editor curtains, splashes and transitions) did:
return start + (U8)mRound((target - start) * progress);
mRound returns F32, and (target - start) is negative whenever a channel
fades toward a SMALLER value (e.g. alpha 255 -> 0). Casting that negative
float directly to U8 is undefined behaviour: arm64's fcvtzu SATURATES it to
0 (the same instruction/issue as the getRealMilliseconds clock bug), so the
delta was always 0 and every fade-OUT froze at its start value. Fade-IN
(positive delta) worked, which is why it was easy to miss.
Concretely: the Project Manager's "torqueCurtain" (a full-screen editorBG
sprite shown on top, then faded to alpha 0 to reveal the UI) never faded,
so the editor booted to a window that only showed the background — the
actual Project Manager was fully rendered underneath the stuck curtain.
Fix: round to a signed int and cast only the final, in-range sum to U8, so
the negative intermediate is never converted to an unsigned type.
Verified on arm64: the old expression yields 255 (stuck) for a 255->0 step;
the fixed one yields 239 (progressing). The S32/F32 processValue overloads
were already safe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update BUILD-PLATFORM-NOTES.md to reflect that macOS (arm64) now builds, code-signs, launches as a single window, boots, and the editor renders + animates — verified from Xcode. The notes still said "GUI runtime unconfirmed". Documents the six runtime bugs found between "it builds" and "the editor works" (AppKit bootstrap, std::sort comparator crash, frozen sim clock, duplicate windows from the tool product type, codesign/bundle, frozen fade-outs), and calls out the recurring arm64 trap (float->unsigned SATURATES on arm64 where x86 wrapped) so the pending iOS runtime work knows to look for it. Also flags the shared Torque2D_DEBUG.app output path between the iOS (flat) and macOS (Contents/) builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…note The 32-bit Make build now builds, links, and runs under WSLg (boots + GL init, toybox/project list and window resize confirmed). It falls back to llvmpipe (software GL) because WSLg's hardware-GL passthrough is 64-bit only. Also corrects an earlier inaccuracy: libsdl1.2-dev:amd64 and :i386 do NOT coexist -- they conflict on shared files, so installing one removes the other. Records the non-destructive 32-bit workaround (point SDL12_LIBRARY at the i386 runtime libSDL-1.2.so.0, since the i386 dev symlink is gone once the amd64 -dev is installed) and the full -m32 configure recipe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The animation editor needs to draw the same thing twice: the palette of every frame an image offers, and the timeline of the frames an animation plays. This is what they will share -- an image asset to draw from, and where the cells go. It has to be C++. Script cannot ask an image where a frame IS: in implicit cell mode getFrameSize is the only per-frame question with an answer, and a grid needs the source rect, which ImageAsset::getImageFrameArea has and no binding exposes. A grid of GuiSpriteCtrls was never an option. The layout is entirely static functions taking everything they use, so the renderer and the hit test call the same function with the same numbers and cannot drift apart -- the discipline GuiEditorExplorerTree's gutter uses, and for the same reason: a disagreement between where a cell is drawn and where it is clicked is experienced as clicking the wrong frame, which is a maddening bug to be told about and an easy one to test away. So there are 22 of them. The ones worth naming, because each is a mistake that would otherwise ship: getColumnsFor asks its question of a width one pad wider than the real one, because n cells span n advances LESS the gap the last one does not need. The naive width/advance loses a column at exactly the width that fits it. It also never answers zero -- a pane dragged narrower than one cell is ordinary, and zero is what the row arithmetic divides by. cellAt returns -1 for the gaps and for the empty tail of a short last row. A click on nothing must not become a click on the nearest something, and on the timeline the gap is where the insertion caret lives -- a different question with a different answer. getContentExtent leaves no trailing gap. It is what the scroller is told, and a pad of overshoot there is a scroll bar for a gap. Each subclass says which axis it grows along by overriding getDesiredExtent, one line each: a palette is as wide as its scroller and as tall as its rows, a timeline the other way about. Inferring it from the column count was tried first and read as a riddle. Named GuiEdit... so both copies of the palette-refusal rule refuse it by prefix and no icon table needs an entry; palette (152 checks) and inspectorSpec (114) confirm it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Two grids over the shared arithmetic, and almost nothing else in common -- which is why they are two classes rather than one with a mode flag. The palette derives its cells from the image and wraps them into rows; the timeline holds an editable list in one scrolling line. One appends on a click, the other selects and scrubs. Only one has a keyboard. A flag would have made every method an if. The palette's whole job is the fork between a click and a drag, five pixels of slop apart, which is the same fork GuiEditorControlTile makes. Both ends report to script rather than acting: what a dropped frame MEANS is the timeline's business. It also has to guard the double-fire -- a release that followed a drag is that drag ending, not a click, or a dragged frame would be both dropped where it was let go and appended to the end. The timeline holds a COPY of the list and never writes the asset, reporting one onFramesChanged per completed gesture. That matters more than the usual separation would suggest: AnimationAsset::setAnimationFrames has no equality guard, so every call rewrites the .animation.taml, and a per-drag-tick write would be hundreds of file writes for one reorder. Two things in it are worth reading twice. Repeats of one frame are drawn joined across the gap, because the asset format has no per-frame duration -- every frame gets AnimationTime divided by the count -- so naming a frame twice is the ONLY way to hold a pose, and a run of duplicates has to read as one held frame rather than as somebody's mistake. And insertionAt counts cell CENTRES, not edges, so the caret flips halfway across a cell where a person expects "before this one" to become "after it"; measuring from the edge makes the caret lag the pointer by half a frame. The drop and the caret call it with the same numbers, so what was shown is what happens. The playhead is read in onPreRender, which recurses from the canvas every frame -- the cheapest correct poll and the documented place to mark yourself dirty. It reads getAnimationFrame, the slot, not getCurrentAnimationFrame, the image frame: one image frame can fill several slots and the marker has to be on the one actually playing. Selection and playhead are drawn differently on purpose, an outline against a bar, because they are usually the same cell and scrubbing sets one by reading the other. Ten more layout tests, 32 in all. palette (152 checks) confirms all three classes are refused from the Gui Editor by their prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
The animation editor needs the preview area divided into the art, the frames available and the timeline, with dividers a user can drag. This puts the frame set that will do it in place and changes nothing else -- the whole point of the commit is that the Asset Manager looks and behaves exactly as it did. That it can be invisible is what makes "build the nest always, split on demand" safe. GuiFrameSetCtrl::resize hands its one frame its own extent with no insets, so unsplit it is a pass-through; and splitting later never reparents anything, because splitFrame only rewrites which frame holds a control and removing one collapses the frame and hoists its twin. The background sprite, the SceneWindow, the scene and the audio overlay all stay exactly where they are for the life of the editor, whatever the stage does around them. previewHost is the layer that looks like a pointless wrapper. GuiWindowCtrl finds its dock target by casting its parent's FIRST child to GuiFrameSetCtrl; today that child is the background sprite, the cast fails, and window docking is quietly off in the Asset Manager. Making the frame set child zero would switch docking on by accident, aimed at the animation split -- so the Asset Inspector would offer to dock into frames the stage deletes out from under it. One plain control in between keeps the answer no. It is commented in place, because the next reader will otherwise remove it. assetLibrary (81), assetImageInspector (97) and assetPicker (63) are green, and the inspector's seven screenshots are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Choosing an animation asset now splits the preview three ways: the art playing on the left, every frame the image offers on the right, and the frames the animation actually plays along the bottom. Choosing anything else puts it back. AssetAnimationStage owns that. It is a ScriptObject rather than a control because what it manages is a shape -- two panes and the frame set they live in -- and because it gives the five asset kinds that have never heard of animation a single line to ignore: retainFor, called once above the selection chain, which keeps the split up for the asset it already shows and takes it down otherwise. Frames get into the timeline by dragging or by clicking, and both end in the same appendFrame. A click is a drop that never moved, and giving it its own path into the list would mean two places to remember to commit. Three things cost real time and are worth writing down. setFrameSize is the only thing that lays a frame set out, and a layout can only size the controls already in their frames. Sizing the frames before adding the panes produced a split with all the right frames and a palette still 100 x 100, parked behind the preview where nothing whatever could be seen of it. Both sizes now come last, after both panes are in. The two panes take fill on the axis their scroller cannot scroll and let the scroller own the other. That is legal exactly because the bar is alwaysOff there, and it is what lets the palette learn its real width -- which it must have before it can work out how many columns fit. It had been using "width", which preserves the gap it was built with, and the gap was wrong. And moving a divider resizes the SceneWindow, whose onExtentChange answers by re-clicking the selected tile -- which lands back in the stage. So building and collapsing are both shut for the duration. Without that, deleting the first pane re-entered select() while built was still true and the second call reached for a pane that was already half gone. Ids are cleared on teardown too, not just deleted: a field still holding a freed id will answer isObject() about whatever took that id next, and the symptom is a call into a live object that has never heard of the method. 30 checks in tests/smoke/assetAnimationTimeline.cs, including that the split collapses to exactly one frame for any other asset and that the preview window and its scene are the same objects throughout. Four screenshots, and the log is clean of script errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Every asset setter ends in refreshAsset, and the Asset Manager's answer to a refreshed asset has always been to re-click the selected tile -- which clears the preview scene and builds a new sprite. For an image or a font that is exactly right: the picture is a pure function of the asset. For an animation being edited it means dragging one frame restarts playback from the beginning, which makes the timeline useless for the thing it exists to do. So AssetBase::onRefresh now asks AssetAdmin::refreshPreview, which gives the animation stage first refusal. The stage takes the refresh only for the asset it is actually showing, keeps the scene and the sprite it has, and re-reads the values that moved. Everything else falls through to the old path untouched. Two engine behaviours make that harder than it sounds, and both are handled by the same short function. The engine has already restarted playback by the time script hears about it. AssetManager::refreshAsset notifies every AssetPtr pointing at the asset before firing the script onRefresh -- and for a sprite that notification IS playAnimation, from slot zero. So the slot is captured before the write rather than read after it, when the sprite has already forgotten. And playAnimation opens by clearing the pause, so a paused preview comes back playing and has to be paused again. The playhead is restored by SLOT, not by image frame. A slot's meaning shifts when something is inserted before it, so the preview can appear to skip a frame -- but tracking the image frame instead breaks the moment a frame appears twice, which is exactly what a hold is. A resize gets the same treatment: a divider moving used to rebuild the whole preview through onExtentChange, so the animation restarted every time the palette was widened. The sprite is already there and only its size is wrong. Also fixes the stage never learning about its sprite on a first selection: the tile displays before it selects, so displayAnimationAsset announced the new sprite while there was still no stage built to hear it. select() now asks for it rather than waiting to be told, which covers both orders. 38 checks, including that the sprite is the same object across an edit, that the playhead stays where it was, and that a change raised from outside the editor still reloads the strip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
The transport sits over the preview as an overlay, where the audio play button already sits and proof that one there receives clicks over the SceneWindow. It costs no layout and takes no room from the art. Five buttons, and three of them have to show a state, which is why the row is assembled from EditorToggleIcons by hand rather than from an EditorButtonBar of momentary buttons. Stop pauses rather than stopping, and that is not a shortcut. SpriteBase's stopAnimation sets the finished flag, updateAnimation returns immediately on it, and setAnimationFrame goes through updateAnimation -- so a preview stopped that way can never be scrubbed again. Pausing halts it just as visibly, leaves the playhead where you stopped to look at it, and keeps every other gesture alive. armPreview is the way back for a preview that finished on its own, and every path that moves the playhead goes through it. The suite asserts that directly: stop the animation the engine's way, then scrub, and the scrub still works. Loop is the asset's AnimationCycle, so it writes the file like any other edit. Keep-frame-rate is not: it decides what the EDITOR does on the user's behalf, so it is a preference and it is remembered. Uniform timing means AnimationTime is shared out over however many frames there are, so adding one makes every frame play faster and the animation stops lasting as long. Which of those a person wants depends on whether they are lengthening a walk cycle or dropping in a hold, so it is a switch, off by default, and both numbers are always on show. The range builder is a plain object with no dialog attached, because it is the part worth checking and the dialog's feedback line is its own answer read back rather than a second description that could drift. Three stages, and the order is the design: the stepped run, then the ping-pong reverse MINUS both shared end frames, and only then the hold. Hold last is what makes "shared" mean one frame rather than N slots -- keep the ends and the turn at each end lasts twice as long as everything else, which reads as a stutter. Eight rows of table assert it. Two script-only stumbles worth recording: getMax and mClampF do not exist in TorqueScript. The first is a link error you see; the second silently returns nothing, which setAnimationTime then wrote as zero -- and a zero animation time divides by zero in the playback integrator. It is spelled out by hand now, with a note that mClamp would round a sub-second animation down to nothing. 61 checks, and six screenshots including the dialog with a ping-pong read back before it is applied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Animation assets got the generic GuiInspector: General, SimBase, Namespace Linking, Dynamic Fields, and somewhere among them the things that actually matter. They now get the same treatment image assets got -- three blocks that reflow, only the fields worth showing, and a line saying what the numbers add up to. The most important thing about the pane is a field that is not in it. AnimationFrames is the timeline, in the editor above; a box of space-separated numbers beside a timeline editing the same list is two sources of truth, and the box is the one that cannot say which frame 67 is. Named frames are absent for a harder reason: the engine's named-frame API does not round-trip through its own file, so an asset in that mode keeps the generic inspector rather than being offered a pane that would quietly lose work. The info line is the part the separate fields cannot say: "25 frames, 2.08 s, 12.0 per second. Frames are 96 x 96, from ToyAssets:TD_Barbarian_CompSprite (1024 x 1024, 100 frames)." Uniform timing means the rate is a consequence of two other fields, so it has to be shown rather than worked out. Four warnings, and the one worth reading twice compares the specified frame list against the validated one. That is the only comparison script can make and it is exactly the right one, because the engine CLAMPS an out-of-range frame to the last one instead of dropping it -- so the animation goes on playing and shows the wrong art with nothing said. It needs the validateFrames fix from earlier in this branch to have anything to compare against. chooseInspector was a boolean with two literal isVisible() tests reading it back from the far side of the file. It is a key registry now: registerPane names a pane, chooseInspector shows one and unbinds the rest, and activePaneObject is the single accessor the other two went through. imageScroller and imagePane stay as named handles onto it -- assetImageInspector names them in eight assertions, and that suite passing unchanged at 97 checks is the proof this refactor preserved behavior. The five copies of the same four addHiddenField lines are now one inspectStock. 48 checks. Two things needed a second look and are worth knowing: addFieldRow takes the label and kind as arguments rather than asking labelFor/kindFor, so rows built without them come out captionless; and makeInfoLabel gives one line of 20 pixels, so a sentence needs textWrap and textExtend or it is not drawn at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Two suites, split by what actually needs a pointer. The drop path goes in the timeline suite, driven by calling the callbacks with a payload parked at real coordinates. That is not a shortcut around the interesting part, it IS the interesting part: GuiDragAndDropCtrl hit-tests from its own parent and findHitControl answers "me" without ever testing its bounds, so a drop anywhere on the screen arrives at the timeline and the boundary check is the timeline's own to make. The suite drops a frame over the asset library and asserts nothing changed, then drops one on the left half of a slot and asserts it went in before that slot -- the caret's promise, kept. The palette-to-timeline DRAG is deliberately not tested with posted input, and the reason is written down rather than left as a gap: a GuiDragAndDropCtrl gesture follows the real cursor, which a posted WM_MOUSEMOVE does not move. What would be proved is the engine's capture, not this feature's code -- and this feature's share of it, the boundary policing and reading the cursor back off the payload, is script and is covered above. What does need real clicks is the touch path in the two grids, which is new code: the press, the five pixels of slop that decide click from drag, the capture taken and given back, and the suppression that stops a released drag also counting as a click. So assetAnimationClick posts two, and like explorerGutter it is handed the points rather than knowing them -- where a cell lands depends on how many columns the palette wrapped into and how far its scroller sits, and a hard-coded point that drifted off the cell would report a control that never fired, which is exactly what a broken hit test reports. All 51 smoke suites and 240 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Feedback from using it, and one of them turned out to be a real bug rather than a rough edge. The palette had no scrollbar, so the only way to reach a frame near the bottom was to shrink the cells until they all fitted. Two causes. The wheel handler zoomed instead of scrolling, and by taking the event it stopped the wheel ever reaching the scroller -- a wheel over a scrolling list of pictures means scroll, so that handler is gone and cell size stays a field a pane sets. And the vertical bar was dynamic, which has to be decided from the strip's height during the very layout pass in which the strip is working that height out; a sheet worth opening the palette for has more frames than fit, so it is simply always on. The timeline drew a yellow bar and a blue box out of hard-coded colors, which ignored the theme. Every color now comes off the profile: HighlightState fill for hover, SelectedState for the picked cell and for the run joining a held frame, DisabledState for a cell that dragging further would discard, and the selected and highlight FONT colors for the playhead and the caret -- inks rather than fills, so they stay legible on the cell they sit on. Backgrounds moved to behind the art rather than over it, which they had to: a theme's fills are opaque, and a hover painted on top hid the frame the pointer was hovering over. Play and Stop are two buttons with one hidden rather than one toggle. A toggle says "this setting is on"; these say "here is what pressing me will do", which is what a transport means -- and it is why the button could not get stuck. Play is half again the size of the rest, and the order now reads rewind, play, gap, then the three that are settings. The toggles drew their icons at 16 against the push buttons' 20, so EditorToggleIcon takes an iconSize now, defaulted to what every existing caller already gets. The Stop button stayed showing after anything other than the Stop button halted the preview -- clicking a slot to scrub, dragging a frame off the timeline, or a one-shot animation reaching its end. Every one of those goes through the stage, so the stage tells the bar. And the two captions used labelProfile, which is meant for text on the window background: near-black on dark blue under Lab Coat. panelProfile is what the Asset Inspector's own title bar wears and its font is the theme's color5. The bug the tests caught while fixing the rest: with keep-frame-rate on, a commit writes the asset TWICE, and the remembered playhead was being cleared between the two. The second refresh then fell back to the strip's cached marker -- a value onPreRender updates once a frame, so mid-script it is whatever the last drawn frame said -- and scrubbed to it, undoing the restore the first refresh had just made. The playhead went back to zero after every edit. One slot now covers both writes, and the fallback is gone: a refresh this editor did not cause has nothing to restore and should leave the preview alone. 73 checks in the timeline suite now. All 51 smoke suites and 240 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
…sizes Both widgets hard-coded their numbers, and a caller wanting a bigger button had no way to ask. Setting the sizes afterwards did not work either: EditorIconButton forces its own extent in onAdd, and its hover handlers animate the icon to numbers of their own, so anything a caller set survived exactly until the pointer first crossed it. They take buttonSize and iconSize now, defaulted to what every existing caller already gets. What took three attempts to get right is what those two names mean. GuiSpriteCtrl::growTo animates mImageSize -- the PICTURE -- and leaves the sprite control alone. So there are three numbers, not two: the button, the sprite holding the picture, and the picture itself. Conflating the last two is what made a 36 pixel button animate its icon from 32 down to 28 on first hover, which looked like the icon exploding and never recovering. iconSize is therefore the picture. The sprite holding it is deliberately larger, because a sprite clamps its picture to its own content rect -- a holder the same size as the artwork loses a pixel or two of it to the profile's insets, and the symptom is subtle and awful: the icon comes up small, the hover appears to grow it, and it stays grown. The slack is what stops that, and there is a comment telling the next person not to tidy it away. The defaults reproduce the original numbers exactly: a 24 button, a 20 sprite, a 16 picture that goes to 18 under the pointer. EditorToggleIcon gets the same arrangement and the same meaning for iconSize -- the two are frequently sat next to each other and had no reason to disagree about what a size is. headerPane, toggleTip, profileForm, menuBar and the rest of the suites that drive these two are unchanged and green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Four things wrong with the animation editor, and the first was the one that mattered. A frame dragged out of the palette always showed frame 0 under the cursor, and only became the right frame once it was dropped. GuiSpriteCtrl::setImage returns early when the control is not awake -- it keeps the asset id and throws the frame away -- and a drag payload is built detached, so the frame never landed. onWake then re-applies the image from the Frame FIELD, which nothing had set. Setting the fields is the fix; setImageFrame after the payload is on the canvas makes it right whichever order the waking happens in. The test reports 0 without it. The timeline was drawing hard-coded colors, so it ignored the theme. Borrowing listBoxProfile did not work either, because three of its fields mean something else there: a list row's hover is deliberately a whisper, which over a picture is no change at all, and its selected FONT color is the ink drawn ON a selected row, so the playhead bar was dark-on-dark and could not be seen. So the grids get frameGridProfile, which exists for this and says in BaseTheme what each of its six colors is for. The accent goes to the playhead -- the one thing that has to be findable while the animation runs -- and the selection is a quieter raised surface, so the two stay legible when they land on the same cell, which while scrubbing is most of the time. Backgrounds moved behind the art rather than over it, because a theme's fills are opaque and a hover painted on top hid the frame the pointer was hovering over. The transport bar was clipping its big play button, and the cause is a one-line-of-difference bug worth knowing about: a GuiChainCtrl is born VERTICAL, and its resize refuses to change whichever axis is currently the length. Extent was being set before IsVertical, so the height was rejected, the bar stayed at the constructor's mEditOpenSpace of 30, and a 36 pixel button was centred in it -- three pixels off each end. IsVertical now comes first. A chain never grows to fit a taller child, so the height is stated and commented as such. The toggles looked smaller than the push buttons at the same extent, because they are: a GuiButtonCtrl paints across its whole rect and a GuiCheckBoxCtrl paints a box that onRender clamps into the CONTENT rect, inside the borders. With a 2 pixel border all round that is 24 against 20, and no boxExtent can fix it -- the clamp will not let the box out. The toggle is built that much bigger instead, and the amount is read from the profile rather than written as 4. And choosing an animation arrived showing Play over a preview that was already running. A sprite built with an Animation on it does not wait to be started, so the playing state is read off the sprite now instead of being assumed false. The palette also lost its wheel-zoom. It consumed the event, so the wheel never reached the scroller and shrinking the cells until they all fitted was the only way to reach the frames at the bottom. 85 checks in the timeline suite. All 51 smoke suites and 240 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Editing an asset wrote its file. Every setter ended in refreshAsset, and
refreshAsset ended in Taml::write -- so dragging a particle graph key was
indistinguishable from deciding to keep it, and there was no way back. It was
also wrong outside the editor: a running game rewrote its own content as a side
effect of a setter.
refreshAsset now marks the asset unsaved and announces the change. saveAsset
writes, and nothing else does. The private-asset branch was already exactly that
shape, so the two paths are one.
Around that: Save, Revert, Duplicate and Undo/Redo on the inspector's title bar,
a badge on unsaved library tiles, and one Save All / Discard All / Cancel prompt
in front of Close Project and Exit. Any number of assets may be unsaved at once;
switching between them deliberately asks nothing.
The dirty flag and the snapshots are in C++ because two of the editors never
reach TorqueScript -- GuiParticleGraphInspector does every key drag itself, and
the stock GuiInspector writes emitter fields straight onto the object. A recorder
built out of script writes would have been blind to the particle editor, which is
the thing that most needed undo. Script keeps only the policy: what counts as one
step and what it is called.
Undo is whole-asset snapshots of unowned clones. Unowned is the point: with no
owning manager every setter is inert, so taking one marks nothing, notifies
nobody and loads no bitmap. A restore copies onto the LIVE object, never
replaces it -- every AssetPtr holds a raw pointer that a swap would null.
That rested on copyTo, which was broken in four places, all of them live bugs
today via clone() and acquireAsset(id, true):
ImageAsset copied cell COUNT into cell OFFSET, never copied image
layers, and dropped explicit cells unless ExplicitMode
happened to be on
AnimationAsset chose between numbered and named frames by reading the
TARGET's mode, still the default at that point
ParticleAssetEmitter same shape, so an animated emitter copied as a blank
static one -- and ParticleAsset inherited it
Fixed by not listing fields at all: AssetBase::copyTo walks the field table via
copyFieldsFrom, and each type overrides copyAssetStateTo only for what no field
describes. assetStateCopyTests enumerates the field table rather than a list of
its own, so a field added later is covered the day it lands.
Also fixed on the way through, each found by the work above:
- a named-cells animation did not survive its own file. The vector type's
getter joins with commas; the setter split on whitespace alone.
- setAnimationFrames had no "ignore no change" guard, so writing the same list
back counted as an edit and left an undo step that put nothing back.
- a dropped frame committed twice: insertFrameAtPoint announces itself and the
handler announced it again. Two presses of undo to remove one frame.
- refreshAsset's onRefresh callback never reached dependents, contradicting
what the editor assumed. It fires from the manager now, with a flag saying
whether the asset was changed or merely reads from something that was.
- the dependency and loose-file graphs were rebuilt by re-parsing the file that
had just been written, so with no write they went stale. Rebuilt in memory.
- unloadAsset would delete an asset holding unsaved work.
- preloadAsset marked every preloaded asset unsaved at startup.
- findAssetPrivate's five-argument binding called findAssetInternal.
- the Frame Range dialog's Mode list was filled with GuiControl::add, so it was
empty, read "none", and Replace could not be picked.
- three dialogs placed their buttons using the window's height rather than the
content's, which is 34 less; the buttons sat below the fold.
Asset ids are resolved to modules by path, never through
AssetDefinition::mpModuleDefinition -- the editor calls clearDatabase() when it
picks up a project, which frees every ModuleDefinition and leaves that pointer
dangling.
252 unit tests and 52 smoke suites pass. assetDirtySave is the new one, and most
of what it asserts is about the FILE rather than the object, because not touching
it is the whole point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
File, Edit, Layout and Select were written into the shared bar in EditorCore and
switched on when the Gui Editor opened. Every command in them named GuiEditor.
The Asset Manager grew the same class of features last commit -- save, revert,
duplicate, undo, redo -- and could not join them: setMenuActive matches by item
TEXT across the whole tree, so a second editor toggling Undo or Delete would
flip the first editor's items too.
So each editor owns its menus now and lends them to the bar for as long as it is
the one open. The bar always reads Torque2D | the open editor's menus | Theme.
Nothing became generic: the Gui Editor's item still says "Save Gui..." and runs
GuiEditor.SaveGui(), the Asset Manager's says "Save Asset" and runs
AssetAdmin.inspector.SaveAsset(), and Ctrl+S means both. The Console and the
Project Manager show the two permanent ends and needed no code at all -- the
outgoing editor's close() clears the bar, where before they sat behind four
greyed-out menus that would never do anything.
The Asset Manager's File carries New Asset (five kinds), Save Asset, Save All
Assets and Revert Asset; its Edit carries Undo, Redo, Duplicate and Delete, with
Undo and Redo naming the step the way the document bar's tooltips already did.
Revert, Delete and the five New items have no accelerator, for the reason the Gui
Editor's Revert has none.
Four engine facts shaped the mechanism, and they are worth knowing before
touching a menu again:
build into the bar GuiMenuItemCtrl learns which bar it belongs to when it is
added to one, and a submenu learns it from its parent when
IT is added; nothing back-fills it. A tree built standalone
and handed over whole leaves every descendant with no bar,
and openMenu dereferences it. The old literal only worked
because the VM adds a parent to its group before compiling
its sub-objects. EditorMenuSet::addMenu returns an
already-attached empty menu, so the rule is the shape of
the code rather than something to remember.
append only onChildAdded links the sibling chain the keyboard walk
follows by taking end()-2, and childrenReordered rebuilds
the layout but not the chain. So the fixed Theme tail comes
off and goes back on around every swap.
move, never remove SimSet::remove leaves a control registered with no group at
all. Each set parks its menus in a SimGroup of its own.
rebuild accelerators The canvas keeps one flat global list, rebuilt only when a
dialog is pushed or popped. A tab change is neither.
That last one was a bug already, not a new hazard. GuiMenuItemCtrl::onAction
checks its own active flag and never its parent menu's, and buildAcceleratorMap
filters nothing -- so with File greyed out, Ctrl+N still ran GuiEditor.NewGui()
from inside the Asset Manager. Physically removing the items is what fixes it,
plus a GuiCanvas::rebuildAcceleratorMap() for the moment nothing else notices.
setContentControl keeps its own walk deliberately: it descends until it reaches a
control that takes input, and sharing that with the dialog paths would leave the
editor's shortcuts live underneath an open dropdown.
setMenuActive is now used nowhere in editor/. It was a text-compare walk of the
whole tree PLUS a full profile re-apply per top-level menu, fired 22 times per
selection change; greying is item.setActive() on a held handle. That deleted the
menuUndo/menuRedo/menuPaste caches and both forceRefreshMenu twins, which existed
only to dodge that cost. Items answering one shared question -- thirteen on "is
anything selected" -- are groups instead, so toggleMenuItems is four calls.
Two bugs found on the way, both caught by tests:
- a group's count starts unset, and using "" as an index writes to a slot
nothing reads back. The FIRST item of every group silently stopped greying:
Cut, Align Top, Space Vertically, Bring to Front. The clipboard suite caught
it before the code ran anywhere else.
- AssetInspector::documentAsset deduced the asset from the bound pane, and
every load method binds its pane AFTER calling beginDocument -- so the
refresh that follows a load had nothing to ask. It also never cleared, so
with nothing selected it answered with the asset before last. Neither showed
while only the document bar asked, because the bar is hidden in exactly those
moments; the menus never are. It returns what beginDocument was handed now,
which settles the particle case directly (an emitter has no file; its owner
does) and let deleteAsset drop its own copy of the dropdown logic.
tests/smoke/menuSwap.cs covers the bar's order, the two same-named File menus
told apart by object rather than by text, parked menus staying alive in their own
editor's group, the Theme radio group surviving the round trip, and the group
registry. Its input script presses Ctrl+N in both editors: once where it must
reach nothing and once where it must make a new document, because a shortcut that
reaches nothing and a shortcut that was never pressed look identical from inside.
Remove the rebuildAcceleratorMap call and it fails with the sentinel wiped.
53 smoke suites and 252 unit tests green; tests/shots/menuSwap.cs is the visual.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Font assets get an inspector pane of their own in place of the stock GuiInspector: three reflowing blocks, and a line saying what came out of the .fnt -- the native size, the glyph count, and the pages at the size they actually loaded at. That readout is the point. A FontAsset registers exactly one field of its own, so a pane that merely rearranged fields would not have earned its place. What it adds is the two ways a bitmap font goes wrong silently: a .fnt that did not parse, and a page image named inside the .fnt that is missing. Both were a line in the console log and nothing on screen. None of it could be put on screen until BitmapFont could be trusted: - the constructor had an empty body, so mSize, mLineHeight, mBaseline, mWidth, mHeight and mPages were whatever was on the heap. The last two are the divisors ProcessCharacter uses to turn glyph rects into texture coordinates, so this was worse than cosmetic. - buildFontData returned on a failed open without clearing anything, so an asset pointed at a missing file kept the glyphs of the font it used to have, and "did not load" was undetectable from outside. - nothing ever cleared mChar or mKerning -- only the page list and the textures -- so pointing an asset at a SECOND .fnt left the union of both fonts and a glyph count that only ever grew. Re-pointing that file is exactly what this pane makes easy for the first time. getRelativeFontFile mirrors ImageAsset's: the field holds the expanded absolute path, which is neither readable nor portable in a text box. The read-only queries behind the info line are new bindings. BitmapFont needed one accessor for the glyph count and deliberately none for mWidth/mHeight, which are what the .fnt declares rather than what loaded. Two things the shared pane grew here and the sound pane will use next: tipFor, because a field's registered doc string is empty on nearly everything, and fileFilters/fileTitle on EditorFieldRow, because a "file" row had been a bitmap everywhere and an image filter offers a font asset nothing it can choose. bitmapFontParseTests drives the parser directly. It touches only the console, the string table and the stream, so unlike anything reaching buildFontData -- which loads page textures through TextureManager -- it runs without a GL context. assetAnimationInspector was asserting the pane registry's exact contents, and was checking the read-only name row with row[...].isEnabled(). There is no such binding anywhere, so that logged "Unknown command" and had been passing vacuously; it asks the box now, as the image suite already did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Audio assets get an inspector pane of their own in place of the stock GuiInspector: three reflowing blocks, with the length and format of the file sitting under the file itself, where there was room for it. Half of what this pane adds is the tooltips. An AudioAsset registers six fields and gives every one of them an empty doc string, so the generic inspector offered six labels and explained none of them -- least of all VolumeChannel, whose numbering is a per-game convention the engine attaches no meaning to. AssetAutoUnload is left out, and not because it is uninteresting. AudioAsset::initializeAsset calls setAssetAutoUnload(false) unconditionally, so every audio asset reads false whatever its file says and a tick would silently come back off on the next load. A checkbox that cannot be changed is worse than no checkbox: it invites the attempt. The preview no longer goes through alxPlay, which is the fix for a real complaint: turn a game's music channel down to nothing and every music asset in the library became unplayable in the editor. Not quiet -- unplayable. alxCreateSource refuses to build a source at all on a muted channel, so there was no handle, and nothing to distinguish that from a broken file. alxPlayPreview keeps the asset's file, looping and streaming flags but forces full volume on a reserved channel and writes AL_GAIN directly to get past the master volume. It never touches the game's own channels, so auditioning an effect cannot blast the music playing behind it. That write survives because a 2D source is AL_SOURCE_RELATIVE, which is exactly what alxUpdateMaxDistance skips each frame; only alxUpdateTypeGain would recompute it, and that runs when somebody moves the mixer, which while a preview is playing is a thing they meant to do. The game still owns the audio driver, and nothing here changes that -- a shipped game must not depend on anything under editor/. But the Asset Manager can be opened before a project is picked, or against one with no audio module, and there the driver simply is not running. AssetAdmin::ensureAudioDriver covers that case and no other. It asks OpenALIsInitialized first, which is new and is not a nicety: OpenALInit BEGINS by calling OpenALShutdown, so starting a driver that is already up drops every playing source and resets the channel volumes, silently undoing the project's own SetMusicVolume. Two engine bugs found on the way: - setVolume and setVolumeChannel compared the value they were handed against the one they held and clamped only afterwards, so handing either an out-of-range number read as a change every single time -- calling refreshAsset and marking the asset unsaved for an edit that moved nothing. - alxGetAudioLength acquired the asset and released it on none of its three return paths, so every call raised the reference count for good. The pane clamps in writeField as well, which is how it avoids the first of those rather than relying on it. Note mClamp, not mClampF -- the latter is the C++ name and is not bound to script at all, so it returns an empty string and quietly writes a zero. Nothing warns about the channel being muted, deliberately. mAudioChannelVolumes is a static global filled in only by OpenALInit, so before the driver starts every channel reads zero and such a warning cannot tell "somebody muted this" from "nothing has made a sound yet" -- and with the preview no longer caring about the game's mix, it would be warning about something that no longer affects what the user is looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Particle assets get inspector panes of their own in place of the stock
GuiInspector, and were the last editable asset kind still on it. Two panes,
swapped by the emitter dropdown that already sat in the title bar: a small one
for the effect, and a five-block one for an emitter.
The emitter pane is the point. A ParticleAssetEmitter registers about thirty
persistent fields and a large fraction of them are inert depending on four or
five of the others -- a single-particle emitter ignores ten, a POINT emitter
ignores its size and its angle, a fixed-aspect one ignores every Size-Y curve.
The generic inspector listed all thirty flat and alphabetically, so the one
thing it could not tell you was which knobs were live.
Two ways of saying "this does not apply", and the difference is deliberate.
Alternatives SWAP: an emitter draws an image or an animation and never both,
and an orientation has exactly one offset, so showing the other arm invites
filling in both when one would silently win. A field that is real, holds a
value, and is merely unread by this mode is GREYED, with the reason as its
tooltip -- hiding those would lose the value from sight and make the pane jump
about as you tried modes.
The blocks are five rows each, and that is a constraint on the grouping rather
than an outcome of it. A GuiGridCtrl row is as tall as its tallest cell, so
unequal blocks do not give a short column and a long one -- they give columns
of the same height with the short ones mostly empty, which is what makes a wide
layout read as unplanned. The first cut was 7/4/2/6/6. Three fields moved to
fix it and each reads better where it landed: aiming joined orientation (both
answer "which way", where emission answers "where"), PivotPoint joined it too
(it is the point a particle is rotated about), and AlphaTest went to Particle
Image (it is a threshold on that image's own alpha). The emitter's name is a
header above the grid, not a sixth block -- the same failure in miniature.
SELECTING A PARTICLE ASSET CRASHED THE EDITOR BEFORE ANY OF THIS, on unmodified
HEAD. beginDocument snapshots for undo, copyFieldsFrom walks the whole field
table, and every numeric-frame emitter's empty NamedFrame therefore reached
ImageAsset::containsNamedRegion -> dStrcmp(mRegionName, ""). PixelArea's default
constructor was empty and its four-argument setArea never set mRegionName, so
every frame of every ordinary cell-mode image carried an indeterminate pointer.
Initialised now; containsNamedRegion refuses an empty name, which it must
independently, or an empty name would MATCH an unnamed frame and flip an emitter
into named-frame mode; and setNamedImageFrame refuses one too.
Five more engine defects, all confirmed by reading the render path:
- the emitter's BlendMode, SrcBlendFactor and DstBlendFactor round-tripped
through TAML and were read by nothing. sceneRender used mBlendMode and the
two factors, which ParticlePlayer does not declare -- they resolved to the
inherited SceneObject members, so one setting on the player covered every
emitter and the per-emitter fields did nothing. IntenseParticles still
overrides, as it always did.
- quantityVaritationField was initialised from getQuantityBaseField, so the
QuantityVariation graph did nothing and every emitter got a spurious
half-base jitter instead. This one is content-visible: bonfire emitted 5 to
15 per interval and now emits ten.
- the console setEmitterAngle stored mDegToRad(angle) and its getter handed
back mRadToDeg(stored), so the two agreed with each other and with nothing
else. The persist field writes what it is given and configureParticle does
mDegToRad(getEmitterAngle()), so degrees is what the file and the renderer
both mean.
- AlphaChannelScale was sampled at time zero, which reads its first key and
discards the curve the Scale Graph tab exists to draw.
- setTargetPosition has no refreshAsset and DELIBERATELY so -- AngleToy steers
an emitter at the cursor with it on every mouse move, and a refresh there
rebuilds every emitter node per frame. Commented in place so nobody corrects
it; the pane asks for the refresh itself.
getFieldValue was the expensive one. ParticleAsset and ParticleAssetEmitter
each declared getFieldValue(time), the graph sampler, which SHADOWED
SimObject::getFieldValue(fieldName) -- the call the whole editor reads rows
with. Asking an emitter for EmitterName sampled whichever curve was selected at
dAtof("EmitterName") == 0 seconds and returned a plausible 1.0. Every row on
both panes showed "1" while a hundred and forty assertions passed, because they
checked the asset and writes were never shadowed. Renamed to
getFieldValueAtTime; no script called it, the graph editor being C++. The suite
now asserts what a row SHOWS, not only what the object holds.
The preview grew a transport: play/pause, stop, restart, a cycling speed, and
per-emitter solo and switch-off. Its chrome came out into EditorTransportBar in
EditorCore, shared with the animation bar. Solo and switch-off are player state
and never touch the asset -- but every edit rebuilds the preview, so the bar
re-applies them keyed on asset id and resets only when the asset really changes.
Stop is the immediate form: the graceful one leaves mPlaying set until the last
particle dies AND pauses every emitter, which is the flag solo writes in.
isStaticMode is a new binding because the mode cannot be inferred from the
assets: an emitter switched to animation before an animation is chosen is
animated holding nothing, which is indistinguishable from static holding
nothing. EditorFieldRow gained a per-row assetType, having hardcoded ImageAsset
since the animation pane; the emitter has an image row and an animation row side
by side. The emitter button bar was rerouted off inspector.getInspectObject,
which stops answering the moment index 0 leaves the stock inspector, and its
remove-last path no longer asks for getEmitter(-1).
assetParticleInspector drives both panes, the gating, the chrome and the
transport, and counts visible rows per block so the balance cannot rot. Its
screenshot harness is what caught the shadowed reads. Four cases went into
assetStateCopyTests for the parts reachable without a canvas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Choosing an animation asset while any other kind was selected left the preview showing the previous asset. The split, the timeline and the transport bar all came up correctly around it; only the picture was of the wrong thing. AssetWindow::onExtentChange answers a resize by re-clicking the selected tile, which is how the preview refits when a divider moves. But a tile does not record itself as the selected one until the LAST line of its onClick, and choosing an animation is the one selection that resizes the preview from inside that call: AssetAnimationStage::build moves two dividers to make room for the palette and the timeline. So the sequence was -- display the animation, start building the split, get resized, re-click the tile that is still recorded as selected, which is the PREVIOUS one, and have it clear the scene and repaint its own asset over the animation sprite made a moment earlier. select() then resumed and adopted a sprite id that no longer pointed at anything, and stopped quietly. Which is why the two cases that worked did: an animation chosen as the first tile of a session has no previous tile to re-click, and animation to animation never builds a split because one is already up. The window now asks the stage for first refusal on a resize rather than calling resizePreview itself. While the stage is busy -- putting a split up or taking one down -- the resizes are its own doing and it says so, and the selection that started the rebuild paints the preview itself either side of them. That uncovered a second bug the first had been hiding. The sprite is measured against the whole preview area, because it is made before the split exists, and nothing put it right afterwards: it came out at 38.4 units where 26.7 fits. The re-click had been rebuilding it at the correct size by accident. select() now resizes it once, at the end, with the resizePreview a divider drag already uses. tests/smoke/assetPreviewSwitch.cs walks image -> animation -> font -> animation -> the same animation again and checks what the preview scene actually holds at each step. On the old code five of its twenty checks fail, and the report line of the first reads "100 object(s): Sprite(ToyAssets:TD_Barbarian_CompSprite)..." where one animation sprite belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
An emitter's color is four curves -- RedChannel, GreenChannel, BlueChannel and AlphaChannel, each 0 to 1 over a normalized lifetime -- and the Emitter Graph tab offered them as four list entries opening four separate graphs. Every color was already reachable that way. What was not reachable was the question anyone actually opens them to ask: not "what does red do" but "what does this LOOK like over its life". Nobody answers that from three pictures and a mental model of additive mixing. Red, green and blue are now one "Color Channel" entry: the three curves layered on a single plot, with a strip under it showing the color they mix to across the particle's life. Alpha keeps its own entry and its own ordinary graph -- it is not a hue, and folding it into the strip would darken every reading of one. THE LIVE CHANNEL IS THE PARENT'S TARGET FIELD, and that one decision is why this is small. Three toggles down the left pick which channel a click edits; setting one calls setDisplayField, so the hit test, add, delete, drag, the refreshAsset on release -- and therefore snapshot undo -- are all GuiParticleGraphInspector's existing code, unchanged and unduplicated. GuiEditParticleColorGraph adds two things and only two: the other two channels drawn dim and read-only, and the strip. The other two are drawn at the same hue and a lower alpha rather than a darker shade, so they read as sitting behind the live curve rather than as three more colors; dglDrawLine blends, so the grid still shows through them. The strip is exact rather than sampled. Between two consecutive members of the union of the three channels' key times every channel is linear, so one interpolated quad per span is not an approximation of the gradient, it is the gradient. dglDrawBlendRangeBox looks like the tool for that and is not -- its stops are spaced EVENLY across the rect, so it cannot put a stop at 0.13 -- so this is one dglDrawBlendBox per span, with the pixel floored rather than rounded because rounding a pair of adjacent spans independently overlaps them by a pixel and a pixel of overlap between two opaque quads is a visible seam. It samples the key arrays and NOT ParticleAssetField::getFieldValue, which applies a RepeatTime warp and a ValueScale that the plotted curve ignores, and which reads key zero before checking there is one. Where those differ the strip has to agree with the picture directly above it rather than with the runtime, because that is what an editor is for. Neither honors them; said so in the class comment rather than leaving it to be discovered. The parent became a template method to make room: getUnderPlotBandHeight asks for a band between the plot and the x axis labels, renderUnderlay draws behind the curve, renderUnderPlot draws into the band. The layout arithmetic came out into two statics, and getUnderPlotReserve(0) == 0 is the invariant that keeps every existing graph pixel-identical -- it is the first thing the unit tests assert. Two orderings in there are load-bearing and commented as such: the band is reserved BEFORE the rect is snapped to the grid and placed against the snapped rect afterwards, because the snap moves the plot by up to nine pixels; and the offset-changed dirty check moved above the underlay hook, because renderPoints clears mDirty halfway through the frame and the strip is drawn after that. Anything the subclass cached on mDirty alone would have shown the previous frame's color on every frame the user was dragging. The toggles are EditorToggleIcon, whose refresh gained a getIconTint hook. The stock toggle tints with the editor's own inks, bright for on and dim for off, which is right for a switch -- but these three stand for red, green and blue, so the color IS the label and no theme can restyle it without lying. Fixed hues lifted off the primaries, matched to the curve each one controls. That hook is also why this needed no new theme profiles at all. ZOOM WAS DEAD ON EVERY 0-1 FIELD, and had been. ParticleGraphCameraController builds its levels by asking whether max > 1, > 10, > 100, so a field whose max is exactly 1.0 got one level and both zoom buttons answered "no" -- on all four color channels, on both axes. Unit-range fields now get four window widths (1 / 0.5 / 0.25 / 0.1), the last of which is the whole field, which is what makes zooming out unable to go past 0-1. Alpha had the same dead buttons for the same reason and gets the same fix; two 0-1 graphs sitting beside each other should not behave differently. Then the axis labels ate the graph. setDisplayArea kept the caller's string as the label, script hands it a float, and a script float is an F32 printed with "%.9g" -- so a tenth arrived as the eleven-character "0.100000001". The y labels are the entire reason the plot gives up a left margin, so the tightest zoom was spending a third of its width on rounding error. The label is now printed from the value it parsed; the window keeps the value, so nothing the camera computes has to agree with what is drawn to the pixel. Six engine defects fixed on the way through, all reachable before any of this: - getTargetField walked off an emitterless asset. mEmitterIndex = getEmitterCount() - 1 on an unsigned zero is 0xFFFFFFFF, getEmitter warns and returns NULL, and the dereference came BEFORE the AssertFatal meant to catch it -- which compiles out of shipping entirely. Routine here rather than exotic: the color channels exist only on emitters. Now findField, which returns NULL and warns, with guards at all five call sites and the empty-list guard in renderPoints that has to land with them, since the tail there indexes count - 1 unsigned. - the key repair deleted the key it had just inserted, given a first key at a negative time: addDataKey inserts in time order and refuses nothing below mMaxTime, so the new key at zero landed at index 1 and removeDataKey(1) took it straight back out. - the same loop skipped a key after every removal, continuing without stepping i back. Two adjacent bad keys left one behind for a frame. - setDisplayField(name, index) reset the selected point when the field name changed but not when the EMITTER did, so the same channel on a different emitter kept an index into the old key list. Exactly the color graph's normal usage. - dglDrawBlendBox's mobile and web path left GL_COLOR_ARRAY enabled pointing at squareColors, a stack local, so every later vertex-array draw read a dead frame for its colors. Its neighbours in the same file disable it. - initEmitter read %itemWidth, a local of init(), so variGraph and lifeGraph were built with a malformed Extent and positioned all sixteen of their buttons against it. The grid resizing cells afterwards is what had been hiding it. mGridRect and mCalculationOffset were uninitialized and onTouchDown reads the first; findHitGraphPoint returned -1 from a U32 and worked only because the wrap round-tripped through an S32. Both corrected in passing. guiParticleColorGraphTests covers what has no canvas: the reserve invariant, the grid snap including a negative extent that used to become an unsigned four billion, channel sampling, the three-way merge (cross-channel de-duplication, a window that clips, a stop budget that runs out and must still reach the far edge), and the joint property that a strictly increasing stop list produces pixels that never decrease at any rect width -- a negative-width RectI reaches dglDrawBlendBox as a reversed quad. tests/smoke/particleColorGraph.cs drives the collapse, the radio, the mix and the zoom; the shot harness carries the part only a picture settles, including the three hues on all four editor themes, since they are the one thing here a theme cannot restyle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
An image asset in explicit mode cuts its sheet into cells that each carry a
RegionName, and an animation on it can list those names -- block1 block2 block3
block4 -- instead of listing 0 1 2 3. The point is that a named list survives the
sheet being re-cut or re-ordered, which a numbered one does not.
The engine has played these correctly for as long as they have existed.
ImageFrameProviderCore branches on the mode in all four places that matter, and
the runtime path from validate through play and update to the frame area is
mode-aware throughout. Only AUTHORING was broken, and by one character:
AnimationAsset_ScriptBinding.h formatted a StringTableEntry -- a const char* --
through "%d", so getNamedAnimationFrames returned a row of pointer addresses.
Nothing that asked an animation for its named frames could recover them, so the
Asset Manager refused such an asset twice over: AssetInspector sent it to the
stock inspector, and AssetAnimationStage::canEdit denied it a palette and a
timeline. Both refusals carried comments explaining that the API did not round
trip, and both were right.
NAMED CELLS MODE IS NO LONGER A FLAG ANYBODY SETS. The field, its setter, its
write function and the member are gone; getNamedCellsMode() is a live read of
mImageAsset->getExplicitMode(). The image already held the only honest answer,
and a stored copy could disagree with it the moment that image was re-cut -- and
did worse than that, since a person could set the flag true on an image with no
names at all and get an animation with no frames and no explanation. Nothing has
to keep the two in step now because there is only one of them. The refresh
cascade already reaches here: setExplicitMode ends in refreshAsset,
AssetManager::updateAssetDependencies has the edge from the animation's Image
field, and the drain loop is index-based over a growing vector, so the dependent
animation is dispatched in the same drain that dispatched the image.
The whole editor stays in INDEX space, and that is what kept this small. The
palette shows cell N, the timeline holds cell N, a drag carries cell N, the
range dialog builds "28 29 30"; only loading and committing know that names
exist. Every gesture, the caret arithmetic, the hold detection and the undo
transaction are unchanged.
The one thing index space cannot carry is a name whose cell has been deleted. It
resolves to no index, and EVERY such name resolves to the same -1, so a list
round-tripped through indices would come back from a single edit with two broken
frames merged into one and the other silently committed away. So the timeline
keeps a parallel mSlotNames, always exactly the size of mSlots: mSlots stays the
drawing truth and mSlotNames the authoring truth. setFrames takes indices and
derives names, setNamedFrames takes names and derives indices, and both always
fill both -- which is why appendFrame, insertFrameAtPoint and the range dialog
needed no changes at all. A missing frame draws as an outlined empty cell in the
theme's error color with the name it could not find under it, in the same ink,
and says so again in its tooltip and in the inspector's warning line. It is kept
rather than dropped because dropping it is a deletion the user never asked for
and could not have seen happen.
Cells label themselves in both grids now, so the payoff is visible where the
work is done. That is one virtual on the shared base, so the palette and the
timeline cannot label the same cell differently -- which would make dragging
between them a guess. Long names clip with an ellipsis and the full text is in
the tooltip; the clip measures and draws the same string rather than pairing
getStrNWidth with dglDrawTextN, whose counts are bytes and UTF16 units
respectively and disagree the moment a name is not ASCII.
EVERY EXPLICIT CELL NOW HAS A NAME, which is the invariant the rest rests on. A
cell stored without one is named Frame<N>, seeded at its own index and walked
past anything already taken -- not hypothetical, since deleting a cell from the
middle renumbers every one after it. It happens in calculateExplicitMode, the
one funnel every path ends in, because the TAML read pushes straight into
mExplicitFrames and never goes near addExplicitCell. What stood there before was
a "repair" in four places that could not have worked: it read dSscanf FROM the
empty name INTO a U32 passed by value where a pointer was required, and never
assigned a name to anything. Its guard never fired either -- it compared a
console or TAML buffer against StringTable->EmptyString by POINTER, and neither
is ever interned. The image editor's Add Cell button now asks the engine for the
name instead of building "Frame" @ index itself with no uniqueness check, which
is how adding a cell after deleting one from the middle produced a duplicate
that the rename box beside it would have refused.
Switching an image between explicit and cell mode converts the animations on it,
so the switch is a decision rather than a commitment. Both lists are kept in
memory and only the one in use is written, and the conversion runs from
onAssetRefresh, setImage and initializeAsset -- NOT from validateFrames, which
is where it obviously belongs and where it would have destroyed data. That is
called from inside both frame setters, so setAnimationFrames("") -- what the
editor sends when the timeline is emptied, and what copyFieldsFrom sends on
every single copy -- would have seen an empty active list beside a full one and
put the frames the user had just cleared straight back. validateFrames stays a
pure derivation that touches neither specified list. initializeAsset is new
here: settling this after the whole file is read is what makes the result
independent of TAML field order, which mattered as soon as anything depended on
Image and a frame list together.
Gating the write on the mode is what closes the round trip. Both lists used to
be written whenever they had content, and the named one is applied last and used
to force named mode on -- so an animation given numbered frames after ever
having had named ones came back from its own file named. That in turn made an
older landmine reachable: onTamlCustomWrite gated the Cells node on explicit
mode, so saving an image with the mode off deleted every cell in the file and
with them the only thing that could ever resolve those names again. The cells
are authored data that outlive the mode -- copyAssetStateTo says so in as many
words -- so they are written whenever there are any. Which means the file has to
state the mode out loud, because the read infers explicit mode from the presence
of a Cells node and must keep doing so for every file written before this. A
file that states it is believed; only a file that says nothing is inferred from.
Engine defects fixed on the way, all reachable before any of this:
- getExplicitCellOffset returned NULL from a Vector2-returning function when not
in explicit mode, which selects Vector2(const char*), which calls setString on
a null pointer and dereferences it. The image editor's swap-cells path reaches
it.
- all four getExplicitCell accessors indexed with Vector<T>::at, which takes a
U32 and only asserts -- so it is unchecked in release and at(-1) was a read at
four billion. A failed name lookup is exactly what -1 means around here.
- getExplicitCellName and getExplicitCellIndex refused to answer while explicit
mode was off, which is precisely when a name has to be translated back into an
index. The guards are off those two; the four mutators keep theirs.
- getCellByName's empty-name guard was the same pointer comparison as above, so
an empty name matched the first frame of any image whose cells are unnamed.
- ImageFrameProviderCore::mUsingNamedFrame and mNamedImageFrame were never
initialized by the constructor and never cleared by clearAssets, and
validRender reads the first on the first frame of every static sprite -- an
indeterminate true then dereferences an equally indeterminate name.
- getNamedAnimationFrames sized its return buffer at a fixed 4096 that suits a
list of integers. A region name has no length limit and dSprintf truncates in
silence, so a long animation would have lost its tail and said nothing. Both
it and getMissingFrames measure first.
- mValidatedNameFrames was missing its VECTOR_SET_ASSOCIATION, and the dead
mAnimationIntegration field is gone.
Two new bindings exist to stop script having to branch. getFrameCount answers in
whichever space the animation uses; getAnimationFrameCount refuses in named mode
and returns -1, which read as "fewer than one" to Keep Frame Rate and as "-1
frames" on the inspector's info line. getMissingFrames returns the names no cell
answers to, which is cheaper and more honest than N console calls per refresh.
animationFrameConversionTests and imageAssetCellNameTests cover the arithmetic
through the statics, because building a real explicit cell needs a bitmap and a
unit test has no GL context to load one into: the round trip, a hold surviving
it, everything that fails to resolve in either direction, and the naming search
including the collision walk and the case fold. AnimationAssetCarriesNamedFrames
lost its mode assertion with the field and gained a sibling asserting that BOTH
lists survive a copy, which is what makes the mode switch reversible.
animationFrameValidation carries the engine half -- most valuably that
getNamedAnimationFrames returns four names and not four numbers -- plus the mode
switch, the auto-naming, and the file keeping its cells with the mode off.
assetAnimationTimeline drives the editor half through to reading the saved file
back. The shot harness gains the two pictures that only a picture settles:
whether a name reads at 48 pixels, and whether a missing frame is findable.
The toybox 1234 image and 1234Animation are the demo pair, and the only named
assets in the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Asset Manager: an editor for every asset kind, and an asset you can change your mind about
Every project this editor has ever made had a module called BlankGame in it. NewProjectDialog handed CopyModule the same id for source and target, so the engine's rename path never ran, and the game module kept the template's name, the template's Author of nothing, and the template's description -- "A blank game, ready for you to craft into something amazing!" -- no matter what the person creating it typed. The dialog asked for three things and only the title reached anything. It asks for six now: Title, Directory, Game Core, Module Name, Author and Description, each with a tooltip on the caption as well as on the input. Module Name defaults to the title stripped to identifier characters plus "Game", and it follows the title until the moment someone types their own, after which it is theirs. Description now reaches the game module as well as AppCore, and Author reaches it at all. RENAMING A MODULE IS NOT RENAMING ITS ModuleId. The engine calls <ModuleId>::<CreateFunction>, so the id in module.taml and the namespace in the module's script are the same name written twice, and asset ids are that name a third time -- "<ModuleId>:<assetName>", in script and in every taml that references an asset. Rewrite only module.taml and the module still loads, still reports its new name everywhere the UI looks, and silently does nothing: create never fires, and its assets resolve to a module that no longer exists. Nothing about the definition it wrote looked wrong, which is why this went unnoticed. So the rename is a pass over the module's own source, and it lives in one place, ModuleStamper, called from all three paths that rename a module. The other two were worse than the New Project one, because a person could reach them repeatedly: New Module from a template (ProjectGamePanel::onModuleCreated) and Edit Module changing a name (::onModuleEdited) both rewrote ModuleID and stopped there. The substitution is whole-word -- a match counts only where neither neighbour can be part of an identifier -- because in the Edit Module case the old id is whatever a person called their module, and can be a substring of an ordinary word in a comment. The engine has half of this already and it is not enough. copyModule runs a TamlModuleIdUpdateVisitor when the ids differ, but that visitor is root-only, so an asset id on a nested element is missed; it cannot touch .cs at all, which is where the namespace lives; and it renames module.taml to <ModuleId>.module.taml, a name that nothing else in the editor opens a module definition by, with no fileRename bound to script to put it back. Hence the copy going out under the template's own id and the rename happening afterwards, on the copy. TEMPLATE IS NOW A FLAG AND Type SAYS WHAT A MODULE IS. Type == "Template" was only ever read as an enumeration filter, in the two places that either wanted templates or did not; "leave this module alone" is enforced by the engine's own Synchronized field and always was. So BlankGame is Type="Game Core" and ArtPack is Type="Art Pack", both carrying Template="1" and a DisplayName, the two filters test the flag, and the Game Core dropdown can ask findModuleTypes for exactly the templates a project can be built out of. The markers come off the copy. Two fixes to what was already there, both found by the tests. The New Project dialog appended a trailing backslash to its project path and then built every other path on top of it, which the engine's own file calls expand away and script-side isDirectory does not -- it stats the string it is given, so ModuleStamper was handed a folder that did not exist. And the buttons were positioned from the dialog's own height rather than from EditorDialog:: contentHeight, putting the Create button eight pixels past the fold and the whole form behind a scroll bar; the comment on contentHeight names this exact trap and lists two other dialogs that shipped with it. Both suites end by loading the renamed module and checking its gui reached the Canvas, because that is the only thing that proves create actually fired. The layout checks assert every control ends inside the content pane, and they fail on the code that shipped a scroll bar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Make a project and its game module declares Path="Sprites" and Path="Fonts", where the template it was copied from said "sprites" and "fonts". On Windows nobody notices. On Linux those directories do not exist, so an image dropped into sprites/ afterwards is never scanned and never becomes an asset -- silently, with the module.taml sitting right there looking correct. AppCore gets it too, so every project made by this editor has had it. Nothing in the path code was wrong. The fields were TypeString, whose ConsoleSetType interns without caseSens, and StringTable's hash is case insensitive by construction: hashString runs every byte through a to-lower table, so two spellings that differ only in case ALWAYS land in the same bucket, and an insensitive insert then matches the first one there with dStricmp and hands back ITS spelling rather than the one it was asked for. Whether a project came out right depended on whether anything else in the process had already interned "Sprites" -- which is why this looked intermittent and why the template on disk was never wrong. TypeCaseString already existed and already does the right thing -- insert(argv[0], true) -- and is already used on taml serialized classes (GuiControl::text), so this is a field type change rather than new machinery. Declared and Referenced assets take it for Path and Extension; ModuleMergeDefinition for MergePath. ModuleDefinition's ScriptFile and AssetTagsManifest are protected fields, so the setter is the write path and takes the flag directly. The dead C++ setters beside them were changed too, so the classes cannot disagree with themselves later. WHAT IS DELIBERATELY LEFT FOLDED. ModuleId, Group, Type and the create and destroy function names stay TypeString. Those are identifiers rather than paths, and unlike the paths they ARE compared as string table pointers -- ModuleManager does it in nine places for load order, groups, types and dependency resolution. That matching has always been case insensitive, so a project depending on "AppCore=1" resolves against a module whose definition says "appCore" today. Making them case sensitive would break that, and it would present as a missing module rather than as a spelling problem. There is a test asserting ModuleId still folds, so this reads as a decision. Every field changed here was checked for pointer comparison first, because that is the way this fix bites back: StringTableEntry equality is pointer equality, and it only holds because both sides were interned the same way. Once a bucket holds one spelling insensitively and another sensitively, an insensitive insert returns whichever node sits earlier in the chain, so a case sensitive field compared against a value interned the old way can miss. None of these seven are compared -- getPath only ever feeds a dSprintf, getExtension is matched with dStricmp inside the scan, and ScriptFile, AssetTagsManifest and MergePath are only ever tested against EmptyString before being formatted into a path. The ResourceManager and TextureManager dictionaries are the reason the fix stops here. Both HASH by pointer value, so a mismatch does not merely fail a comparison, it never reaches the right bucket at all -- and their keys come from the platform layer's dumpPath, which interns whatever readdir returned. That cluster is self consistent today and has to move as one piece with every platform back end. Worth doing, not worth doing halfway. stringTableCaseTests pins the primitive down in both directions, including the hazard above, so that a later simplification of the flag fails a test that says why it exists. declaredPathCaseTests goes through setDataField rather than the C++ setters, because that is the route TAML takes, and it carries a control: ModuleId is poisoned and set identically and still folds, which is what makes the six passing cases evidence about the field type rather than about the fixture. The New Project suite checks the artifact -- Path="sprites", ScriptFile="game.cs" -- case sensitively, against a project the editor really built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BlankGame declares eight asset paths and shipped three of them, so every project made from it warned four times the first time it was opened -- about sprites, fonts and particles, directories the template never had. Adding them is the whole of the content change here, and each one carries a readme saying what belongs in it and how a file there becomes an asset, because a template is something a person opens and reads. Adding them turned up the rest of this commit. A directory called fonts came back out of readdir as "Fonts". Not sometimes: always, on this platform, since the string table's hash is case insensitive by construction and an ordinary insert returns whichever spelling of a name reached the table first. Two of them get there during STATIC INITIALISATION, before any of the engine runs -- SpriteBatch interns "Sprites" as a taml node name and guiProfileTheme interns "Fonts" as a field group -- so sprites and fonts, the two most ordinary names an asset folder can have, were the two that could never survive a scan. That is why a copied module.taml said Path="Sprites", and why directoryDelete could not recurse into a folder it had just been told about: it was handed a name nothing could stat. So the platform layer interns what readdir gives it case sensitively, and so do ResManager::getPaths, the zip and openFileForWrite paths, and the ResDictionary's own path normalisation. THOSE HAD TO MOVE TOGETHER. The dictionary derives its bucket from the POINTER VALUES of path and name and compares them by pointer, so a half-applied change does not merely compare false -- it hashes into the wrong bucket and reports a file that exists as missing. The four in resourceDictionary are the path key, the rest are the file key, and either one alone would have been worse than the bug. The four editor themes are part of this rather than incidental to it. Each asked for "^EditorCore/Themes/<name>/Fonts" while the directory on disk is fonts, and that is where $GUI::fontCacheDirectory comes from. It worked only because the fold rescued it -- guiProfiles.cs asks for the same LabCoat directory in lower case two files away, which is the codebase disagreeing with itself. Left alone, they would have become a real missing-font-cache bug the moment lookups stopped folding, so a latent bug and its disguise are removed in one go. WHAT IS STILL DELIBERATELY FOLDED is unchanged from the previous commit: ModuleId, Group, Type and the create and destroy function names are identifiers, they are compared as string table pointers in nine places in ModuleManager, and dependency resolution has always matched them regardless of case. directoryScanCaseTests writes a real sprites/readme.md and fonts/readme.md, puts the capitalised spellings in the table by hand so the collision cannot be absent by luck, and then asks for the one thing all of this is about: that a caller can open what the scan named. Its last two lines delete the tree the scan just enumerated, which is the exact step that failed in the wild. Only Linux is changed. The same intern sites exist in all six platform back ends and are wrong in the same way, but Windows and macOS are case insensitive at the filesystem, so it costs them nothing today and I cannot run their tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new project that is named after your game
Torque2D has had no changelog since 2013. The one that existed lived on the wiki, was a hand-copied list of commit hashes written after each merge, and stopped the way that kind of document always stops: the job had no owner and no trigger. This one is in the repository so an entry can be written in the same pull request as the change, by the person who made it, while they still remember why it matters. Review catches a missing entry the way it catches a missing test. CONTRIBUTING.md asks for one. Covers Early Access 1 through 4, written for someone building a game on the engine rather than for someone working on it: an entry earns its place if it changes what you can do, what your scripts have to say, or what ends up in your files. Breaking changes lead each release with their migration. The asset save model is the one to read. Editing an asset no longer writes its file -- refreshAsset only marks it changed and tells everything watching, and saveAsset is the only thing that writes. A game no longer rewrites its own content as a side effect of a setter, and a typo no longer reaches disk before you notice it. History before 4.0 stays in the git log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Until now the only instructions for contributing lived on the wiki and began by telling you to sign an Open Source Software Agreement on garagegames.com, a site that no longer exists. There was no working description anywhere of how to submit a change. CONTRIBUTING.md replaces it, next to the code so it stays true as the code changes. Branch policy, one change per pull request, and the things a contributor cannot know without being told: that new engine sources must be listed in cmake/EngineSources.cmake or they are simply not compiled, that script-callable methods belong in the class's _ScriptBinding.h because the doc comments there generate the scripting reference, and that CI builds every platform but does not run the tests, so a green build does not mean a tested one. It also asks for a changelog entry in the same pull request as the change, which is the only way that file stays alive. What replaces the agreement is the rule Torque3D settled on: your contribution must be legally yours to give and compatible with the MIT license. That names the actual risk -- pasted GPL or proprietary code -- rather than asking anyone to sign anything. CODE_OF_CONDUCT.md is Torque3D's, unchanged. It is already written for the Torque Game Engines community as a whole, and the forums and Discord it refers to are shared, so it applies here as-is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
Six entries for the three commits merged after the changelog was written -- the New Project dialog's new fields, the game module taking the project's name, module renaming rewriting the module's source, BlankGame shipping the folders it declares, and the two string-table case bugs underneath all of it. The last two are worth reading even though they sound like internals. A module could come out declaring Path="Sprites" where its template said "sprites", and on Linux that directory does not exist -- so images dropped into sprites/ were never scanned and never became assets, silently, with a module.taml sitting there looking correct. This is the first entry written the way CONTRIBUTING.md asks for: alongside the change rather than reconstructed from the log afterwards. Reconstructing is how the old wiki changelog died. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
CONTRIBUTING.md, CODE_OF_CONDUCT.md and CHANGELOG.md are new at the root and nothing pointed at them, so a fresh session would not have known they exist or that the changelog wants an entry per change rather than a sweep before a release. Also notes that documentation lives in the sibling wiki repo, since an engine change that alters a script API or an editor workflow usually needs a change there too, and that repo has its own conventions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
CMakeLists says VERSION 4.0.0, the Android build says 4.0, the README says Early Access 4, and TORQUE_GAME_NAME has said "Torque 2D 4.0: Rocket Edition" for a while. TORQUE_GAME_ENGINE was still 1000. By its own comment directly above it -- major * 1000 + minor * 100 + revision * 10 -- that is version 1.0. So getVersionNumber() answered 1000 to any script that asked, and serverQuery's compatibility check, which compares that number between client and server, has been treating every build as 1.0 rather than comparing anything meaningful. TORQUE_GAME_VERSION_STRING was "Open Source", which is not a version. It is what getVersionString() returns and what Linux prints at start-up, so the banner read "Open Source (built on ...)". It now says "4.0 Early Access 4". Neither value is gated on by engine code, so nothing changes behavior beyond what those two functions answer -- which is the point. An engine that cannot say what version it is makes every downstream version check meaningless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merges
developmentintomasterto ship Early Access 4 (tagv4.0-ea4). This is the EA3→EA4 body of work: the GUI Editor and Asset Manager buildout, a complete demo game, full-platform builds via CMake, and a large batch of cross-platform fixes.Highlights
TORQUE_SCRIPT.mdstyle guide.CMakeLists.txt— Windows, macOS (Apple Silicon), Linux, iOS, Android, and Emscripten/Web (web now boots in a browser).Breaking changes
EA4 has breaking changes for EA3 projects — chiefly that editing an asset no longer writes its file (
saveAsset()is now explicit), and AppCore's hand-written GUI profiles were replaced by themes. Full list inCHANGELOG.md.Verification
PR-builds.yml) builds Debug + Release across Windows (VS2022/VS2026, x64/Win32), Linux (x64/32-bit), macOS, iOS and Android — all green on the release commit.tests/run.*) and the GoogleTest unit suite (tests/run-unit.ps1) pass.