Changelog - v6.2.0-beta18

Range: dcea3206..0c8160d4

Feature

macOS Platform

  • Platform::Mac appended after Psp (value 7, serialized values preserved), PlatformBit_Mac, StringToPlatform gains "PSP" and "Mac", GetPlatform() returns Mac under PLATFORM_MAC. ASSET_VERSION_INPUT_PROMPT_MAP_PLATFORM_MAC = 43, ASSET_VERSION_CURRENT = 43; pre-v43 InputPromptMap files remap raw 7 back to Platform::Count so "Any" entries do not become Mac. Legacy sidecar masks naming all six original platforms (kLegacyNamedAllMask) now mean PlatformBit_All so PSP and Mac are not excluded.
  • Build. PLATFORM_MAC=1 + API_VULKAN=1 over MoltenVK. New Makefiles mirroring the Linux ones line for line: Engine/Makefile_Mac, Standalone/Makefile_Mac_Editor, Standalone/Makefile_Mac_Game, Template/Makefile_Mac_Editor, Template/Makefile_Mac_Game, External/Bullet/Makefile_Mac, External/Assimp/Makefile_Mac. Objects in Intermediate/Mac/, outputs in Build/Mac/; editor at Standalone/Build/Mac/PolyphaseEditor, runtime at Standalone/Build/Mac/Polyphase.macho (the suffix avoids colliding with Packaged/Mac/<Project>/). MACHDEP := -arch arm64 -mmacosx-version-min=12.0; -std=gnu++17; .mm files compile -ObjC++ -fobjc-arc; link -rdynamic -Wl,-rpath,@loader_path -Wl,-rpath,@loader_path/../Frameworks -Wl,-rpath,$(VULKAN_SDK)/lib with Cocoa, Metal, QuartzCore, IOKit, GameController, AudioToolbox, CoreAudio, Security, CoreFoundation and Carbon; the editor adds -lAssimp -lBullet -lgit2 -liconv -framework GSS -lshaderc_combined -lspirv-cross-core. Shared variants (POLYPHASE_SHARED=1 / POLYPHASE_EDITOR_SHARED=1) emit libPolyphaseGame.dylib / libPolyphaseEditor.dylib from PIC objects in Intermediate/Mac-pic. Apple make is 3.81, so the Mach-O link rule is explicit; Assimp filters OFILES to %.o because Apple ld rejects raw source names. Makefile_Mac_Game honours -include $(PROJECT_ROOT)/Generated/AddonInject.mk and recurses via $(firstword $(MAKEFILE_LIST)) so the packager's Makefile_TEMP splice is respected. CMakeLists.txt gains elseif(APPLE), but the Makefiles are canonical. Tools/prebuild_mac.sh verifies $VULKAN_SDK/bin/glslc, cmake and xcode-select -p, then runs prebuild_libgit2.sh (Darwin adds -DUSE_HTTPS=SecureTransport -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0), the shader compile, and the embedded-stub generator. External/Zlib/zutil.h's OS_CODE 7 block now excludes __APPLE__. <malloc.h> is guarded #if !PLATFORM_MAC in Texture.cpp, ContentObfuscation.cpp, ContentPak.cpp, Renderer.cpp, Stream.cpp.
  • Toolchain. Xcode Command Line Tools (clang, ar, ld64), Homebrew cmake python3, LunarG Vulkan SDK for macOS 1.4.357.1 (MoltenVK, loader, glslc, libshaderc_combined.a, libspirv-cross-core.a) with VULKAN_SDK pointing at ~/VulkanSDK/<ver>/macOS. wss:// needs Homebrew curl via the new POLYPHASE_LIBCURL env override (honoured on Linux too).
  • System_MacCocoa.mm. NSApplication bootstrap with menu bar, PolyphaseMetalView (flipped, CAMetalLayer, Finder drag-drop → SYS_DrainDroppedFiles), PolyphaseAppDelegate (Cmd-Q and close set mQuit), a pixel coordinate model in backing pixels (drawableSize, mouse × backingScaleFactor, SystemState::mBackingScale, SYS_GetDisplayScale()), a polled NSEvent pump feeding INP_* and ImGui_ImplMac_*, left/right modifier bits with Command aliased onto POLYPHASE_KEY_CONTROL_L/R, trackpad precise scroll × 0.02 accumulated to ticks, CGWarpMouseCursorPosition, Alt+Enter fullscreen, NSOpenPanel / NSSavePanel, NSPasteboard, open for reveal. SYS_OPEN_CMD macro ("open" vs "xdg-open"). GameMain chdirs to bundle Resources when Config.ini is not in cwd.
  • System_Mac.cpp (POSIX half). SYS_GetBundleResourcePath(), bundle-aware SYS_GetPolyphasePath, posix_memalign with alignment clamped to sizeof(void*), RAM via task_info(MACH_TASK_BASIC_INFO), CPU% from task basic + thread times, SYS_GetTotalRAM via hw.memsize, SYS_GetPlatformTier() = 2, double-fork SYS_SpawnDetachedExecutable, SYS_CopyFile verified by stat. Save directory: $POLYPHASE_SAVE_DIR → <projectDir>/Saves if writable → ~/Library/Application Support/<Project>/Saves. SYS_CloseDirectory null-guards because closedir(NULL) segfaults on macOS.
  • Audio_Mac.cpp. DefaultOutput AudioUnit fed by a lock-free SPSC SampleRing; kMixSampleRate 44100, kMixChannels 2, AUDIO_MAX_VOICES 8, playback buffer 1/15 s, ring two update periods. Streaming pool kMaxStreamingVoices = 4, one AudioUnit per voice, 16-bit mono/stereo only, 1 s ring with soft backpressure (AUD_SubmitStreamBuffer returns 0), mFramesPlayed counts real frames only so the clock stalls rather than drifts, AUD_FlushStream via atomic mReset.
  • Input_Mac.mm. GameController.framework polled per frame into INPUT_MAX_GAMEPADS slots; DualShock 4 and DualSense via productCategory; triggers → L2/R2 at > 0.5; balanced NSCursor hide/unhide; cursor trap via CGAssociateMouseAndMouseCursorPosition; Carbon kVK_* KeyCode enum; presets under ~/Library/Application Support/PolyphaseEditor/InputPresets.
  • Network_Mac.cpp, Serial_Mac.cpp (termios raw mode; enumerates /dev/cu.usb*, cu.usbserial, cu.usbmodem, cu.SLAB, cu.wchusb), and HttpBackend_Linux.cpp / WebSocketWss_Linux.cpp now build on Mac with libcurl.4.dylib dlopen candidates plus Homebrew paths.
  • Vulkan / MoltenVK. VK_EXT_metal_surface, VK_KHR_portability_enumeration + VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR, vkCreateMetalSurfaceEXT on mMetalLayer, VK_KHR_portability_subset when advertised. Metal has no triangle fans, so Quad::kExpandFanToList (kMaxFanVertices 38, Mac kMaxQuadVertices = 108) and VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST on Mac. VulkanContext::CreateInstance now warns and continues without validation layers instead of asserting (shipped .app, no SDK on the layer path). MaterialBase::Compile resolves $VULKAN_SDK/bin/glslc or PATH glslc on POSIX. Engine/Shaders/GLSL/compile.sh rewritten: the old val= $VULKAN_SDK/bin/glslc … never captured a status and [ val != 0 ] was always true.
  • Editor. imgui_impl_mac.{h,mm}, a Cocoa backend forked from imgui_impl_osx.mm and restructured like imgui_impl_xcb (engine pump feeds ImGui_ImplMac_EventHandler, backing pixels, IME via NSTextInputClient). EditorMain::SetupMacToolchainEnvironment() fills VULKAN_SDK, VK_DRIVER_FILES, VK_ADD_LAYER_PATH, DEVKITPRO/PPC/ARM and PATH for Finder launches (reads External.json vulkanSdkPath); Retina defaults mEditorInterfaceScale to the display scale unless EditorInterfaceScale is explicit in Config.ini (EngineConfig::mEditorInterfaceScaleExplicit). NativeAddonManager builds addons with clang -dynamiclib -arch arm64 -mmacosx-version-min=12.0 -Wl,-undefined,dynamic_lookup as lib<binaryName>.dylib; ModuleLoader loads .dylib; terminal uses forkpty from <util.h>; AutoUpdater prefers .dmg; preferences under ~/Library/Application Support/PolyphaseEditor; Docker disabled for Mac profiles; GamePreview defaults to 1920x1080; BuildDependencyWindow adds Mac-only CheckXcodeTools() and CheckGlslc(); .NET hint brew install --cask dotnet-sdk. VS Code: Polyphase Editor - Mac / Polyphase Game - Mac (lldb) with matching make tasks.
  • Packaging games — MacBundlePackager (polyphase.mac, "macOS (App Bundle)"), the canonical target for Platform::Mac. Compile reuses Makefile_Mac_Game (make -C {BuildProjDir} -f Makefile_TEMP -j 12, strip -S); PostPackage wraps Packaged/Mac/<Project>.macho, the loose payload, libvulkan.1.dylib + libMoltenVK.dylib and a bundle-relative Contents/Resources/vulkan/icd.d/MoltenVK_icd.json into <Project>.app; addons go to Contents/MacOS/Addons/; copyBinaries stage into Contents/Frameworks. Options: mac.bundleId (com.polyphase.<AppName>), mac.version (1.0.0), mac.minOsVersion (12.0), mac.iconPath (.icns via sips + iconutil), mac.signingIdentity, mac.notarize, mac.notaryProfile, mac.createDmg (hdiutil); any change alters the build-cache variant key. Bundles are always at least ad-hoc signed (codesign --sign -) because Apple Silicon refuses unsigned arm64 and install_name_tool invalidates signatures; distribution signing uses --options runtime --timestamp with disable-library-validation, allow-unsigned-executable-memory and allow-dyld-environment-variables entitlements, then xcrun notarytool and stapler. Build & Run uses open -n; headless -build Mac embedded. verify_project_build.sh … Mac locates the .app and runs codesign --verify --deep --strict. New ActionManager::ResolveCompiledBinaryDir() and RunPostPackage(), which now also runs on the cache-hit path because banner, RSF and tool inputs are not in the manifest.
  • Shipping the editor. Installers/build_app_mac.sh runs stage_distribution.py --platform mac and builds dist/Polyphase.app (CFBundleIdentifier com.polyphase.editor, LSMinimumSystemVersion 12.0, exports UTI com.polyphase.project for .octp, bundle-relative ICD with is_portability_driver), signing dylibs, Polyphase.macho and the bundle inside-out; env MAC_SIGN_IDENTITY, MAC_NOTARY_PROFILE, SKIP_STAGE=1. Installers/build_dmg_mac.sh produces dist/PolyphaseEditor-<version>-macos-arm64.dmg (UDZO, /Applications symlink, three hdiutil retries). stage_distribution.py gains the mac platform and stage_vulkan_runtime_mac() (via resolve_vulkan_sdk_mac()). Automated in beta.18, see CI below.
  • *CI (beta.18).* New composite action .github/actions/install-vulkan-sdk-mac (inputs version default 1.4.357.1, polyphase-repo default Polyphase-Labs/Polyphase-Engine, mirror-url full-URL override; output vulkan-sdk-dir). It downloads vulkansdk-macos-<ver>.zip from a vulkan-sdk-<ver> GitHub Release on the engine repo first and LunarG's CDN second (with a ::warning::), because LunarG cycles old SDKs off the CDN and old releases should stay reproducible; skips the download when bin/glslc and lib/libMoltenVK.dylib already exist; drives the InstallBuilder .app headlessly (--accept-licenses --default-answer --confirm-command install); hard-fails unless bin/glslc, lib/libMoltenVK.dylib, lib/libvulkan.1.dylib, lib/libshaderc_combined.a, lib/libspirv-cross-core.a and share/vulkan/icd.d/MoltenVK_icd.json exist; exports VULKAN_SDK=$HOME/VulkanSDK/<ver>/macOS and prepends bin to PATH. New release.yml job build-mac on pinned macos-15 (Apple Silicon; the -large runners are Intel and the Makefiles hardcode -arch arm64), needs: publish-docker-images: submodule init, actions/cache@v4 on ~/VulkanSDK/1.4.357.1 (key vulkan-sdk-macos-1.4.357.1), the action, bash Tools/prebuild_mac.sh, make -C Standalone -f Makefile_Mac_Editor -j$(sysctl -n hw.ncpu), make -C Standalone -f Makefile_Mac_Game (staged into the bundle at Contents/Resources/Standalone/Build/Mac/Polyphase.macho so the Packaging window can ship script-only projects), make -C External/Lua a SYSCFLAGS="-DLUA_USE_MACOSX" MYCFLAGS="-arch arm64 -mmacosx-version-min=12.0" copied to External/Lua/Build/Mac/arm64/ReleaseEditor/libLua.a (the a target, not macosx, which would also link lua/luac against readline) with its own verify step because stage_distribution.py only warns when it is missing, bash Installers/build_dmg_mac.sh, a glob that errors unless exactly one dist/PolyphaseEditor-*-macos-arm64.dmg exists, hdiutil verify, upload as artifact macos-dmg. create-release now needs build-mac, downloads {windows-installer,windows-sdk,linux-deb,linux-tarball,macos-dmg} and attaches PolyphaseEditor-*-macos-arm64.dmg. Ad-hoc signed only: MAC_SIGN_IDENTITY / MAC_NOTARY_PROFILE are documented in the job but not wired, so first launch is right-click → Open. The Linux-only smoke addon and the verify-test-project matrix are not ported. Mac.md notes the SDK version now lives in three places (doc, action default, cache key).
  • *Input fixes (beta.18), imgui_impl_mac.mm / System_MacCocoa.mm.* (1) Cmd+A/C/V/X/Z were dead in every text field: ImGui_ImplMac_UpdateKeyModifiers fed ImGui from the engine key array, where Command is aliased onto Control for the editor's Ctrl-named hotkeys, but under ConfigMacOSXBehaviors ImGui resolves ImGuiMod_Shortcut to Super and Shortcut() matches the modifier set exactly, so a Cmd press that also read as Ctrl never matched. Modifiers now come straight from [NSEvent modifierFlags] (Control / Shift / Option → ImGuiMod_Ctrl/Shift/Alt, Command → bd->SuperDown), which also self-heals a modifier left stuck by a Cmd+Tab whose key-up went to another app. (2) Held keys typed one character, and typing could be dead entirely: ImGui_ImplMac_EventHandler returned on [event isARepeat] before the character path, and when the responder chain was broken insertText: never fired. The repeat guard now only wraps AddKeyEvent / SetKeyEventNativeData; new ImGui_ImplMac_Data::CharsThisEvent counts characters the responder produced, and when it produced none for a NSEventTypeKeyDown the handler walks [event characters] into io.AddInputCharacter, skipping control codes (< 32, 127) and the 0xF700–0xF8FF private-use block AppKit uses for arrows, F-keys, Home and End. SYS_Update now runs the ImGui handler after [NSApp sendEvent:] (engine state → Cocoa dispatch → ImGui) so it can tell whether the responder produced characters. (3) Keyboard lost after focus changes, fullscreen or a native file dialog: new IMGUI_IMPL_API void ImGui_ImplMac_EnsureKeyResponder() re-asserts the NSTextInputClient responder as first responder, called from ImGui_ImplMac_Init, windowDidBecomeKey:, windowDidEnterFullScreen:, windowDidExitFullScreen:, and via RestoreKeyResponderAfterModal() at the end of SYS_OpenFileDialog, SYS_SaveFileDialog and SYS_SelectFolderDialog.
  • EditorShortcutModDown() (beta.18) — new inline in EditorImgui.h (ImGui::IsKeyDown(ImGuiMod_Shortcut): Cmd on macOS, Ctrl elsewhere) replaces io.KeyCtrl wherever it meant "shortcut or multi-select modifier" rather than the physical Control key a terminal still needs: Ctrl-click toggle-select in the Scene and Scripts panels, EditorHotkeyMap::IsActionJustTriggeredImGui (every ImGui-routed hotkey binding now fires from Cmd), Debug Log (entry toggle, Ctrl+C, Ctrl+A), CLI Terminal (range and toggle select, Ctrl+A, pipe-mode Ctrl+C), Packaging Build Output (same), Node Graph (Ctrl+C/V/D), Script Editor (Ctrl+S/O/N). Behaviour-preserving on Windows and Linux.
  • Consoles from a Mac host. devkitPro's pacman .pkg, sudo dkp-pacman -S wii-dev 3ds-dev, the libogc2 key import (with an HTTPS curl fallback when HKP port 11371 is blocked), a [libogc2-devkitpro] entry with macos/$arch mirrors, and gamecube-tools-git libogc2 libogc2-libdvm. No WSL analogue is needed.
  • Limitations. Apple Silicon only (Intel and universal binaries unsupported; MACHDEP is where they would go); the compute path tracer for light baking stays Windows/Linux; wideLines is unavailable on Metal so debug lines are 1 px; no Docker builds; the Lua debugger transport is Windows-only; Finder double-click of an .octp arrives as an Apple Event (use open -a Polyphase --args …).

Occlusion Culling

  • OcclusionData (new Engine/OcclusionData.h/.cpp) — a per-scene potentially-visible set over a uniform grid (mMin, mCellSize default 4.0f, mDimX/Y/Z). Each cell maps to a bitset of occludees (mWordsPerSet words); identical bitsets are deduplicated through mSetPool; cell → set indices are 16-bit (mCellToSet16, 0xFFFF = no set) whenever every index fits, else 32-bit. API: IsValid, Clear, FindCell(vec3), GetCellSetIndex, GetSet, IsVisible(set, occludeeIndex) (null set ⇒ visible), GetCellAABB, GetVolumeAABB, GetNumCells, GetNumUniqueSets, GetStaticTableBytes / GetDynamicTableBytes / GetMemoryBytes, SaveStream, LoadStream(stream, version). mBakeAlgorithm records the sampling scheme so the baker only reuses bits from a bake with the same scheme.
    • Dynamic coarse table. Optional second table of mCoarseFactor-cubed blocks (mCellToCoarseSet16/32, mCoarseSetPool). IsBoxVisibleDynamic(cell, AABB): a moving occludee is visible if any coarse cell its box overlaps is visible; boxes partly outside the volume or covering more than kMaxDynamicCoarseCells = 64 coarse cells are reported visible.
    • Versions. ASSET_VERSION_SCENE_OCCLUSION = 40 (cell table as plain uint32), ASSET_VERSION_SCENE_OCCLUSION_DYNAMIC = 41 (width byte, coarse table, mOcclusionDynamic, mOcclusionConsoleBudgetKB), ASSET_VERSION_SCENE_OCCLUSION_ALGO = 42 (mBakeAlgorithm, defaults to 1 for v41 data).
    • OcclusionEntryIndex — tolerant lookup of baked OcclusionEntry { mCenter, mExtents } records, bucketed by floor(center / max(cellSize, 1)), probing the 27 neighbouring buckets and accepting the closest entry within max(0.1 * cellSize, 0.01 * maxExtent). Play-in-editor clones and re-instantiated trees rebuild rotations from euler properties, so float noise on a large rotated wall exceeds any exact quantisation; identity by UUID was rejected for the same reason.
  • Scene properties (category Occlusion Culling): Occlusion Culling (false), Occlusion Cell Size (4.0), Occlusion Bake Quality (Low / Medium / High, default Medium), Occlusion Dynamic Objects (true), Occlusion Console Budget KB (512, 0 = unlimited). Console budget strip: Scene::SaveStream for a console platform writes an empty OcclusionData when GetMemoryBytes() exceeds the budget and logs a warning; the editor copy always keeps it.
  • Primitive3D category Occlusion: Occluder Static / Occludee Static (both false), EnableOccluder / IsOccluder / EnableOccludee / IsOccludee, runtime SetOcclusionSlot(slot, bakedCenter) / GetOcclusionSlot() (0 = none) / GetOcclusionBakedCenter(), and SetOcclusionMatchBox(localAABB) for nodes whose match box differs from mesh bounds. OcclusionArea3D (new node, Extents default {20, 10, 20}, cyan wire-cube gizmo, Add Node → Environment) bounds the bake volume; without one the volume is the union of occludee bounds.
  • Runtime. DrawData::mOcclusionSlot with kDynamicOccludee = 0xFFFFFFFF; Mesh3D::GetDrawData uses the static slot only while distance2(center, bakedCenter) <= (0.25 * cellSize)², else the dynamic path. World::SetOcclusionData / IsOcclusionCullingEnabled / IsOcclusionDataStale / RequestOccludeeResolve / ResolveOccludees; resolve is deferred to Renderer::GatherDrawData, calls GetTransform() before GetOcclusionMatchAABB() because freshly cloned trees have dirty transforms, and flags the data stale (with an editor toast) if any baked occluder is unmatched. Eligible occludee types: StaticMesh3D, SkeletalMesh3D, Terrain3D, Voxel3D, TextMesh3D; never Skybox3D or ShadowMesh3D. Children of an unrolled InstancedMesh3D inherit the parent's slot; InstancedMesh3D::ComputeUnrollBuckets is shared with the baker so cells match on every platform. Renderer::OcclusionCull runs before FrustumCull (a bit test per draw, so frustum math is only spent on survivors), skips orthographic cameras, leaves mShadowDraws alone so hidden casters still shadow, never culls depthless draws, and routes culled draws through HandleCullResult(false) so skeletal and particle "always update" modes apply. The editor camera never drops draws; with View > Occlusion Preview on, it draws the volume, the game camera's cell, and per-occludee green/red boxes. Renderer::EnableOcclusionCulling / GetNumFrustumCulled / GetNumOcclusionCulled; StatDisplayMode::Culling on the stats overlay. Maths::RayIntersectsAABB (slab) and Maths::RayIntersectsTriangle (Möller–Trumbore, double-sided) added for the baker.
  • Baker — OcclusionBaker (Editor/OcclusionBake/), BakeCurrentScene() / ClearCurrentScene(), run from the end-of-frame dispatcher, cancellable, multi-threaded (hardware_concurrency() - 1, capped at cell count), status Cell N / M (K occludees[, incremental], T triangles[, + dynamic table]). Occluder triangles come from opaque-material StaticMesh3D, Terrain3D and Voxel3D; InstancedMesh3D occludees also bake one extra occludee per unroll cell so console-unrolled children cull individually. Median-split TriangleBvh (kMaxTrisPerLeaf = 4); LCG cell samples inset 2% from cell faces; samples inside solid geometry (≥4 of 6 axis probes hit back-faces within 4 * cellSize) are discarded and a fully solid cell is marked all-visible. Targets are box centre + 8 corners + optional face centres + a face grid + up to N mesh vertices; quality presets 0 → 4 samples / 0 verts, 1 → 9 / 8 / face centres, 2 → 17 / 24 / face centres. kWarnCells = 65536, kMaxCells = 262144 (hard error suggesting a larger cell size or OcclusionArea3D), kLargeOccludeeCells = 8, kTargetCoarseCells = 512 (coarseFactor = clamp(ceil(cbrt(numCells / 512)), 4, 16)), cell size floored at 0.25. Incremental: if dims, cell size, volume, occluder count and mBakeAlgorithm match and every occluder matches via OcclusionEntryIndex, pre-existing occludees' bits (and the dynamic table when mCoarseFactor matches) are copied rather than re-traced. Output bitsets are deduplicated (PackSets, FNV hash) and a warning fires when static + dynamic bytes exceed the console budget.
  • Editor wiring. World menu: Bake Occlusion Culling, Clear Occlusion Data (disabled in play-in-editor), Mark Selected Occluder + Occludee. View menu: Occlusion Preview. A top-centre toast with Re-bake / Dismiss appears when resolve flags stale data. REST: POST /api/occlusion/bake, POST /api/occlusion/clear, GET /api/occlusion/status (enabled, stale, baking, cells, unique sets, bytes, culled counts, camera cell, per-node slot and moved state), PUT /api/occlusion/preview.
  • Lua. Primitive3D:EnableOccluder(bool) / IsOccluder() / EnableOccludee(bool) / IsOccludee(); Renderer.EnableOcclusionCulling(bool) / IsOcclusionCullingEnabled() / GetNumFrustumCulled() / GetNumOcclusionCulled().
  • SeenByCamera. World::IsSeenByCamera(Node3D*, Camera3D* = nullptr) and World::GetSeenByCamera(std::vector<Node3D*>&, const std::vector<std::string>* tags = nullptr, Camera3D* = nullptr) — a frustum sphere test on the node's bounds plus the static bit or dynamic table; non-primitives use a 0.25 radius sphere; ComputeMatrices() is called on demand so results never lag a frame; hidden nodes and the camera itself are never seen. Lua: World:IsSeenByCamera(node, camera?) → boolean, World:GetSeenByCamera(tags?, camera?) → Node[] (tags as a string or array). Note: the generated World.lua stub declares GetSeenByCamera(camera) → Node, which does not match the binding.

3DS CIA Export

  • New built-in target polyphase.n3ds.cia ("Nintendo 3DS (CIA)", Editor/Packaging/CiaPackager.{h,cpp}) shares Platform::N3DS cook and compile with the canonical .3dsx target; PostPackage stages romfs (stripping .3dsx / .cia / .smdh / .elf so stale CIAs do not ride along), regenerates the SMDH with smdhtool, substitutes $(APP_TITLE), $(APP_PRODUCT_CODE), $(APP_UNIQUE_ID) and $(APP_ROMFS) into Standalone/3DS/template.rsf (or cia.rsfPath) at <Project>/Intermediate/3DS_CIA/app.rsf, and runs makerom -f cia … -target t -exefslogo (test keys). Output in Packaged/polyphase.n3ds.cia/. A CIA gets the full application region: 64 MB on O3DS, 124 MB on N3DS (SystemMode 64MB / SystemModeExt 124MB in the RSF); IORegisterMapping 1ff00000-1ff7ffff is required or ndspInit data-aborts. Install via FBI or Azahar; the HOME Menu caches banners by Title ID, so delete the title before reinstalling.
  • Options. cia.productCode (default CTR-P- + 4 chars), cia.uniqueId (default 0xFF000 | FNV1a(project) & 0xFFF, the homebrew block; Title ID 0x000400000<id>00), cia.version (6.6.4-packed, max 63.63.15), cia.rsfPath, cia.bannerMode (image / scene), cia.bannerImage (256x128), cia.bannerScene, cia.bannerRotate, cia.bannerSpin (30), cia.bannerRotMin / Max (0 / 360), cia.bannerAudio, cia.bannerAudioLoop, cia.bannerAudioAdpcm. Shared SMDH options n3ds.title / description / author / iconPath apply to both 3DS targets under a 3DS Target Options header and reach make as APP_TITLE, APP_DESCRIPTION, APP_AUTHOR, ICON (icon resized to 48x48 from any stb-readable image or Texture asset).
  • Banner. Audio forced to 16-bit stereo 44.1 kHz (kBannerAudioRate = 44100), capped at kBannerAudioMaxFrames = 96000 (about 2 s) with a 50 ms fade — 32 kHz CWAV plays as beeps on hardware. PCM16 via bannertool makecwav by default; DSP-ADPCM via cwavtool opt-in. 3D banners: BannerGltfExporter writes banner.gltf / .bin / tex_*.png (power-of-two textures ≤ 256) with nodes BannerOrigin → BannerSpin → BannerFit fitted to kTargetRadius = 10 at kLookAtY = 1; spin is about local Z under a −90° X tilt because pycgfx decomposes Y via asin; keys land on whole 60 fps frames (kMinFramesPerKey = 15, kMinCycleFrames = 60, kMaxSegments = 16) because fractional-frame keys froze the HOME Menu. Lighting rides glTF extras.polyphase.{lightDirection, lightColor, ambient} into Standalone/3DS/banner_cgfx.py, which calls pycgfx's convert_gltf / write and then sets TheLight and every material's ambient. A CGFX over 512 KB falls back to the image banner. New Scene::GetAmbientLightColor() accessor.
  • Tools. None ship with devkitPro. CiaPackager::ResolveTool looks in Preferences > External > Launchers > 3DS CIA Tools (makeromPath, bannertoolPath, cwavtoolPath, pythonPath, pycgfxPath), then the tools folder (%APPDATA%\PolyphaseEditor\Tools\3DS, ~/.config/PolyphaseEditor/Tools/3DS, ~/Library/Application Support/PolyphaseEditor/Tools/3DS), then <devkitPro>/tools/bin, then PATH. A Download button fetches makerom v0.19.0, bannertool 1.2.3 (no macOS release; build from source) and cwavtool 1.0.0; Install pycgfx pins a commit and runs pip install --user gltflib pillow (pycgfx has no licence and is never bundled). The Build Dependencies window lists makerom as optional.
  • Headless. -build <arg> now accepts a dotted target id (EngineConfig::mBuildTargetId) as well as a platform; headless mode creates the EditorUIHookManager, loads the matching build profile (target options, static content, Content Pak) and reaches PostPackage. PolyphaseEditor -headless -project MyGame/MyGame.octp -build polyphase.n3ds.cia embedded.

Content Pak Mount Stack and PS2 Hardware I/O

  • *Mount stack (beta.17).* ContentPak went from one module-level mounted archive (sPakFile / sRecords / sPathBlob) to std::vector<std::unique_ptr<PakMount>> sMounts. Mount() pushes a new top-priority layer; Find() searches newest-first so a later pak shadows an earlier one instead of evicting it (the stated use: a downloaded bundle layered over the base Content.pak). API, all POLYPHASE_API: typedef uint32_t MountHandle (0 never valid); MountHandle Mount(const char* pakPath) (was bool); MountHandle MountMemory(const void* data, uint32_t size, const char* debugName, bool takeOwnership) — a pak straight out of RAM for platforms with no writable storage for a downloaded bundle (the header names GameCube memory cards); takeOwnership means free() on unmount, so the buffer must be malloc-allocated, which Stream and HTTP bodies are, and ownership is handed back on a failed mount; void Unmount(MountHandle) (was no-arg); void UnmountAll(); IsMounted() now means "at least one mount"; FindEntry(path, MountHandle& outMount, uint32_t& outDataOffset, uint32_t& outDataSize) (signature changed); GetPakPath(MountHandle); GetMountMemory(MountHandle, const uint8_t*&, uint32_t&); List(prefix, …) merges across mounts newest-first with de-duplication so a shadowing entry wins (this is how the Vulkan global-shader enumeration from Engine/Shaders/GLSL/bin/ works in a packed build, where there is no directory to walk). Header parsing is factored into ParseAndCheckHeader() / ParsedHeader / FinishMount() shared by file and memory mounts; memory mounts bounds-check indexOffset + indexSize <= size and copy the index out before DecodeInPlace mutates it. Per-mount mMutex replaces the global sPakMutex; memory mounts skip the lock. PakMount sits behind unique_ptr so a push_back cannot invalidate a handle a SysFile still holds. Index format, FNV-1a hashing, binary search and collision-degrades-to-miss are unchanged. Source-breaking for anything calling the old Mount / Unmount / FindEntry / GetPakPath — see Deprecated.
  • SysFile (SystemUtils.cpp) gained mMemoryData / mMemorySize. SYS_FileOpenRead calls the four-arg FindEntry then GetMountMemory; a memory-mounted entry reads straight from the buffer with no fopen, a file-backed entry opens an independent FILE* on GetPakPath(handle) so seekable streaming does not fight ContentPak::Read for file position. SYS_FileRead / SYS_FileSeek have memory-backed branches (the offset is recomputed from mPayloadBase + mDecodedPos each call, since there is no OS cursor); ContentObfuscation detection and decoding work on both paths. Caveat: the memory path bounds-checks against the whole pak buffer rather than the entry, so an over-read at an entry boundary returns adjacent pak bytes instead of a short read.
  • Registry fold-in for addons. AssetManager::DiscoverAssetRegistry() and RegisterAsset() are now POLYPHASE_API, as are the AssetDir, Sha256 and Canvas classes. An addon can call DiscoverAssetRegistry once per downloaded bundle to add its stubs to the live registry (additive, never clears the lookup maps, works with EDITOR=0) and RegisterAsset a stub whose mPath is a key inside a mounted pak, resolved later through Stream::ReadFile. Pass a non-null AssetDir* or mPath is empty and the asset never persists.
  • *PS2 hardware (beta.18), ContentPak.cpp / SystemUtils.cpp.* Established on hardware; PCSX2 reproduces none of it. Every fopen / fseek / fread on PS2 is a SIF RPC through one shared, unguarded client buffer that the audio mixer thread also uses every ~2 ms; cdvdman (OPL) serves an unaligned or partial-sector request as three reads; and a plain fread of the header landed in a recycled newlib FILE buffer that came back as garbage over SMB. Symptom: not a valid pak, a scrambled index, failed asset loads. New ReadThroughAlignedBounce(FILE*, pakPath, offset, size, dst): a static 32 KB aligned bounce (kBounce = 32*1024, kSector = 2048, kAlign = 64) that rounds every request to whole sectors, slices reads so the mixer is never starved for more than one slice, takes the SIF lock per request and copies out the sub-range; header, index and ContentPak::Read all go through it. New hook macros, no-ops off PS2, implemented in the PS2 target's System_PS2.cpp: PAK_DIRECT_READ → Ps2_DiscReadRange(path, off, n, dst, got) (disc boots read with sceCdRead; false falls back to fread), PAK_IO_LOCK / PAK_IO_UNLOCK → Ps2_SifLock / Ps2_SifUnlock, PAK_IO_SYNC → Ps2_SyncDCacheRange; Mount()'s two fopen calls are bracketed by the lock. Handing the IOP an uncached 0x2000_0000 alias was tried and rejected (DMA never lands there; ps2sdk's own write-back/invalidate is enough). The index read is retried up to 3× — DecodeInPlace checksums the whole index so it doubles as an end-to-end read check — with ContentPak: '%s' index read failed (attempt %d of 3) / index decode failed (attempt %d of 3) -- re-reading warnings, and the asset read error now reads read of %u bytes at %u failed for '%s'. SYS_FileOpenRead / SYS_FileRead / SYS_FileSeek / SYS_FileClose run under new SYSFILE_IO_LOCK / SYSFILE_IO_UNLOCK / SYSFILE_IO_SYNC / SYSFILE_IO_GUARD (a SysFileIoGuard RAII struct), and SYS_FileRead reads in 8 KB slices (kIoSliceBytes = 8 * 1024) releasing the lock between them, because holding it across a whole streaming chunk starved the mixer (which needs the lock every ~2 ms to top up audsrv) and glitched audio for the duration of every read. This also removed a double rpc_packet_free of the same packet from a third caller (null-packet crash in sifrpc.c:318, _request_end).
  • AudioManager: sStreamThreadRun stays false; the comment now records the PS2 profiling behind that decision (UpdateStreamingSources() blocked the render thread ~310 ms every ~5 s; threading it fixed the hitch but produced audible glitching and doubled-up playback; the ring runs full at ≈68 rejects/s and ≈0 underruns, so the fault is in source management above the stream layer).

Android HTTP Backend (beta.17)

  • HttpBackend_Android.cpp (new; AndroidHttpBackend : public HttpBackend in an anonymous namespace, returned by CreatePlatformHttpBackend() under PLATFORM_ANDROID) drives java.net.HttpURLConnection over JNI — not OkHttp, Cronet or libcurl. Rationale in the header: the Android link list carries no libcurl or OpenSSL, so HTTPS goes through the OS stack and its auto-updated CA store, the same OS-native policy as WinHTTP on Windows and libctru httpc on 3DS. Three new public methods on PolyphaseActivity (called on system.mActivity->clazz, like iterateDirFiles / setSystemOrientation): Object[] httpOpen(String verb, String url, String[] headerLines, byte[] body, int timeoutMs, int maxRedirects, boolean verifySsl) returning { HttpConn connOrNull, Integer status, String errorCode, String errorMessage, String finalUrl, String[] headerLines }; byte[] httpReadChunk(Object conn, int maxLen); void httpClose(Object conn); plus a private static HttpConn holder (HttpURLConnection + InputStream) and installTrustAllForConnection(HttpsURLConnection). errorCode ∈ none / timeout / tls / badresponse / network / unknown, mapped by MapErrorCode() onto HttpError::Timeout / Tls / BadResponse / Network / Unknown.
  • Supported. HTTPS via HttpsURLConnection; HttpRequest::VerifySsl(false) installs a permissive X509TrustManager + HostnameVerifier on that single connection instance only, never on the process-wide SSLContext; request headers marshalled as Key: Value lines split at the first :; response headers enumerated with getHeaderFieldKey(i) / getHeaderField(i), duplicates merged with ", " in CopyHeaders() to match the libcurl backend's HeaderCb (repeated Set-Cookie); POST / PUT / PATCH bodies through getOutputStream() (bodies on other verbs are dropped); GetTimeoutMs() (default 10000) applied to both setConnectTimeout and setReadTimeout; redirects followed manually (setInstanceFollowRedirects(false), loop over 301/302/303/307/308, honours GetMaxRedirects() default 5, crosses http↔https, which the built-in follower refuses; a redirect with no Location → badresponse; the resolved conn.getURL() → HttpResponse::SetFinalUrl); getErrorStream() fallback so 4xx/5xx bodies are readable; blocking on HttpClient.cpp's worker std::thread with a ScopedJniThread RAII attach/detach per request (safe because the worker is never already attached, unlike the UI thread); body pulled in kReadChunkSize = 64 * 1024 chunks polling cancelFlag between them (HttpError::Cancelled); GetMaxBodyBytes() (64 MiB) enforced in StreamBody() (HttpError::TooLarge).
  • Not supported. Download-to-file or streaming to disk (the body accumulates in HttpResponse::MutableBody()), progress callbacks, streamed uploads (the request body is a materialised std::vector<uint8_t>), HTTP/2 or connection-reuse control, cookie jar, proxy configuration, client certificates. Cancellation granularity is one chunk; a stalled connection blocks until the read timeout. Initialize() fails (IsAvailable() false) with Android activity/JavaVM not available. outside an activity process. HttpBackend_Stub.cpp adds PLATFORM_ANDROID to its negative guard. No manifest change was needed: INTERNET and ACCESS_NETWORK_STATE were already declared.
  • VulkanContext.cpp: the VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR block is now #if PLATFORM_MAC. The constant comes from vulkan_beta.h and the NDK's vulkan_core.h does not declare it, which broke the Android compile; portabilityEnumFound was only ever set in the Mac branch anyway.

Headless Service Mode (-serve) (beta.17)

  • -serve [port] implies -headless and still requires -project <path>; ReadCommandLineArgs takes the port from the next argv when it does not start with -. New EngineConfig::mHeadlessService (false) and mServicePort (7890, matching NetworkModule); new POLYPHASE_API bool IsHeadlessService() (IsHeadless() && mHeadlessService). LoadProject's native-addon gate changed from !IsHeadless() to !IsHeadless() || IsHeadlessService() so a service loads native addons before scenes deserialize — otherwise addon node types (VideoPlayer3D, for example) are unknown and collapse to their nearest registered parent, Node3D, corrupting the scene's type layout. A one-shot -headless -build cook still skips them.
  • EditorMain gained an IsHeadlessService() branch ahead of the one-shot cook path. It creates ActionManager, BuildCache, EditorUIHookManager, PreferencesManager, NativeAddonManager and AddonManager; sets NativeAddonManager::Get()->GetEngineAPI()->editorUI = EditorUIHookManager::Get()->GetHooks() so addon-registered REST routes and node/asset categories are live; LoadProject(...) then ActionManager::CheckProjectNeedsUpgrade() / UpgradeProject() (auto-upgrade to the UUID asset format); GetEditorState()->Init() (every GFX call it reaches is IsHeadless()-guarded) and an explicit SetAssetDirectory(AssetManager::Get()->FindProjectDirectory(), false), because plain LoadProject leaves GetAssetDirectory() null and a null AssetDir* makes RegisterAsset produce an empty mPath (assets register in memory and never reach disk); ControllerServer::Create() + Start(mServicePort); a main loop of Update(), NativeAddonManager::TickAsyncBuilds(), TickEditorAllPlugins(dt), ControllerServer::Tick(), SYS_Sleep(4); orderly teardown of all six managers and Shutdown(). Net effect: an external process can drive the editor's REST API — imports, cooks, scene queries — against a project for as long as the process is up, with no interactive editor and no GPU.
  • Headless guards (the Fixed Linux addon loading commit; whitespace-ignored it is 65 added lines, every one the same class of bug — headless code driving paths that need a window, an ImGui context or a Vulkan context): EditorProgress::Begin() and DoPumpUnthrottled() early-out (ImGui::GetTime() and Renderer::Render() would fault; leaving mProgressActive false makes every other entry point inert); EditorImguiDraw() returns (ImGui::GetIO() hard-asserts with no current context); EditorState::Update() returns (Viewport3D::ShouldHandleInput asserted every frame); ThemeModule's constructor gate is mShowTheming && !IsHeadless() and LoadSettings() skips EditorTheme::ApplyTheme (ImGui::GetStyle() on a null GImGui); Engine::Update() wraps the per-world render loop in !IsHeadless() (GFX_MakePerspectiveMatrix dereferences the null VulkanContext singleton); SYS_Update() on Linux returns (xcb_poll_for_event on the null connection SYS_Initialize never created); AUD_Update() returns when sSoundDevice == nullptr (snd_pcm_avail(nullptr) hard-asserts inside libasound when there is no default ALSA device, routine on a server or an SSH session).
  • Import fixups headless. TextureImportFixupModal::Enqueue under IsHeadless() applies the non-destructive ApplyPad and logs a warning instead of queuing a row nobody will answer (the stranded row left mPixels empty and made ActionManager::ImportAsset skip its save — a silent pixel-less import); SoundWaveImportFixupModal auto-resamples to kTargetSampleRate via ApplyResample(row) and warns.
  • Exported to addons. ActionManager::OpenScene(Scene*), ImportAsset(...), ImportAssetCombined(...) and RetargetAnimation(const RetargetAnimationOptions&) are POLYPHASE_API: the per-asset importers let an addon drive a conversion pipeline without ImportScene (which ends in OpenEditScene and needs a fully initialized editor), and RetargetAnimation takes an explicit mTargetDir and never reads EditorState::GetAssetDirectory(), so it is callable from a headless cook.

Character Creator (beta.17)

  • Tools > Create > First Person Character / Third Person Character (EditorMainMenu.cpp, above the Version Control submenu; disabled with Stop play-in-editor first. / Open a project first. tooltips). New Editor/CharacterCreator/CharacterCreatorDialog.{h,cpp} (OpenCreateCharacterDialog(CharacterRigType), DrawCharacterCreatorDialogs() called once per frame from EditorImguiDraw beside DrawScriptCreatorDialogs) and CharacterRigBuilder.{h,cpp}. The dialog (Create First Person Character###CreateCharacterDialog) is a plain ImGui::Begin window rather than BeginPopupModal, because a modal blocks input to every other window and breaks AssetRefPicker autocomplete and asset-browser drag-drop. Fields: Name (prefilled FirstPersonPlayer / ThirdPersonPlayer; rejects / \ : * ? " < > |); Skeletal Mesh (Polyphase::AssetRefPicker filtered to SkeletalMesh; "Recommended" for third person, "Optional" for first); Fit mesh to height (scale the bind pose to exactly Height, off = authored scale 1); Height (m) (DragFloat 0.3–10.0, default 1.8, live readout Capsule: Height %.2f + 2 x radius %.2f caps = %.2f m total. plus Eye height %.2f m. or Camera pivot at %.2f m, camera %.2f m behind.); Script (text field + combo: kBuiltInScripts[] = { "FirstPersonController", "ThirdPersonController" } labelled (engine), a separator, then every project script from AssetManager::GetAvailableScriptFiles() de-duplicated, sorted, .lua stripped; accepts a DRAGDROP_SCRIPT payload from the Scripts panel); Create Scene asset ("reusable prefab; an instance is placed in the open scene", off = spawn directly) with Output Directory + Browse... (SYS_SelectFolderDialog; must be inside the project asset root; defaults to the asset browser's current directory; resolved absolute path shown). Escape closes; Create is disabled with the exact blocking reason.
  • What is built. CharacterRigBuilder::BuildRig(spec, outError) touches no ImGui, so the REST controller or an addon can drive it. It builds a node hierarchy, not a skeleton: <Name> Capsule3D (EnableCollision(true), EnablePhysics(false), EnableOverlaps(true), SetCollisionGroup(ColGroup0), SetCollisionMask(ColGroupAll), AddTag("Player") — a collider, not a rigid body, because the controllers move via SweepToWorldPosition + SetWorldPosition); first person: child Camera (Camera3D) at eye height, Main Camera; third person: CameraPivot (Node3D) at chest height with Camera behind it; Mesh (SkeletalMesh3D; always for third person, only when a mesh is picked for first); Controller (Node) carrying the script. ComputeMetrics(spec) → CharacterRigMetrics, shared with the dialog preview: height clamped [0.3, 10.0], radius = clamp(0.2H, 0.1, 0.6), capsuleSegment = max(H - 2r, 0.05) (Bullet's capsule height is the cylinder only), rootY = 0.5H (feet on y = 0), eyeLocalY = 0.9H - rootY, pivotLocalY = 0.75H - rootY, cameraDistance = 2.5H, followCamHeight = 1.5H (the Lua defaults of 10 / 4 assume a large demo world). Mesh placement uses the new SkeletalMesh::GetBindPoseAABB() rather than GetAABB() (which is padded by Bounds Scale and would both mis-scale and float the mesh) at -rootY - minY * scale, so feet sit on the capsule bottom for both feet-origin and centred-origin rigs; an animation named Idle becomes Default Animation. controller->SetScriptFile(...) instantiates the Lua class immediately, which populates the property list (errors: Script 'X' failed to load. It must define a table named after the file. / Script 'X' was not found in <Project>/Scripts or Engine/Scripts.); then collider and camera are set by name for both rigs, plus cameraPivot, mesh, cameraDistance, followCamHeight for third person (the controllers auto-resolve collider/camera in Start(), but explicit refs survive renames; cameraPivot / mesh are never auto-resolved).
  • Outputs. SpawnRigIntoScene(spec, parent, feetPos, outError): undo group Create Character, deep-clone via ActionManager::EXE_SpawnNode (remaps script node refs to the clone's own children), EXE_AttachNode or promote to root, select — one undo step; the rig is built and validated before the group opens so a bad script never leaves a half-made undo entry; refuses to parent inside an instanced scene (Cannot add nodes inside an instanced scene...); warns when the scene already had a main camera (The first one found wins at Play.). SaveRigAsScene(spec, dir, outError): writes <Name>.oct (uniquified via EditorAddUniqueAsset) into the AssetDir, Scene::Capture, save; refuses the engine asset directory; then, with a scene open, places an instance at the focus point under undo group Place Character (SpawnBasicNode(BASIC_SCENE, ...)), or with none open calls ActionManager::RequestOpenScene(stub) (deferred, since opening a scene must not happen inside the ImGui frame). Placement is EditorGetFocusPosition() with y forced to 0. ResolveOrCreateOutputDir(userPath, outError) creates on disk (CreateDirectoryRecursive) and in the AssetDir tree (AssetDir::CreateSubdirectory) behind a case-insensitive containment check; also ToProjectRelativeAssetPath, GetProjectAssetRoot, CharacterRigSpec { mType, mName = "Player", mHeight = 1.8f, mSkeletalMesh, mFitMeshToHeight, mScriptFile, mTagAsPlayer = true, mMainCamera = true }, enum class CharacterRigType : uint8_t { FirstPerson, ThirdPerson }.
  • SkeletalMesh. New AABB GetBindPoseAABB() const { return mAABB; }. Fix: Create() now calls ComputeBounds() when mNumVertices > 0 && mVertices.size() >= mNumVertices, before GFX_CreateSkeletalMeshResource, because LoadStream restored the bounding sphere but not mAABB, so any skeletal mesh loaded from disk reported the default unit box from GetAABB() (runtime, all platforms). Build files: Engine.vcxproj + .filters (four new files), Source/Editor/CharacterCreator added to SOURCES in both the shared-editor and static-editor branches of Makefile_Linux and Makefile_Mac, .llm/Editor.md gains a CharacterCreator/ row.

GameCube / Wii — USB Gecko Log and GDB (beta.18)

  • USB Gecko log (System_Dolphin.cpp). New static s32 sGeckoChan = -1 and InitUSBGecko(), called from SYS_Initialize() before VIDEO_Init() so boot-time logs reach the wire. It probes EXI channel 1 (memory card slot B, since the engine's save path uses slot A) then 0 with usb_isgeckoalive(). SYS_Log mirrors each line with the non-blocking usb_sendbuffer() (newline sent separately), so an unplugged cable or a closed terminal can never hang the game. The whole probe is compiled out under #if !POLYPHASE_GDB, because in GDB builds the stub owns the EXI channel and raw log text would corrupt GDB remote packets.
  • SYS_Log fixes. The PLATFORM_WII / else split (vsnprintf + SYS_Report vs SYS_Reportv) was backwards relative to its own comment. It now formats once into char logBuffer[256], bails on a negative vsnprintf return, clamps on truncation, and calls SYS_Report("%s\n", logBuffer) — the buffer as an argument, not the format string, so a log line containing a stray % is no longer re-expanded (a format-string bug). SYS_Reportv only exists in the GameCube's libogc2; the unified path works on libogc v1 (Wii) and libogc2 (GameCube).
  • GDB over Gecko. New make variable POLYPHASE_GDB=1 in Engine/Makefile_GCN, Engine/Makefile_Wii, Standalone/Makefile_GCN, Standalone/Makefile_Wii: GDB_CFLAGS = -Og -gdwarf-4 -gstrict-dwarf -DPOLYPHASE_GDB=1 (else -O2; CFLAGS changed from the hardcoded -g -O2 to -g $(GDB_CFLAGS)), GDB_LIBS = -ldb spliced into LIBS before -logc in the Standalone makefiles; the block is duplicated into the engine makefiles so building the engine library directly with the flag produces a matching archive rather than a DWARF-5 / -O2 mix (command-line variables propagate into the MakeEngine sub-make). devkitPPC pins GDB at 7.6.2, which cannot parse the DWARF-5 modern GCC emits by default, hence -gdwarf-4; -O2 inlines away most of what you would step through, hence -Og. Standalone/Source/Main.cpp OctPreInitialize() calls DEBUG_Init(GDBSTUB_DEVICE_USB, POLYPHASE_GDB_CHANNEL) (new define, default 1 = slot B) followed by _break() under #if PLATFORM_DOLPHIN && POLYPHASE_GDB — the earliest hook the game owns, before ReadEngineConfig and Initialize(), so a breakpoint set from GDB can catch engine startup. Usage: make -f Makefile_Wii clean && make -f Makefile_Wii POLYPHASE_GDB=1, powerpc-eabi-gdb Build/Wii/Polyphase.elf, target remote \\.\COM4 (or /dev/ttyACM0 on a CDC clone). Objects carry no dependency on the flag, so make clean when toggling; libogc2 itself needs the same two DWARF flags for its own frames to resolve.

Addons Manager — Update, Remove, Preflight

  • Deferred pipeline. Buttons only queue ids into EditorState::mPendingAddonInstalls / mPendingAddonUninstalls / mCheckAddonUpdatesAtEndOfFrame; EditorMain drains via AddonsWindow::ProcessPendingInstalls / ProcessPendingUninstalls / ProcessPendingUpdateCheck under EditorProgress, because a synchronous curl inside an ImGui frame froze the editor with no feedback.
  • Update tracking. New AddonInstallSource and AddonUpdateStatus (NotChecked / UpToDate / NewCommits / NewVersion / Pinned / NoSource / Error); Addon::mBranch (default main); InstalledAddon gains mBranch, mCommit, mCommitDate, mPinned, mIsStandalone. AddonManager::FetchLatestCommit() queries https://api.github.com/repos/<owner>/<repo>/commits?sha=<branch>&per_page=1[&path=<id>] (10 s timeout, memoised); CheckForUpdates(outSummary) refreshes registries first; GetUpdateStatus() combines registry version and mUpdated stamp with the branch-head SHA as primary, with a date fallback for pre-tracking installs. DownloadAddon records the branch head before fetching the archive so the recorded commit is never newer than the files; 40-hex refs are marked mPinned. UninstallAddon() also removes Intermediate/Plugins/<id> and warns on locked files.
  • UI. Check Updates (Browse), Check for Updates + Update All (N) (Installed), Remove on Browse rows, cards and details; DrawUpdateStatusLabel ("New commits", "New version", "Up to date", "Check failed", "Pinned", "No source", "Not checked"). The Update Addon modal warns that local edits are lost and flags folders that are their own git repo. The Remove Addon modal lists transitive installed dependents (removed first) and local-only dependents (left in place), notes when a project restart is needed, and is blocked during play-in-editor.
  • Dependencies. AddonDependencyResolver::ReadPackageVersion / ListPackageIds / CollectDependents (reverse-graph post-order DFS, cycle-safe); a registry miss at project load triggers a one-time RefreshAllRepositories() before declaring a dependency missing. NativeAddonState::mMissingDependencies / mFixHint / mFixUrl; RefreshMissingDependencies() blocks build and load until deps exist; DiscoverAddonPackage(id), ForgetAddon(), RemoveNativeAddonsWithProjectRestart() (unload → forget → UninstallAddon inside the close window, never rebuilds).
  • Build preflight. RunBuildPreflight() checks deps on disk, engine include dirs, Vulkan headers and (Windows) Polyphase.lib / PolyphaseEditor.lib + Lua.lib; ClassifyBuildFailure() maps compiler output to hints. ResolveVulkanIncludeDir() probes VULKAN_SDK, <engine>/External/Vulkan/include, C:/VulkanSDK/*/Include, /usr/include, ~/VulkanSDK/*/{x86_64,macOS}/include and Homebrew; generated build.bat / build.sh / VS / VS Code / CMake configs use the resolved path (CMake emits FATAL_ERROR when none is found). The problem modal is now Native Addon Problem, with a "How to fix" section, Open download page, Build Dependencies and per-dependency Install <id> buttons. Shadow-copy delete retries are paced (kRetryIntervalSec = 5, kMaxRetries = 6).
  • Distribution. stage_distribution.py stages Vulkan headers (vulkan, vk_video, LICENSE) from VULKAN_SDK and the prebuilt External/mbedtls/lib-wii and lib-gcn archives (fixes cannot find -lmbedtls in installed Wii/GameCube packaging); release.yml fails if the staged vulkan.h is missing; package_windows_sdk.py ships the headers in the SDK zip; External/Vulkan/ gitignored.

Import and Editor QOL

  • SoundWaveImportFixupModal (Sound Import - Non-standard Sample Rate): per-row or bulk Resample to 44100 Hz, Keep, Cancel Import; triggered when the rate is neither 44100 nor 22050 because Android hard-locks 44.1 kHz and console cooks assume it. Backed by new AUD_ResamplePcm(src, srcSize, bits, channels, srcRate, dstRate, outPcm, outNumSamples) (AudioResample.h/.cpp, linear, 8-bit unsigned / 16-bit signed, 1–2 channels). SoundWave::ParseWav / WavInfo shared by Import and LoadFromMemory; new GetNumFrames(); the inspector shows duration, PCM size and a non-standard-rate warning.

  • Batched imports. ActionManager::RequestImportAssets() / ImportPendingAssets() run non-mesh imports at end of frame under a cancellable Importing Assets modal (file dialog and FileDropImportModal). EditorProgress::SetStatus calls added across addon download, extract, install, registry refresh, dependency fetch and asset save; fixup modals and the Asset Name Clash popup wait while progress is active. Scene::Instantiate reports progress only for the outermost scene.

  • Loose-file multi-select in the asset browser (Ctrl/Shift), type-coloured rows, Packaging (N files) / Delete (N files) context actions; the inspector applies flags to all selected.

  • InputMap::SetKeyboardEmulationEnabled / IsKeyboardEmulationEnabled (default true, persisted as keyboardEmulationEnabled) with a Keyboard -> Gamepad Emulation Enabled checkbox in the Input Map window; gates all keyboard → gamepad button and axis emulation.

  • BuildDependencyWindow adds CheckMakerom(), CheckVulkanSDK(), and validates that DEVKITPPC / DEVKITARM point at a folder containing wii_rules / 3ds_rules (cygpath-converted on Windows); GetDevkitproPath() falls back to /opt/devkitpro. LaunchersModule::BuildAdbLogcatCommand gains an osascript Terminal.app variant.

  • *Logging toggle is live (beta.18).* EngineConfig::mLogging was only copied into the log gate at InitializeLog(), so the App Settings > Runtime > Logging checkbox took effect at the next editor launch and a project's own Config.ini setting was ignored for the whole session (the one sync at engine Initialize() runs before any project is open). AppSettingsWindow::DrawRuntimeSection now calls EnableLog(config->mLogging) on change, and LoadProject calls EnableLog(sEngineConfig.mLogging) right after ReadEngineConfig() (#if EDITOR, skipped when IsHeadless() so cooks keep logging). LoadProject also logs Loading project '%s'.

  • *Scene tab title after duplicating a scene file (beta.18).* An asset copied or renamed outside the editor keeps its old serialized Name, so anything showing Asset::GetName() — the scene tab — showed the original. AssetManager::LoadAsset now calls stub.mAsset->SetName(stub.mName) after EnsureUuid() when the loaded name differs from the stub (#if EDITOR).

C# Scripting (PolyphaseSharp)

  • Gameplay scripts can now be written in C#. Nothing changes at runtime: C# sources are transpiled ahead of time to ordinary engine Lua, so scripts run on the same Lua 5.3 (LUA_32BITS) VM the engine already ships on every platform — desktop, Android, Web, PS2, PSP, 3DS, Wii/GameCube, Dreamcast — with no .NET runtime in the shipped game. The .NET SDK (8+) is an authoring-time dependency only; generated .lua files sit next to the sources, so teammates who never touch C# never need dotnet. New tool: Tools/PolyphaseSharp/ (polyphasesharp, net8.0, InvariantGlobalization).
    • Pipeline. Scripts/CSharp/Foo.cs plus the engine API sources from Tools/PolyphaseSharp/Polyphase.Engine are compiled by Roslyn (Microsoft.CodeAnalysis.CSharp 5.6.0) in Analyzer.Compile; CS* errors surface first, then the analyzer discovers script classes, [Property] fields, [Button] methods, overridden lifecycle methods and public methods, and runs subset lints. SourceRewriter turns each [Property] field into an extern property whose @CSharpLua.Get/Set templates read and write {this}.__node.<Name> (the node uservalue is the single source of truth) and wraps namespace-less files in the default namespace Game so CSharp.lua's global publishing cannot collide with engine script tables. The rewritten sources go through the vendored CSharpLua.Compiler; WrapperEmitter then produces the final per-file .lua: a -- ~PolyphaseSharp~ marker line, Script.Require("CSharp/CSharpCore") plus a Script.Require per cross-file class dependency, the module body, CSharpCore.Finalize({...}) in inheritance-depth order, and the engine wrapper table.
    • Runtime bundle. CoreSystemBundler writes one Scripts/CSharp/CSharpCore.lua: the CSharp.lua CoreSystem BCL runtime (comment-stripped by LuaMinifier, hot-reload guarded by __POLYPHASE_CSHARP_CORE__), the transpiled Polyphase API module, and the glue CSharpCore.New(cls, node) / CSharpCore.Finalize(ordered). The bundle is trimmed to modules whose usage tokens appear in the generated code (a kDroppable table with KeptBy dependencies); dropped modules are logged. Orphaned generated files (marker present, no longer produced) are deleted; hand-written Lua is never touched.
    • Authoring. Derive from Polyphase.Script (any node) or Polyphase.Script3D (transform nodes). File name must equal the class name; one script class per file. Lifecycle overrides: Create (before serialized props apply), Awake, Start, Tick(float), EditorTick(float), Stop, Destroy, BeginOverlap(Node), EndOverlap(Node), OnCollision(Node, Vector3, Vector3). Bare calls (AddRotation, Position, Name, AddTag) act on the attached node, like self: in Lua; Node returns the handle (typed Node3D on Script3D), World the owning world. The generated file emits Rotator = {} and function Rotator:Create() which applies property defaults then constructs the companion via self.__cs = CSharpCore.New(Game.Rotator, self); only the lifecycle methods the class actually overrides get forwarders, and overlap/collision forwarders drop thisNode. [Property] / [Button] members produce a GatherProperties() returning { name, type = DatumType.X, display_name, tooltip } entries ([Button] → DatumType.Function). Every public, non-overloaded instance method also gets a wrapper forwarder so Lua or other C# can call node:Method(args); overloads emit warning PS2003 and no slot. Statics and singletons work; cross-file references get automatic Script.Require.
    • Escape hatch. Lua.GetGlobal / SetGlobal / Get / Set / Call(target, method, up to 6 args) / As<T>. Typed façades are Node3D subclasses with extern members carrying /// @CSharpLua.Template comments.
    • Engine API surface (Polyphase.Engine, namespace Polyphase, netstandard2.1, extern-template only): Script, Script3D (lifecycle plus Name, Position/Rotation/Scale and World* variants, AddRotation, AddWorldRotation, RotateAround, LookAt, GetForward/Right/UpVector, FindChild, tags, EnableTick, DestroyNode, EmitSignal); Node, Node3D, Primitive3D (SweepToWorldPosition, EnableOverlaps/Collision/Physics), Camera3D (FieldOfView), SkeletalMesh3D (PlayAnimation, StopAnimation, StopAllAnimations, IsAnimationPlaying, QueueAnimation), HitResult, World (GetRootNode, RayTest, FindNode); Vector3 (maps to engine Vec, reference semantics, operators, Dot/Cross/Lerp/Rotate/Distance, Clone, Normalize), Color; Input, Key, Gamepad (constants read from Lua tables at runtime, never baked), Mathf, Log, Lua; attributes PropertyAttribute (Display) and ButtonAttribute (Title, Tooltip).
    • CLI. polyphasesharp --scripts <Project>/Scripts/CSharp [--scripts-root <dir>] [--lua-roots <a;b>] [--check] [--no-trim] [--verbose]. Exit codes 0 ok, 1 compile/validation error, 2 usage. Diagnostics are file(line,col): error|warning CODE: msg. Codes: PS0001 API sources missing; PS1001 file/class name mismatch; PS1002 multiple script classes; PS1003 generic script; PS1004 constructor; PS1005 unsupported [Property] type; PS1006 non-literal initializer; PS1007 name collides with hand-written Lua (checked across --lua-roots, i.e. Engine/Scripts and addon Packages/*/Scripts); PS1008/PS1009 property field shape; PS1010 duplicate basenames; PS1011 non-identifier basename; PS1012 bad [Button] signature; warnings PS2001 (long/ulong/decimal), PS2002 (async), PS2003 (overloads).
    • Vendored dependency. Tools/PolyphaseSharp/External/CSharp.lua/ (83 files) is yanghuan/CSharp.lua pinned at 6dcc5b155… (2026-07-29), Apache-2.0 — the compiler library plus CoreSystem.Lua; upstream launcher, tests and docker are not vendored. VENDOR.md records two mandatory local patches (marked POLYPHASE PATCH): LuaSyntaxNodeTransform.cs VisitPropertyOrEventIdentifierName binds the implicit this receiver for property templates eagerly (upstream NRE'd in LuaRenderer.Render on bare Prop access, which the entire bare-call API depends on); CoreSystem/Core.lua adds System.getRegisteredModuleNames() so CSharpCore.Finalize() can System.init only the types one generated file registered, which per-file hot reload requires.
    • Limitations (v1). 32-bit Lua VM: double is float; long/ulong/decimal are unrepresentable. No threads; async has no scheduler. No reflection. [Property] types are int, short, byte, float, double, bool, string, Vector3, Color, Node, Node3D and Node-derived; initializers must be literals or new Vector3/Color(<literals>); no arrays. Binary NuGet packages are unsupported by design (IL cannot run) — vendor source instead. Debugging is at generated-Lua level; no C# source maps. Hot-reloading CSharpCore.lua itself requires a game restart.
    • Tests. Tests/run_tests.ps1 builds the tool, copies Tests/Sample/Scripts/CSharp/Rotator.cs into Tests/work/, transpiles it, builds a Lua 5.3 interpreter once from the engine's own External/Lua (with Tests/lua_main.c as driver) so LUA_32BITS semantics match shipping platforms, and runs Tests/harness.lua, which stubs DatumType, Log, Vec and Script.Require, fakes a node with Rotator as its metatable, and asserts Create seeds __cs and defaults, GatherProperties yields two typed entries, three Tick(0.25) calls accumulate 67.5° of rotation, toggling Enabled through the uservalue stops it, and re-running the chunk (hot-reload simulation) works. The interpreter build is Windows/MSVC-only (vcvars64.bat); the tool itself is portable.
  • Editor integration — CSharpManager (new Engine/Source/Editor/CSharp/CSharpManager.{h,cpp}, singleton CSharpManager::Get()).
    • EnableForProject() sets mCSharpScripting = true and calls WriteEngineConfig() itself (so the flag persists even if the settings window is closed without Save), scaffolds <Project>/Scripts/CSharp/ via EnsureWorkspaceFiles(), drops a sample SpinnerCS.cs (a Script3D with a [Property] Vector3 AngularVelocity = (0, 90, 0)) only into an empty folder — deliberately not named Rotator because script class names are global and Engine/Scripts/Rotator.lua already owns that name — and force-reprobes dotnet, logging winget install Microsoft.DotNet.SDK.8 / sudo apt install dotnet-sdk-8.0 guidance when missing.
    • EnsureWorkspaceFiles() writes Game.csproj (netstandard2.1, LangVersion latest, Nullable disable, obj/bin redirected to ../../Intermediate/CSharp/, NoWarn CS0626;CS0824;CS0169;CS0649, engine API source-included via a <Compile Include=".../Tools/PolyphaseSharp/Polyphase.Engine/Polyphase/**/*.cs" Link="EngineApi/..."/> so no prebuilt DLL is required) and a fixed-GUID single-project Game.sln. Idempotent — projects enabled by older builds pick up Game.sln retroactively on OpenIde(). LoadProject rewrites the POLYPHASE_ENGINE_API-tagged <Compile Include> line so IntelliSense survives an engine or project move.
    • CheckDotnet() runs dotnet --version, requires major ≥ 8, and caches the result. EnsureToolBuilt() runs dotnet build PolyphaseSharp.csproj -c Release --nologo -v q only when the newest .cs/.csproj/.lua/.xml under the tool root is newer than bin/Release/net8.0/polyphasesharp.dll. Transpile(checkOnly, outLog) invokes the tool with --scripts <Scripts/CSharp> --lua-roots "<Engine>/Engine/Scripts;<Project>/Packages" so a C# class shadowing an engine or addon Lua script fails the transpile instead of silently losing the duplicate at packaging time; output routes to LogError / LogWarning / LogDebug by line.
    • UI. Tools > CSharp menu (project loaded only): Build C# Scripts (Ctrl+R; transpile then RequestReloadAllScripts() + InvalidateScriptPickerCache()), Open C# Solution, Check Dependencies, and Enable C# for This Project when not yet enabled. App Settings > Runtime: C# Scripting checkbox; when enabled but dotnet is missing shows ".NET SDK 8+ not found" with an Install button (Windows runs the winget command detached; Linux xdg-opens the download URL). Disabling only stops future transpiles; generated .lua stay in place. Create Project dialog: Scripting: radio (Lua / C#, mProjectType 0 / 2); the old C++ type (1) stays hidden. Build Dependency window: new .NET SDK check ("Required only for C# scripting (8.0 or newer)").
    • IDE preference. Preferences > External > Editors gains a "C# IDE" section: EditorsModule::CSharpIde enum (VisualStudio = 0 default, VSCode = 1, Custom = 2), persisted as csharpIde, csharpIdePath, csharpIdeArgs (default {editor} {folder}; placeholders {editor}, {solution}, {project}, {folder}). OpenCSharpWorkspace(): VS Code opens the folder via the code CLI (a .csproj opened as a file gives no IntelliSense); Visual Studio resolves devenv.exe once via External\vswhere\vswhere.exe -latest -property productPath and launches it with SYS_SpawnDetachedExecutable — explicitly not start "" Game.sln, because the file association frequently points at VS Code on machines that have it; falls back to the OS association with a warning if vswhere returns nothing.
    • Scripts panel. A generated .lua with a sibling .cs (HasSiblingCSharpSource, computed on refresh not per frame) shows as one merged row tagged [C#]; its context menu gains Open C# Source and relabels the Lua entries Open Generated Lua in External Editor / Script Editor. Ctrl+R (Edit_ReloadScripts) transpiles first when mCSharpScripting is set.
    • Auto-transpile on save. EngineConfig::mCSharpScripting (default false, persisted as CSharpScripting=). FileWatcher::WatchDirectory(dir, recursive=true, extensions={".lua"}) gains an extension filter (empty = all files). With C# on, LoadProject watches Scripts/ for {".lua", ".cs"}; any .cs event (including deletes) schedules a debounced transpile (400 ms) serviced in Engine::Update() (non-headless only). The generated .lua writes then hot-reload through the normal per-file path.
    • Packaging. BuildPhase1 transpiles before script gathering so generated .lua flow through the embedded and loose-copy paths; a failed transpile aborts the build. .cs/.csproj are stripped from the packaged Scripts/ tree (mirrors the LuaPanda strip). BuildCache::GatherScriptFiles adds .cs files (skipping obj//bin/) as ordinary FileEntry rows so editing a .cs invalidates the cache without a manifest format change.
    • Makefile_Linux: Source/Editor/CSharp added to SOURCES for both the shared-library PolyphaseEditor and static EngineEditor targets.

Networking — WebSocket Client

  • New WebSocket client subsystem (RFC 6455, client-only), a per-frame-pumped, callback-or-polling client parallel to Http::. All new files live flat in Engine/Source/Network/ on purpose: a new subdirectory would have to be added to every engine console Makefile and to every already-shipped build-target package Makefile; flat files are picked up automatically everywhere except Engine.vcxproj (updated). Files: WebSocketTypes.h, WebSocketClient.h/.cpp, WebSocketFraming.h/.cpp, WebSocketHandshake.h/.cpp, WebSocketTransport.h (internal), WebSocketWss_Windows.cpp, WebSocketWss_Linux.cpp, LuaBindings/WebSocket_Lua.h/.cpp.
    • C++ API. namespace WebSocket { Initialize(); Shutdown(); Tick(); bool IsAvailable(); const char* GetMissingDependencyMessage(); std::shared_ptr<WsConnection> Connect(const WsConnectOptions&, std::string& outError); }. Abstract WsConnection: GetState(), SendText(const char*, uint32_t), SendBinary(const uint8_t*, uint32_t), Close(uint16_t code, const char* reason), SetOpenCallback / SetMessageCallback / SetErrorCallback / SetClosedCallback, GetAvailablePacketCount(), TakePacket(WsMessage&), GetSelectedProtocol(), GetCloseCode(), GetCloseReason(), WasDowngraded(). Callback aliases WsOpenCallback = void(), WsMessageCallback = void(const uint8_t*, uint32_t, bool binary), WsErrorCallback = void(const char*), WsClosedCallback = void(uint16_t, const char*, bool wasClean).
    • Types. enum class WsState : uint8_t { Connecting=0, Open=1, Closing=2, Closed=3 } (matches browser readyState); enum class WsError { None, BadUrl, NoTransport, Dns, Connect, Handshake, Protocol, TooLarge, Transport } + WsErrorToString(); WsUrl, WsConnectOptions { mUrl, mProtocols, mHeaders, mMaxQueuedBytes }, WsMessage { mData, mBinary }. Constants: WS_CLOSE_NORMAL 1000, WS_CLOSE_GOING_AWAY 1001, WS_CLOSE_PROTOCOL_ERROR 1002, WS_CLOSE_TOO_LARGE 1009, WS_CLOSE_ABNORMAL 1006 (synthesized locally, never on the wire), WS_DEFAULT_MAX_QUEUED_BYTES 1 MiB, WS_MAX_MESSAGE_BYTES 16 MiB, WS_RECV_CHUNK_SIZE 8192, WS_CONNECT_TIMEOUT_SECONDS 15, WS_CLOSE_GRACE_SECONDS 5 (peer never answers our Close → dropped, wasClean=false).
    • Three-arm compile seam. #if !defined(POLYPHASE_PLATFORM_ADDON) real implementation; #elif defined(POLYPHASE_WS_PROVIDED_BY_ADDON) nothing (the build-target package supplies the whole WebSocket:: namespace and its own WsConnection subclass, e.g. the web packages' Runtime/Web/Ws_Web.cpp); #else self-contained stubs (IsAvailable() → false, Connect() → null, "WebSocket is not supported on this platform."). Keying the middle arm on an opt-in define rather than PLATFORM_WEB means every pre-existing build-target package (PSP, Dreamcast, …) keeps linking with zero edits.
    • Two transport flavours (WebSocketTransport.h). Byte transports (ws:// over NET_ stream sockets) hand raw bytes up and the engine runs handshake and framing; message transports (WinHTTP, libcurl, browser) own handshake/framing and exchange whole messages (SendWholeMessage — deliberately not SendMessage because <windows.h> macro-expands it). Per-platform secure hooks: WsIsSecureSupported(), WsSecureUnavailableMessage(), WsCreateSecureTransport().
    • Platform coverage. Windows: ws + wss (WinHTTP WebSocket API, Windows 8+; receive and send worker threads push into a mutex-protected handoff queue drained by Tick(); links winhttp.lib). Linux: ws + wss when libcurl ≥ 7.86 is present (dlopen'd libcurl.so.4 / .so / -gnutls.so.4 / .so.3, CURLOPT_CONNECT_ONLY=2, curl_ws_recv/curl_ws_send; only the handshake blocks, on a one-shot joined thread). Android, 3DS (soc:u, FIONBIO), GameCube/Wii (libogc/libogc2): ws only. Web (web.webgl2/web.webgpu): supplied by the package via POLYPHASE_WS_PROVIDED_BY_ADDON. Other addon targets: stub arm. The core has no exceptions, RTTI or threads (consoles build -fno-exceptions -fno-rtti); only the two desktop wss backends thread, and nothing off the main thread touches Lua.
    • Framing. WsEncodeFrame (always FIN, always client-masked with a fresh xorshift32 key; 7/16/64-bit lengths), WsEncodeClose (reason capped at 123 bytes), incremental WsDecoder (Feed, Next(Event&), SetMaxMessageBytes; rejects RSV bits, fragmented or oversized control frames, high-bit lengths, stray continuations, unknown opcodes; reassembles fragments). Self-contained WsSha1() and WsBase64Encode() so no crypto dependency is dragged onto console toolchains.
    • Handshake. WsParseUrl() (ws/wss only, default ports 80/443, fragment stripped, rejects userinfo @ and IPv6 literals), WsBuildHandshakeRequest() (HTTP/1.1 GET upgrade, Sec-WebSocket-Version: 13, CR/LF header-injection guard), WsParseHandshakeResponse() → WsHandshakeResult { NeedMoreData, Ok, Failed } (requires 101, Upgrade: websocket, Connection containing upgrade, exact Sec-WebSocket-Accept; refuses any Sec-WebSocket-Extensions; 32 KiB header cap).
    • State machine. Ordering is always Open → Message* → Closed; a failure before Open yields Error → Closed(1006, "", false). Ping is answered inside the transport and never surfaced. The incoming queue is only used when no message callback is set; overflow in either direction logs once per connection and drops. FinishClosed() releases all callbacks, breaking the registry→closure→userdata→connection cycle Lua's GC cannot collect. WebSocket::Tick() locks and compacts weak refs before pumping so a callback calling WebSocket.Connect is safe; Shutdown() closes live connections with 1001 "Engine shutting down".
    • Engine wiring. WebSocket::Initialize() after Http::Initialize(), WebSocket::Tick() under SCOPED_FRAME_STAT("WebSocketTick") after Http::Tick(), WebSocket::Shutdown() before NET_Shutdown(). WebSocket_Lua::Bind() registered in LuaBindings.cpp.
    • New NET_ primitives (Network.h, all five backends: Windows/Linux/Android/3DS/Dolphin). NET_SocketConnectAsync(SocketHandle, ip, port) (leaves the socket non-blocking), NET_SocketConnectPoll(SocketHandle) (-1 fail / 0 pending / 1 connected via select + SO_ERROR; Wii uses net_poll POLLOUT, GameCube uses net_select because libogc2 declares but never links net_poll), NET_SocketWouldBlock(SocketHandle, opResult) (takes the returned value because libogc reports errors as negated errno in the return rather than in errno). Purely additive; addon-target Network_*.cpp stubs need no new symbols.
    • wss→ws auto-downgrade, per-build-target opt-in. Compile define POLYPHASE_WS_DOWNGRADE_WSS=1 rewrites wss:// to ws://, maps default port 443→80 (explicit port kept), logs a warning once per connection, and WasDowngraded() reports true. Default off everywhere; never for web targets (mixed content).
    • Lua API (global WebSocket, metatable Polyphase.WsConnection). WebSocket.Connect(url [, options]) → conn | nil, err with options { protocols = {...}, headers = {name=value}, maxQueuedBytes = n }; WebSocket.IsAvailable(); WebSocket.GetMissingDependencyMessage(). Connection: SetOpenCallback(fn()), SetMessageCallback(fn(data, isBinary)), SetErrorCallback(fn(message)), SetClosedCallback(fn(code, reason, wasClean)) (nil clears), SendText(str) → bool, SendBinary(str) → bool, Close([code=1000 [, reason=""]]), GetState(), GetAvailablePacketCount(), GetPacket() → data, isBinary | nil, GetSelectedProtocol(), GetCloseCode(), GetCloseReason(), WasDowngraded(). Global enum WebSocketState = { Connecting=0, Open=1, Closing=2, Closed=3 }. Userdata holds a shared_ptr<WsConnection>; __gc clears callbacks then Close(1001) — dropping the handle closes the connection, matching Godot's WebSocketPeer. Callback errors are lua_pcall-caught and logged as "WebSocket callback error".
    • Known limitations. Client only; no server, no permessage-deflate, no HTTP/2, no IPv6 literals, no user:pass@ URLs; outgoing messages are never fragmented; text frames are not UTF-8 validated. DNS (NET_ResolveHost) is synchronous on every platform, so Connect with a hostname stalls — use a numeric IP or connect at menu/load time. Dolphin: libogc's net_connect may still block if IOS ignores FIONBIO. Linux wss: libcurl exposes no negotiated-subprotocol API in CONNECT_ONLY mode, so GetSelectedProtocol() echoes the first offered protocol; the handshake thread cannot be aborted (bounded by the 15 s connect timeout). The headers option is native-only; web builds warn and ignore it.
  • Tools/ws-echo/ — dependency-free Node ≥ 16 echo server (node:http + node:crypto only; framing adapted from the WebGL2 target's relay). node Tools/ws-echo/echo.js [--host 127.0.0.1] [--port 9002] [-v]. Echoes text and binary byte-identically (including fragmented), answers Ping with Pong, negotiates subprotocol echo.v1, closes with 4000 "requested" on text close-me, and rejects unmasked client frames. Pairs with Scripts/WsEchoTest.lua in the WebSocket test project (logs WS-TEST PASS / WS-TEST FAIL <step>). Not a production server.

Animation

  • Multi-section SkeletalMesh — per-part material slots. New SkeletalMeshSection { mName, mFirstIndex, mIndexCount, mBaseVertex, mVertexCount, mMaterial } and std::vector<SkeletalMeshSection> mSections on the asset. Accessor surface: GetNumSections, GetSection, GetSectionMutable, GetSectionMaterial, SetSectionMaterial, FindSectionIndex, GetSections. Importing with MeshImportMode::AsSingleObject produces one section per source primitive. ASSET_VERSION_SKELETAL_MESH_SECTIONS = 36; assets below that version load with one implicit "Default" section spanning the whole index buffer, so the backend draw loop stays unconditional.
    • Per-instance overrides. SkeletalMesh3D gains GetNumMaterialSlots, GetMaterialSlot, SetMaterialSlot, FindMaterialSlot, backed by a new "Section Material Overrides" vector property filtered to Material. Resolution order: component override, then the section's own material, then the legacy whole-mesh mMaterialOverride, then the mesh default, then the renderer default.
    • Lua. On SkeletalMesh: GetNumSections, GetSectionName, GetSectionMaterial, SetSectionMaterial, FindSectionIndex. On SkeletalMesh3D: GetNumMaterialSlots, GetMaterialSlot, SetMaterialSlot, FindMaterialSlot. Slot arguments are Lua-1-indexed; GetMaterialSlot and SetMaterialSlot accept either an integer slot or a section-name string; FindMaterialSlot and FindSectionIndex return 0 when not found.
    • Backends. One indexed draw per section on Vulkan, GX (Wii and GameCube), and C3D (3DS). The Vulkan shadow pass still issues a single whole-mesh depth draw. C3D requires per-section BindMaterial before the matrix/UV/color-scale uniforms — the reverse order leaves the first section drawing on stale state.
    • Inspector. The SkeletalMesh asset inspector gains a Sections (%u) block listing [i] <name> (<n> tris) with a per-row material picker, a Bones (%zu) count, and a collapsible bone-hierarchy tree.
  • SkeletalAnimationAsset — standalone reusable clips. Animation clips are no longer welded to the mesh they were imported with. Channels are keyed by bone name rather than bone index, so one clip binds to multiple rigs. SkeletalAnimationChannel { mBoneName, mSourceBoneIndex, mPositionKeys, mRotationKeys, mScaleKeys }. Surface: GetClipName, GetDuration, GetTicksPerSecond, GetDurationSeconds, GetChannels, GetEventTracks, GetSourceRigName, GetSourceBoneNames, GetSourceParentIndices, GetSourceBindPose, CopyFromEmbedded. New files: SkeletalAnimationAsset.cpp/h, SkeletalAnimationAsset_Lua.cpp/h.
    • Extract. Right-click any SkeletalMesh stub → Extract Animations... opens a modal with Prefix (default SA_), Overwrite existing, Unique names, and Extract Selected / Extract All. Backed by ActionManager::BeginExtractSkeletalAnimations.
    • Import. Asset browser → Import Asset → Import Animations reads animation-only .fbx/.glb/.gltf/.dae files that carry no render mesh, one SkeletalAnimationAsset per clip. Backed by ActionManager::BeginImportAnimations.
    • Playback. SkeletalMesh3D gains an "Animation Assets" vector property. FindAnimation resolves embedded animations first, then the mAnimationLookupMesh chain, then external assets, with lazy per-(asset, mesh) bone-index binding cached in mBoundExternalAnims and invalidated by InvalidateAnimationBindings.
    • Lua (read-only): GetClipName, GetDuration, GetDurationSeconds, GetTicksPerSecond, GetSourceRigName, GetNumChannels, GetChannelBoneName. There is no Lua API for the asset list itself; it is edited through the inspector.
  • HumanoidAvatarAsset and bake-time retargeting. enum class HumanoidBone : uint8_t with 21 slots (Hips, Spine, Chest, Neck, Head, LeftShoulder, LeftUpperArm, LeftLowerArm, LeftHand, RightShoulder, RightUpperArm, RightLowerArm, RightHand, LeftUpperLeg, LeftLowerLeg, LeftFoot, LeftToes, RightUpperLeg, RightLowerLeg, RightFoot, RightToes) plus HumanoidBoneName. Asset surface: GetReferenceMesh, SetReferenceMesh, GetBoneName, SetBoneName, AutoMap, Validate, GetReferenceLocalBind. New files: HumanoidAvatarAsset.cpp/h. No Lua bindings — editor and bake-time only.
    • Auto-map. NormalizeBoneName strips ' ', '_', '-', '.', ':' and the mixamorig, def and mch prefixes, covering Mixamo, Auto-Rig Pro, Rigify, and Blender .L/.R suffix conventions.
    • Retarget. Right-click a SkeletalAnimationAsset → Retarget.... RetargetMode::NameRemap (UI: Tier 1 - Name remap (passthrough)) rewrites channel bone names only. RetargetMode::ReferencePose (UI: Tier 2 - Reference-pose aware) computes worldDelta = sourceWorldRot * inverse(sourceWorldBindRot), targetWorld = worldDelta * targetWorldBindRot, targetLocal = inverse(targetParentWorldRot) * targetWorld — bone-axis conventions drop out because everything is computed in a common frame. Hips translation is scaled by the bind-pose Y ratio. No IK end-effector solving, no twist-bone distribution, no synthesis for missing intermediate joints.
  • BoneMaskAsset and layered animation. Eight simultaneous animation slots per SkeletalMesh3D (NUM_ANIMATION_SLOTS = 8), each with independent weight, speed, bone mask, and layer mode. enum class AnimLayerMode { Replace, Additive, Count }. ASSET_VERSION_BONE_MASK = 37. New files: BoneMaskAsset.cpp/h, BoneMaskAsset_Lua.cpp/h, BoneMaskEditor.cpp/h (~1000 LOC).
    • Mask asset. Name-keyed include roots, exclude roots, and a self-only flag, resolved lazily against whatever mesh queries them with a one-entry cache. Surface: GetTargetMesh, SetTargetMesh, GetIncludeRoots, GetExcludeRoots, GetSelfOnly, AddIncludeRoot, RemoveIncludeRoot, AddExcludeRoot, RemoveExcludeRoot, Resolve, InvalidateCache. Because masks are name-keyed, the same asset drives any rig sharing those bone names.
    • Node API. PlayAnimationMasked, PlayAnimationAdditive, SetSlotMask, SetSlotLayerMode, SetSlotWeight, FadeSlotWeight. ActiveAnimation gains mBoneMask, mWeightTarget, mWeightFadeRate, mLayerMode.
    • Blend math. Replace slots glm::mix position and scale and glm::slerp rotation by slot weight. Additive slots accumulate (clip - bind) * weight for position and scale, and slerp(identity, clipRot * inverse(bindRot), weight) for rotation. All of it runs on decomposed local TRS before the parent-chain walk, so every backend — Vulkan, GX, C3D, and addon targets — works with no per-platform code.
    • Supporting SkeletalMesh additions. GetBindPosePos, GetBindPoseRot, GetBindPoseScale (decomposed local-bind TRS cached in InitBindPose), GatherDescendants, GatherSubtreeBoneSet.
    • Lua. On SkeletalMesh3D: PlayAnimationMasked, PlayAnimationAdditive, SetSlotMask, SetSlotWeight, SetSlotLayerMode, FadeSlotWeight. On BoneMaskAsset: GetTargetMesh, SetTargetMesh, GetSelfOnly, SetSelfOnly, GetNumIncludeRoots, GetIncludeRoot, AddIncludeRoot, RemoveIncludeRoot, GetNumExcludeRoots, GetExcludeRoot, AddExcludeRoot, RemoveExcludeRoot. New global enum table AnimLayerMode with Replace and Additive.
    • Bone Mask Editor. New dock under View > Bone Mask Editor, also reachable via right-click a BoneMaskAsset → Edit Bone Mask. Owns its own preview World, Camera3D, DirectionalLight3D, SkeletalMesh3D and offscreen color/depth targets, with orbit camera, grid toggle, animation selection, and play/loop/scrub. Included bones draw as green parent-to-child segments.
    • PlayAnimation and QueueAnimation gained a new argument order — PlayAnimation(name, slot, loop, speed, weight) and QueueAnimation(name, dependentAnim, slot, loop, speed, weight). The legacy orders still work; dispatch is on whether argument 3 is an integer or a boolean. Source-compatible.
  • Procedural pose-modifier hook. struct SkeletalLocalPoseTransform { mPosition, mRotation, mScale, mValid } and class ISkeletalPoseModifier with ModifySkeletalPose(SkeletalMesh3D*, SkeletalMesh*, std::vector<SkeletalLocalPoseTransform>&, float), registered through SkeletalMesh3D::RegisterPoseModifier / UnregisterPoseModifier. Modifiers run after blending, masking and additive layering, and before the local pose is composed into bone matrices — the intended consumers are jiggle-bone, spring, and spline-IK addons.
  • Skeletal introspection on the plugin C API. Five new PolyphaseEngineAPI function pointers — SkeletalMesh_GetNumBones, SkeletalMesh_GetBoneName, SkeletalMesh_GetBoneParentIndex, SkeletalMesh_FindBoneIndex, Node_GetSkeletalMesh — so an addon can build bone-picker UI without linking engine asset types. A null or non-skeletal asset reads as a zero-bone skeleton; parent index -1 is root.
  • New asset types force-linked in Engine.cpp: FORCE_LINK_CALL(SkeletalAnimationAsset), FORCE_LINK_CALL(HumanoidAvatarAsset), FORCE_LINK_CALL(BoneMaskAsset). New AssetRef typedefs SkeletalAnimationRef, HumanoidAvatarRef, BoneMaskRef.
  • MeshSocket on SkeletalMesh: mName, mBoneName, mPosition (0,0,0), mRotation (euler degrees), mScale (1,1,1). Keyed by bone name (the same pattern as BoneMaskAsset) so a reimport that reorders bones does not relocate attached props. Asset API: GetNumSockets(), GetSocket(i), GetSocketMutable(i), GetSockets(), FindSocketIndex(name), AddSocket(socket) (empty name → "Socket"; collisions suffixed _1, _2, …), RemoveSocket(i), GetSocketLocalMatrix(i) (T·R·S).
    • ASSET_VERSION_BONE_SOCKETS = 39, ASSET_VERSION_CURRENT = 39. SkeletalMesh::SaveStream/LoadStream serialize the socket list (meshes below v39 load with zero sockets; resaving promotes them). SceneNodeDef::mParentBone widened int8_t → int32_t (Int32 at v39, Int8 below).
    • Node3D attachment by name. New serialized String property "Attach Socket" (mAttachSocket). API: AttachToSocket(SkeletalMesh3D*, socketName, keepWorldTransform=false, childIndex=-1), SetAttachSocket(name), GetAttachSocket(). mParentBoneIndex / mAttachSocketIndex are now a lazily-rebuilt cache (ResolveAttachSocket / InvalidateAttachSocket); sockets win over bones on a name collision, and missing targets log a warning. Lazy resolution is what makes scene load work: Scene::Instantiate applies properties before adding the child, and Attach() clears the attachment, so an eagerly-resolved index would be destroyed. AttachToBone now also records the bone name so bone attachments round-trip through scene save. The whole attachment funnels through GetParentTransform() — parentWorld * boneMatrix * mInvOffsetMatrix * socketLocal — so UpdateTransform, SetWorldPosition, SetWorldScale, dirty propagation and UpdateAttachedChildren inherit socket support with no per-backend work.
    • Scene::Instantiate treats the Attach Socket property as authoritative; the legacy mParentBone index is used only when no name is set.
    • SkeletalMesh3D. GetBoneWorldMatrix(i) = node world * skinning matrix * mInvOffsetMatrix is now the single source of truth for three previously duplicated bone-space derivations; GetBonePosition/RotationQuat/Scale decompose it. World-space socket queries: FindSocketIndex, GetNumSockets, GetSocketName(i), GetSocketTransform(name) (falls back to a bone of that name), GetSocketPosition, GetSocketRotationQuat, GetSocketRotationEuler. UpdateAttachedChildren also dirties children with a non-empty socket name so freshly loaded props do not sit at bind pose.
    • Animation time access. GetAnimationTime(anim) (−1 if not active), GetAnimationNormalizedTime(anim) (0..1), SetAnimationTime(anim, seconds), SetAnimationNormalizedTime(anim, t), GetAnimationDurationSeconds(anim).
    • Animation notifies. AnimationNotify { mAnimName, mNormalizedTime, ScriptFunc mCallback, mHandle }; AddAnimationNotify(anim, normalizedTime, ScriptFunc) → handle (handles start at 1; −1 on invalid input), RemoveAnimationNotify(handle), ClearAnimationNotifies(anim = nullptr). Registered at runtime, unlike Event_* bones which need a DCC re-export and support one handler per node. Three deliberate differences from the legacy DetectTriggeredAnimEvents: detection runs outside the weight > 0 guard so a slot still fading in still fires; the interval test is sign-agnostic ([min(prev,cur), max(prev,cur)]) so scrubbed playback with mSpeed = 0 and reverse playback both work, and a frame spike that steps past the threshold is caught; loop wrap is detected as |curNorm - prevNorm| > 0.5 and the covered interval is the two outer segments, so keys near t=0 are not dropped each loop. Dispatch is deferred to the end of UpdateAnimation via a mPendingNotifies member (not a function-local static, because UpdateAnimation re-enters through the inherit-pose path and a callback can re-enter too); the list is swapped out before firing so a callback may freely mutate notifies or mActiveAnimations. One-time LogWarning if notifies are registered under AnimationUpdateMode::OnlyUpdateWhenRendered, which never advances time for a culled mesh.
    • Editor. Properties panel for SkeletalMesh gains a Sockets (N) section with + add, per-socket Name, Bone combo, Position (0.01 step), Rotation (0.5), Scale (0.01), X remove, and a red "Bone '%s' not found" warning. Sockets draw as RGB axes (length 0.25) whenever a SkeletalMesh3D using that mesh is selected. Node3D::DrawCustomProperty renders Attach Socket as a combo ((None) / Sockets / Bones) routed through ActionManager::EXE_EditProperty (undoable, multi-select); hidden unless the parent is a SkeletalMesh3D. Scene panel: Attach Selected To Socket submenu (before Attach Selected To Bone) via new ActionManager::AttachSelectedNodesToSocket(); Create Socket From Selection bakes a bone-attached node's local offset into a new socket, re-points the node with SetAttachSocket and zeroes its transform so the offset is not double-applied. ActionAttachNode takes an optional socketName and records mPrevSocketName so undo restores either socket or bone attachment in one step. Reimporting a skeletal mesh carries sockets across (keyed by bone name, warning if the bone vanished) — they have no source-file counterpart and would otherwise be wiped.
    • Lua. Node3D:AttachToSocket(newParent, socketName, keepWorldTransform?, childIndex?), Node3D:SetAttachSocket(string) (empty string detaches), Node3D:GetAttachSocket() → string; SkeletalMesh3D:GetNumSockets(), GetSocketName(index) (nil when out of range), GetSocketPosition(name) → Vector, GetSocketRotation(name) → Vector (euler degrees; both accept a bare bone name), GetAnimationTime(name), GetAnimationNormalizedTime(name), SetAnimationTime(name, seconds), SetAnimationNormalizedTime(name, t), AddAnimationNotify(name, t, fn) → integer, RemoveAnimationNotify(handle), ClearAnimationNotifies(name?).
    • Gotchas. AttachToSocket reparents (a RemoveChild/AddChild round trip that reorders siblings); swapping sockets on the same parent must go through SetAttachSocket. UpdateTransform still overwrites scale with vec3(mScale.x) when the node has children (the anti-shear guard), so a socketed prop with a child effect loses its Y/Z scale.

Lighting — SpotLight3D

  • New node SpotLight3D : PointLight3D (Nodes/3D/SpotLight3d.h/.cpp, type name "SpotLight", default node name "Spot Light", FORCE_LINK_CALL(SpotLight3D)). Deriving from PointLight3D means radius, domain, culling and every existing point-light code path apply unchanged — and a backend that does not check LightType::Spot renders it as an omni point light rather than dropping it.
    • Properties (category Light): Inner Angle (default 30.0), Outer Angle (default 45.0) — half-angles in degrees. SetInnerAngle clamps to [0, 89.9], SetOuterAngle to [0.1, 89.9]. GetDirection() returns the node's forward vector; SetDirection(dir) calls LookAt(worldPos + dir, up=(0,1,0)). New Light3D::IsSpotLight3D() virtual. Dispatch on Is()/As<> must test SpotLight3D before PointLight3D (done in Renderer::SetLightData and RayTracer).
    • LightData gains mInnerConeAngle / mOuterConeAngle. Renderer::SetLightData fills LightType::Spot with outer clamped [0.1, 89.9] and inner clamped [0, outer]; point/directional/preview lights zero both.
    • Vulkan. LightUniformData pads become mConeCosInner/mConeCosOuter (cos of radians; empty slots get 1.0/1.0); GLSL LightData in Common.glsl mirrors it. Forward.frag and ForwardSpec.frag handle LIGHT_TYPE_SPOT in the point branch, scaling attenuation by clamp((dot(dir, -L) - cosOuter) / max(cosInner - cosOuter, 0.0001), 0, 1).
    • Lightmap bake. RayTraceLightType::Spot, RAY_TRACE_LIGHT_SPOT 2; RayTraceLight pads become mConeCosInner/mConeCosOuter; RayTracer::UpdateRayTracingScene emits spot lights with shadows, intensity and cone cosines; LightBakeDirect.comp applies the same cone factor.
    • GX (Wii/GameCube). GX_InitLightDir with a view-space direction and GX_InitLightSpot(cutoff, GX_SP_COS2), cutoff = outer angle clamped [1, 89]; other lights keep GX_SP_OFF. Inner angle is not used (single hardware cutoff).
    • C3D (3DS). LightEnv gains mLightSpotLuts[8], mLightSpotInner[8], mLightSpotOuter[8]. A spot LUT is built via LightLut_FromFunc(SpotAttenFunc) and regenerated only when the angles change; C3D_LightSpotDir / C3D_LightSpotLut / C3D_LightSpotEnable per light.
    • PSP / PS2 / addon targets: no in-tree changes; they render a spot as a point light until their backends add cone handling.
    • Editor. BASIC_SPOT_LIGHT "Spot Light" in the Basic 3D spawn menu (defaults: white, radius 10.0, LightingDomain::All); EditorAction::Spawn_SpotLight bound to Ctrl+Alt+2; hierarchy icon ICON_LUCIDE_SPOTLIGHT (checked before PointLight3D); proxy draw is SM_Sphere at 0.2 scale plus an SM_Cone pointer, and when selected a preview cone with base radius mRadius * tan(outer). ImportScene maps aiLightSource_SPOT to SpotLight3D (radius 50.0, Assimp half-angle radians converted with glm::degrees into SetOuterAngle/SetInnerAngle).
    • Lua (SpotLight3D, inherits PointLight3D): SetInnerAngle(number), GetInnerAngle(), SetOuterAngle(number), GetOuterAngle(), GetDirection() → Vector, SetDirection(Vector). New LuaMeta/SpotLight3D.lua and Documentation/Lua/Nodes/3D/SpotLight3D.md.
  • Console-aware preview lighting. EditorState::ShouldPreviewConsoleLighting() returns true when the current build target id/platform (or, with no target, the Game Preview preset name via new GamePreview::GetSelectedPresetName()) matches a console token (gamecube, wii, 3ds, psp, ps2, dreamcast, …). In Renderer::GatherLightData that clamps color * intensity to max(GetColorScale(), 1.0) with intensity reset to 1.0 and suppresses the synthetic preview directional light, so the desktop viewport matches fixed-function headroom on GX/C3D/PSP/PS2. View > Preview Lighting is disabled and relabelled Preview Lighting (off: console target).

Audio

  • Streaming SoundWave playback. New Streaming bool property on SoundWave, plus IsStreaming, GetStreamPcmOffset, GetStreamPcmSize. ASSET_VERSION_SOUNDWAVE_STREAMING = 38. In a shipped build the PCM is not loaded resident — the .oct records the payload's byte offset and AudioManager re-opens the file and feeds the platform push-PCM stream voice in chunks. SaveStream forces compress = false for streaming waves, since there is no per-chunk Vorbis decode.
    • Editor asymmetry, by design. SoundWave::LoadStream only takes the skip-resident-PCM path under #if !EDITOR. Packaging re-serializes the in-memory SoundWave, so a skipped load in the editor would write waveDataSize=0 and ship an .oct with no audio. Consequently GetStreamPcmSize() is 0 in the editor and PlaySound2D / PlaySound3D fall through to resident playback there; the Streaming flag only changes behavior in a built game.
    • Voice pool. kMaxStreamingSources = 4, a pool separate from the static voice slots that never enters FindAvailableAudioSourceIndex. StreamingSource carries a retry stash, a prebuffer gate (kStreamPrebufferSec = 0.15f, the voice stays paused until 0.15 s is queued), loop-by-seek, and 8-to-16-bit widening. New internals: StartStreamingMusic, UpdateStreamingSources, CloseStreamingSlot, IsStreamingPlaying, StopStreamingBySound, StopAllStreaming, StreamingIOThread. AudioManager::Initialize and Shutdown are no longer empty — they create and destroy sStreamMutex and tear down live streams.
    • Streaming voices play non-spatially. PlaySound3D on a streaming wave routes to the 2D streaming path.
    • Graceful degradation. AUD_OpenStream returning 0 logs AudioManager: AUD_OpenStream failed for '%s' (streaming unavailable on this platform) and returns false rather than faulting. A wave reporting GetStreamPcmSize() == 0 is refused up front so the Audio3D auto-start pass cannot re-trigger in a loop. Backend status: XAudio2, Linux, Dolphin and 3DS DSP support stream voices; the 3DS CSND fallback and the Android backend do not.
  • Seekable file API. struct SysFile plus SYS_FileOpenRead, SYS_FileRead, SYS_FileSeek, SYS_FileClose, declared in System.h and implemented once in SystemUtils.cpp so every platform links it without per-backend work. The handle transparently spans Content Pak entries (an independent handle at the entry offset, so long-lived seeking does not fight ContentPak::Read for the file position) and obfuscated containers (logical decoded cursor, per-chunk ContentObfuscation::DecodeRange).
  • Platform-conditional chunk size. kStreamChunkBytes is 128 KB under POLYPHASE_PLATFORM_ADDON and 16 KB elsewhere. On slow-media consoles each blocking filesystem read carries a large fixed command latency, so reads are made big and rare — 128 KB is roughly one read per six seconds of 22 kHz audio. Desktop I/O is fast and keeps 16 KB.
  • Asset load-byte cap. Asset::LoadFile now passes a cap to Stream::ReadFile; under POLYPHASE_PLATFORM_ADDON a SoundWave gets 512 KB, dispatched off RuntimeName() rather than a new virtual so the Asset vtable ABI is undisturbed. SoundWave::LoadStream detects a truncated non-streaming payload and logs SoundWave::LoadStream: '%s' PCM (%u bytes) exceeds the load cap and is not marked streaming; sound disabled. Mark large audio as streaming.
  • Animation keyframe lookup is now binary search. New file-local template<typename KeyT> FindKeySegment; FindScaleIndex, FindRotationIndex and FindPositionIndex are one-line wrappers. The old linear scan cost grew with distance into the clip, worst on the last frames before a loop, which surfaced as a per-loop hitch on slow targets.

Shipping and Content Protection

  • Static Content — obfuscated shipping assets. New ContentObfuscation.cpp/h (341 LOC). Cooked .oct assets, .lua scripts and AssetRegistry.txt ship wrapped in a 24-byte container (kHeaderSize = 24) carrying magic PLYOBF, version, flags, decoded size, a per-file salt, an FNV-1a-32 payload checksum and a header self-check. Enabled by the Static Content checkbox in the build profile; serialized as "staticContent" in BuildProfiles.json and absent-means-false, so existing profiles are unchanged. This is obfuscation, not DRM — the key is a compile-time constant and therefore ships inside the executable. It defeats opening Player.lua in a text editor and casual asset ripping; it does not defeat a disassembler.

    • Endian-independent. Every header field is read and written a byte at a time and the keystream byte is extracted by shifting, never by aliasing a uint32_t, so big-endian GameCube and Wii decode identically to desktop with no #if.
    • Offset-addressable. A byte's keystream depends only on its own payload offset, so truncated reads, mid-file seeks, the console load cap and streaming audio all work.
    • No per-file state and no allocation. Decode is in-place and single-pass, reentrant on the async asset-load worker, and does not double peak memory on a multi-megabyte console texture.
    • Detection is by magic, not by config flag, and the header self-hash makes false positives effectively impossible — so plain and obfuscated files coexist in one package and the editor reads its own project tree untouched.
    • Decode chokepoints. Stream::ReadFile via new Stream::DecodeObfuscatedData; Asset::LoadEmbedded for the embedded byte-array table; AssetManager::DiscoverEmbeddedAssets for the asset header read at registration time; ScriptUtils::RunScript for embedded Lua; and the SysFile family for streaming audio. Missing the DiscoverEmbeddedAssets site presents as Embedded-plus-Static crashing while Embedded-only and Static-only both work.
    • Static matters most alongside Embedded: without it an embedded build carries Lua source and every asset path as plaintext in the binary.
  • Content Pak — single-archive shipping. New ContentPak.cpp/h (526 LOC). Folds assets, scripts, shaders, the .octp and the asset registry into one Content.pak, then removes the loose copies, so filenames and the folder tree are hidden as well as file contents and no per-asset directory entry is spent on the target filesystem. Enabled by the Content Pak checkbox, drawn as a child of Static Content; serialized as "contentPak".

    • Layout. A 32-byte plain header (kPakMagic = "PLYPAK1\0", kPakVersion = 1, entry count, index offset and size, FNV-1a-32 header check), then per-entry obfuscated containers concatenated, then a 24-byte-per-record index (kRecordSize = 24: path hash, data offset, data size, path offset, path length) sorted by FNV-1a-64 path hash and itself obfuscated. Only the index is resident — roughly 10 to 20 KB for a few hundred entries — and entries are read and decoded on demand, so console memory behavior is unchanged. Lookup is a binary search on hash confirmed against the stored path, so a collision is a miss and never a wrong asset. Reads are mutex-guarded because the async asset loader shares the handle.
    • Keys are package-relative with forward slashes, for example Game/Assets/Textures/T_Grass.oct and Engine/Shaders/GLSL/bin/Forward.vert. Config.ini is never packed — ReadEngineConfig runs before the mount — and raw assets (.png, .json, .mp4, .rcss) stay loose because addon code opens those with its own file I/O.
    • Mount timing. Engine::Initialize under #if !EDITOR, before LoadProject, trying Content.pak, then <projectDir>Content.pak, then <projectName>/Content.pak, and falling back to SYS_GetAbsolutePath for device-prefixed paths such as romfs:/, host: and /cd/. Mounting forces mUseAssetRegistry on and re-asserts it after LoadProject, which would otherwise reset the flag when it re-reads the project Config.ini — the symptom of getting that wrong is a black screen with No default scene found.
    • Shader discovery. Global Vulkan shaders were found by walking Engine/Shaders/GLSL/bin/, which a packed build has no directory for. New ContentPak::List(prefix, outKeys); VulkanContext::CreateGlobalShaders enumerates the pak index and falls back to the directory walk when no pak is mounted or the pak carries no shaders.
    • Checkbox visibility. New PlatformUsesShaderFiles(Platform) returns true only for Windows, Linux and Android; the pak checkbox hides when it would be redundant against Embedded, and a stale saved true is force-cleared so it cannot silently apply. Build-target addons opt out cosmetically via POLYPHASE_OPT_HIDE_CONTENT_PAK ("polyphase.hideContentPak" = "1") in the profile's target options, with no build-target API version bump.
    • Cook ordering is load-bearing. ObfuscatePackagedContent and PackPackagedContent run in BuildPhase1 after the embedded generators (which read cooked files back through Stream::ReadFile, now decoding) and before the 3DS romfs copy (which clones the whole packaged directory). Pack prunes loose originals only after a successful ContentPak::Build, falling back to loose obfuscation on failure.
    • Build cache. BuildCache::GetManifestPath now takes staticContent and contentPak, producing BuildManifest_<Platform>_<Embedded|External>[_Static][_Pak].json, and both flags are written into the manifest JSON. Without this, flipping either checkbox reported UpToDate and shipped the previous build.
  • Build All profiles. New Build All button beside Build in the Packaging window, tooltip Build every profile sequentially; stops on first failure. TickBuildAllQueue runs every frame from PackagingWindow::Draw before the open-state early-out, so an in-flight queue survives closing and reopening the window. Force Rebuild is force-enabled and locked for the duration and restored afterwards, because the build-cache short-circuit stores a false success that the queue would read as a real failure. Cancel All stops the queue but not the in-flight build.

  • GetEngineContentDir(subDir) in Engine.h — resolves shipped engine content whether it sits at Engine/<sub> relative to the working directory or inside a per-project deploy folder. Used by Engine::Initialize, ScriptUtils::RunScript's engine-script fallback, and asset discovery. Returns the canonical root-relative form untouched when a pak is mounted, since the pak index is keyed on it.

  • Linux RPM and AppImage build targets. Two new built-in targets registered in BuiltInBuildTargets::RegisterAll: polyphase.linux.rpm ("Linux (RPM)") and polyphase.linux.appimage ("Linux (AppImage)"), category Desktop, basePlatform = Platform::Linux, binaryExtension = ".elf" — declaring .rpm would break the "<projectName><ext> exists" check in FinalizeLocalBuild. GetCompileCommand stays null so the legacy Makefile_Linux_Game path builds the ELF; only Validate, PostPackage and DrawProfileOptions are set.

    • RPM (RpmPackager): options rpm.version (1.0.0), rpm.release (1), rpm.license (Proprietary), rpm.summary, rpm.prefix (/opt), rpm.requires (vulkan-loader, libxcb, alsa-lib), rpm.iconPath, rpm.wslDistro. Generates a spec with %global debug_package %{nil}, __os_install_post %{nil}, _build_id_links none, AutoReqProv: no; a wrapper at /usr/bin/<pkg> that cds into <prefix>/<pkg> before exec; hyphens in Version/Release become _. Scratch in Intermediate/rpm.
    • AppImage (AppImagePackager): options appimage.version, .categories (Game;), .iconPath, .updateInformation (appimagetool -u), .runtimePath (offline mksquashfs + cat path), .wslDistro. AppRun resolves $HERE via readlink -f and cds into usr/bin; chmod 0755 on AppRun and the ELF because the engine never sets exec bits; falls back to PolyphaseLogo_128.png when no icon; APPIMAGE_EXTRACT_AND_RUN=1 so FUSE is not required. Output <AppName>-x86_64.AppImage.
    • LinuxHostShell (new): UsesWsl() (true on Windows), ToShellPath() (M:\x → /mnt/m/x), Quote(), BuildCommand() (wsl [-d distro] bash -lc "..."), Run(), RunScript() (writes an LF script so only the quoted path crosses the shell boundary), EnsureHostDir(), HasTool() (command -v), GetOption(), Report(), WriteTextFileLF() in binary mode — CRLF scripts die with "bad interpreter".
    • Dispatch rule change. ActionManager::FinalizeLocalBuild now calls PostPackage whenever the callback is non-null, not only for !mIsBuiltIn. mIsBuiltIn now only decides platform ownership: IsCanonicalTargetForPlatform() (id == BuiltInBuildTargets::IdForPlatform) → Packaged/<PlatformName>/; everything else → Packaged/<targetId>/ via ResolvePackageSubdir.
    • Build-cache variant key. ResolveBuildVariantKey() = sanitised target id + FNV-1a hash of the sorted profile-option map, threaded through BuildCache::CheckRebuildNeeded / BuildCurrentManifest / LoadManifest / GetManifestPath (new targetVariant, outputDirectory params; manifest gains targetVariant). RPM, AppImage, PSP, Dreamcast and PS2 all share the Linux base platform and previously read one BuildManifest_Linux_External.json, so switching targets falsely reported "up to date" and PostPackage never ran. Canonical targets pass "" so legacy manifest filenames are unchanged. mBuildState.mTargetOptions is now snapshotted and restored across the async build like the other flags; controller-server and CLI callers otherwise hit PostPackage with an empty map.
    • PolyphaseBuildContext.WriteOutputLine / .Log wired via BuildCtx_WriteOutputLine → ActionManager::AppendBuildOutput (made public) and BuildCtx_Log (severity → LogError / LogWarning / LogDebug).
    • Linux save-dir fallback (System_Linux.cpp GetSaveDir()): $POLYPHASE_SAVE_DIR → <projectDir>/Saves if writable → $XDG_DATA_HOME/<Project>/Saves (default ~/.local/share). Needed because the AppImage mount and the /opt prefix are read-only. New EnsureDirPath() does mkdir -p.
  • *Disc-image content layout (beta.18).* New target-option key POLYPHASE_OPT_DISC_CONTENT = "polyphase.discContent" (BuildProfile.h); a build-target addon that emits a disc image sets it to "1". PackagingWindow::DrawProfileSettings then forces Embedded off, Static Content on and Content Pak on, draws the three checkboxes inside BeginDisabled / EndDisabled with the note Disc layout set by the build target: Embedded off, Static Content and Content Pak on., and ExecuteLocalBuild applies the same forcing (buildEmbedded / buildStatic / buildPak) so a queued profile whose settings were never drawn this session still builds the only layout that boots. Previously nothing stopped a profile from shipping loose files onto an ISO.

  • *Shader compile without the Vulkan SDK (beta.17).* Engine/Shaders/GLSL/compile.bat was (( %VULKAN_SDK%/Bin/glslc.exe … ) && ( echo Compile Successful )) || pause; the editor streams the script into the build window, so any failure — including an unset %VULKAN_SDK% malforming every command — hit pause and waited forever for a keypress with nothing on screen. It now uses setlocal EnableDelayedExpansion, resolves GLSLC from "%VULKAN_SDK%\Bin\glslc.exe" then the first where glslc hit then a hard error naming the LunarG URL (exit /b 1), echoes Using %GLSLC%, quotes every path, skips .glsl case-insensitively (if /I NOT "%%~xf" == ".glsl"), sets FAILED=1 per file, and exits 1 / 0. ActionManager gained FindGlslcExecutable() ($VULKAN_SDK/Bin/glslc.exe or /bin/glslc, then where glslc / command -v glslc via SYS_Exec, "" when absent) and HasPrecompiledShaders(shaderDir) (every src/* other than .glsl includes has a bin/ counterpart, and at least one source exists). BuildPhase1 clears mBuildState.mShaderCompileCommand and logs glslc (Vulkan SDK) not found; skipping shader compilation and packaging the precompiled shaders in Engine/Shaders/GLSL/bin. when glslc is missing and bin is complete, and warns (naming https://vulkan.lunarg.com/sdk/home and VULKAN_SDK) then proceeds when neither exists; BuildCompileThreadFunc downgrades a non-zero shader exit to a warning (Continuing with the existing precompiled shaders... they may be stale if shader sources were edited.) and forces shaderExit = 0 when bin is complete and the build was not cancelled — the same escape hatch keying past the old pause gave. The Windows shader command is now cd /d "<dir>" && "./compile.bat": without /d, cd does not change drive, so an engine on M: with the shell on C: silently never found the script.

  • *Windows SDK release (beta.17).* Installers/package_windows_sdk.py used os.environ.get("VULKAN_SDK", "") in the Vulkan-header staging step without importing os; every Windows SDK-zip release run crashed with NameError: name 'os' is not defined after the earlier staging steps had already run. import os added.

Profiling

  • Memory Snapshot Profiler (Engine/Source/Editor/MemorySnapshot/, new). Tools > Memory Snapshot opens MemorySnapshotWindow (Open / Close / Draw / CaptureNow / Shutdown, accessor GetMemorySnapshotWindow()). It captures the running game — the Game or 3DS preview surface, or live play-in-editor — never the editor viewport. Memory is attributed only to assets referenced by game-world nodes, so gizmos, editor fonts and thumbnails are excluded by construction.
    • Data model (MemorySnapshot.h): SnapshotCategory { Geometry, Textures, Audio, Scripts, Materials, Fonts, Particles, Animation, Nodes, Other, FrameBuffer }; SnapshotEntry { mName, mTypeName, mCategory, mCpuBytes, mGpuBytes, mRefCount, mDetail }; MemorySnapshot adds whole-process mSystemRamUsed / mSystemVramUsed / mSystemTotalRam, mWasPlayingInEditor, mActiveAudioVoices, and an optional RGBA8 thumbnail (max 256 px, nearest-neighbour downscale of GamePreview::CaptureScreenshotToMemory).
    • Sizing (MemorySnapshotProfiler.cpp): GatherGameReferencedAssets() walks node and script properties (IsAssetDatumType) and expands transitively — mesh → material → texture, including MaterialLite slots read directly so the white fallback is not counted; font → atlas; particle → material. ClassifyAndSize() is format-accurate (BytesPerPixel per PixelFormat, CMPR = 0.5, ×1.3333 for mips), streaming-aware (SoundWave::IsStreaming() → 0 resident bytes), and splits SkeletalMesh into Geometry plus Animation (keyframe bytes). BuildGameMemoryEntries() adds Lua source sizes from <project>/Scripts/ and a Frame Buffers row (w*h*4*2 + w*h*2, default 640x480 or the Game Preview size).
    • Serialization. Loose files in <project>/Debug/DebugSnapshot_YYYY-MM-DD_HH-MM-SS.oct — a sibling of Assets/, so the AssetManager never indexes them — with kSnapshotMagic = 0x504e5350 ("PSNP") and kSnapshotVersion = 1. Toolbar: Capture Snapshot, Save, Load..., Reveal Debug Folder. A RAM-budget combo carries 17 console presets from 2 MB to 32 GB (default index 6 = 64 MB) plus Custom, with a green / amber / red bar at ≤75% / ≤100% / over. The footprint metric is mGpuBytes if non-zero else mCpuBytes, which avoids double-counting a texture's editor-only CPU copy. Sortable seven-column table.
    • Profiling window unified. ProfilingWindow::EstimateMemoryForPlatform now buckets BuildGameMemoryEntries() output as the single source of truth, throttled to one recompute per 0.5 s via mCachedMemory. New Other bar; RT/FB renamed Frame Buf. Without a build target it shows the game estimate (no budget cap) instead of raw process memory, and gains Capture Snapshot / Open Memory Snapshot Window buttons plus a whole-process RAM/VRAM footnote.

Widgets and UI

  • Fill anchors. Three new AnchorMode values — Fill, FillHorizontal, FillVertical — appended before Count, which moves 16 to 19. Inspector labels Fill / Fill Horizontal / Fill Vertical; Lua AnchorMode.Fill / AnchorMode.FillHorizontal / AnchorMode.FillVertical; XML anchor="fill" | "fill-horizontal" | "fill-vertical". On a filled axis GetX/GetY return 0 and GetWidth/GetHeight return the parent extent, and mOffset and mSize are ignored. New predicates AnchorFillsX, AnchorFillsY, FillsX, FillsY. Regenerated: Engine/Generated/LuaMeta/AnchorMode.lua, Engine/Generated/XML/PolyphaseUIDocument.xsd, Tools/generate_ui_xsd.py.
    • SetAnchorMode handles transitions explicitly: entering a Fill mode zeroes mOffset and mSize on the newly filled axis so a FullStretch to FillHorizontal switch cannot leave a stale ratio-to-pixel conversion; leaving one restores mSize to 1.0f or the parent extent so the widget does not become zero-size and invisible.
  • Widget::SetParentRectOverride / ClearParentRectOverride / HasParentRectOverride, and Widget::UpdateRect is now virtual. When set, UpdateRect treats the override as the effective parent rect; it is cleared automatically in SetParent. This is the slot mechanism ArrayWidget hands to children.
  • ArrayWidget padding and flex-grow. Four new float properties — Padding Left, Padding Top, Padding Right, Padding Bottom — plus an editor-only Refresh Layout button property that marks self and every child dirty, lays out immediately and calls Renderer::DirtyAllWidgets. Tick, EditorTick and TickCommon were removed in favor of UpdateRect and PreRender overrides: adding, removing or reordering a child only marks the child dirty, never the ArrayWidget, so a dirty-gated layout missed those cases.
    • LayoutChildren is a two-pass flex algorithm — pass one sums the array-axis size of non-Fill children and counts Fill children, then fillSlot = (axisTotal - padBefore - padAfter - nonFillTotal - totalSpacing) / fillCount clamped at zero, then pass two stamps a slot rect on each Fill child. Non-Fill children get anchor-ratio compensation so a RightStretch or MidRight child lands in its slot without manual negative offsets. Only Fill* modes flex-grow; Stretch* children take the classic path.
  • ScrollContainer::SetContentWidget(Widget*), null restoring the legacy first-non-transient-child scan. ListViewWidget's internal ArrayWidget is transient, so the fallback scan skipped it and the container computed a zero content size — scrolling never activated.
  • ListViewWidget sizing. New UpdateContentSize (sums item extents plus spacing, floors the cross extent at the viewport size, resizes the internal array widget and marks the scroll container dirty) called from PreRender, AddItem, RemoveItem, UpdateItem, Clear, SetSpacing, SetOrientation, SetItemWidth, SetItemHeight and RebuildItems; and ApplyItemSizeToAll, which indexes by size rather than range-for so a re-entrant mutation cannot dangle the end iterator. EnsureContainers now wires SetContentWidget(mArrayWidget).
    • Behavior change: CreateItem gives the item wrapper AnchorMode::FillHorizontal on a vertical list (or FillVertical on a horizontal one) when the cross-axis size is not fixed, so rows span the list by default. Setting a fixed cross-axis size keeps the previous TopLeft default. ApplyItemSize and ListViewItemWidget::SetContentWidget now skip auto-sizing on any axis that fills or stretches.
  • Game View resolution drives widget layout. New editor-only Widget::ComputeGamePreviewLetterbox, cached per frame on the frame number, computing a letterboxed sub-region of the editor viewport at the Game Preview target aspect. Root widgets use it as their parent rect and multiply mAbsoluteScale by the letterbox scale, so fixed-pixel content scales proportionally down the tree. GetParentWidth / GetParentHeight return the authored target dimensions rather than scaled letterbox pixels. New GamePreview::GetSelectedPresetWidth / GetSelectedPresetHeight read the selected preset rather than live render targets, so the letterbox stays correct with the panel closed. Outside EditorMode::Scene2D the root scissor clips to the letterbox; in Scene2D it deliberately does not, so overshooting widgets stay visible for editing.
  • Game view border. New DrawGameViewBorder outlines the Viewport2D wrapper rect in Scene2D via ImGui::GetBackgroundDrawList(), clamped to the viewport so it cannot spill into adjacent panels, drawn in cyan with a one-pixel black shadow so it reads on any background and stays distinct from the orange Canvas and green selection outlines.
  • Game Preview Project Settings preset. GetAllPresets inserts a synthetic top preset from AppSettings' WindowWidth, WindowHeight and Fullscreen — the resolution the packaged game actually runs at — formatted * Project Settings %dx%d or * Project Settings %dx%d (Fullscreen). New ApplyProjectDefaults, called from ActionManager::OpenProject, resets the selection and force-recreates render targets, fixing a stale 640x480 target lazily created at editor startup before the project was known. BeginPlayInEditor now honors mFullscreen via SYS_SetFullscreen.

Editor and UX

  • Create Asset is categorized. Eight submenus replace the flat list: Scene; Material (Material (Lite), Material (Base), Material (Instance), Skybox Material, Prototype Grid Material); Animation (Timeline, Transform Animation, Sprite Animation, Humanoid Avatar, Bone Mask); Tilemap (Tile Set, Tile Map); VFX (Particle System); UI (UI Document, CSS Stylesheet); Input (Input Prompt Map, Input Prompt Style); Logic (Node Graph). Addon asset factories that opted into a bucket via PolyphaseEngineAPI::SetAssetCategory are appended under the matching built-in category.
  • Other node categories. sNodeOtherNames became sNodeOtherCategorized plus sNodeOtherAddonGrouped, matching the existing 3D and Widget shape. New ClassifyOther routes PlayerInputRegistrar to Input; TimelinePlayer, SpriteAnimator, TransformAnimationNode3D and TransformAnimationWidget to Animation; NodeGraphPlayer to Logic; everything else to Generic. Draw order is Input, Animation, Logic, Generic. The 3D and Widget menus each gained an Animation bucket, and TransformAnimationNode3D / TransformAnimationWidget are cross-listed there — they derive from Node, so without it they were invisible in the menus users actually look in. Uncategorized addon nodes route to Other / Addons / <addonId>.
  • Add Node quick-spawn toolbar. Two rows of icon buttons in the Scene panel. Row one: Add Node3D, Add Static Mesh, Add Skeletal Mesh, Add Instanced Mesh, Add Camera 3D, Add Audio 3D, Add Directional Light, Add Point Light, Add Particle 3D. Row two: Add Canvas, Add Quad, Add Button, Add Text, Add Input Field, Add Array Widget, Add Window, Add Timeline Player, Add Spline. Spawn parent is the single selected non-scene-linked node, else the world root. A separate + button (Add Node...) opens the full add-node popup beside the search filter, which now stretches to fill.
  • Assets panel quick-create buttons. A + button (Create Asset...) opening the full hierarchy, plus direct-create buttons Create Scene (SC_Scene), Create Material (Lite) (M_Material), Create Tile Map (TM_TileMap), Create Timeline (TL_Timeline) and Create Transform Animation (TA_TransformAnim). The cluster dims and no-ops with no project, no current directory, or an engine/addon directory selected.
  • Main menu bar shortcuts. Five right-aligned icon buttons left of the existing Hammer button: Preferences, App Settings, Player Input Panel, Addons Manager, Build Profiles.
  • Script Creator Node Reactions: section. A class combo over Button, CheckBox, Slider, SpinBox, ComboBox, ProgressBar, InputField, ScrollContainer, ListViewWidget, ListViewItemWidget, Window, DialogWindow, TimelinePlayer and Audio3D, each exposing its callbacks as checkboxes that emit empty stubs in declared order. Changing the class clears stale picks.
    • Same pass cleaned up generated scripts: a new Lifecycle: section with Singleton (emits <Class>.Instance, a Start() that destroys duplicates and claims the instance, and a Destroy() that nulls it) and Persist across scenes (DontDestroyOnLoad) (emits self:SetPersistent(true)); property defaults now emit if self.X == nil then self.X = ... end so inspector-set values survive; and vector constructors were corrected to Vector.Create(...).
  • Script Panel right-click menu. Open in External Editor (disabled unless a Lua editor is configured), Open in Script Editor, then Rename..., Duplicate... and Delete (hidden for engine scripts, which are read-only), then Reveal in Explorer. Right-click now also selects the row so the highlight matches the target. Operations are deferred until after the tree finishes drawing, because the walk holds pointers into a cached vector that mutating would invalidate. Rename validates against Lua identifier rules, rejects collisions, and rewrites the class name inside the file with a boundary-aware replace so the class table matches the new filename.
  • Mesh import brings materials and textures with it. MeshImportOptions expanded to mMode, mPlaceInSubdirectory, mImportMaterials, mImportTextures, mPrefix, mDefaultShadingModel, mDefaultVertexColorMode, surfaced in the Import Mesh Asset modal as Place in subdirectory, Prefix, Import Materials, Import Textures, Shading Model and Vertex Color Mode. ImportAsset and ImportAssetCombined take the options through, and they ride the name-clash flow via PendingImportClash so a re-driven import after a rename keeps the user's settings.
    • New shared helpers MaterialImportContext, ImportOrFetchMaterialTexture and ImportSceneMaterials factor the logic out of the scene-import path so mesh-asset import and scene import share it. Per material: glTF metadata maps AI_MATKEY_GLTF_ALPHAMODE MASK to BlendMode::Masked and BLEND to Translucent, AI_MATKEY_GLTF_ALPHACUTOFF to SetMaskCutoff, and AI_MATKEY_TWOSIDED to CullMode::None; up to four diffuse slots resolve to textures. Embedded GLB textures are extracted to a temp file, imported, and removed. Textures are named T_<meshStem>_<prefix><name> and materials M_<meshStem>_<prefix><aiName>, and the source material index is auto-assigned to the mesh — before the existing "user has a Material selected" override, so a manual pick still wins.
  • ActionReplaceNodeType and the Replace With menu. New undoable ActionManager::EXE_ReplaceNodeType(Node*, TypeId) morphs a node's type in place: preserves the slot in the parent, transfers every property matched by name and type, moves all children, and preserves the persistent UUID so Timeline bindings and node-graph references stay resolvable. Surfaced for widgets as Replace With > Widget > <bucket> > <Type>, reusing the same category maps as the Add Node menu. Blocked for scene-linked nodes, sub-scene children and the root.
  • PlayerInputRegistrar node. New Node subclass with a single Input Actions property filtered to InputActionsAsset. On Start() it unregisters every existing action and replays register, trigger and binding from the asset, mirroring PlayerInput.LoadActions. Categorized under Other > Input. New files: PlayerInputRegistrar.cpp/h.
  • System.ListDirectory(path) in System_Lua, returning a 1-indexed array of tables with name (string) and isDirectory (boolean). Non-recursive; an unopenable path yields an empty table rather than an error. DirEntry is a LuaLS documentation class describing that table shape, not a bound userdata type. Generated stub added to Engine/Generated/LuaMeta/System.lua.
  • Copy Hierarchy now captures everything. BuildHierarchyJson gained AppendJsonEscapedString, AppendDatumJsonValue and AppendPropertiesJson, and emits full inspector state from GatherProperties — transform, widget size, anchor, color, opacity, pivot, texture, material — so addon node types are covered automatically, plus a scriptProperties object for script-exposed fields deduplicated against the main block. Datum type coverage mirrors ControllerServerRoutes.cpp so copied output matches the controller REST API. type now uses GetClassName() rather than GetTypeName().
  • ScriptUtils::ReloadAllScriptFiles sweeps disk. Previously it only re-ran already-loaded scripts. It now walks Engine/Scripts/, <project>/Scripts/ and Packages/<addon>/Scripts/ via AssetManager::GatherScriptFiles, so brand-new files and scripts pulled in through Lua require() — which bypasses LoadScriptFile — are covered without a restart. Companion InvalidateScriptPickerCache forces the inspector's script picker to re-walk disk past its two-second debounce.
  • AssetManager::UnregisterTransientAsset(Asset*), symmetric to RegisterTransientAsset. Removes the entry and clears the transient flag without destroying the asset; the caller keeps ownership. Added for addons that own a Texture* themselves but need it visible to IsAssetLive so Quad::ResolveQuadTexture does not clear it as dangling on the first PreRender.
  • EditorImageCache (new, Vulkan-only). Get(absPath) → ImTextureID with negative caching, GetSize, Invalidate, InvalidateAll, RetirePending (drained at the top of EditorImguiDraw before NewFrame, with a DeviceWaitIdle), Shutdown (from EditorImguiPreShutdown). Release is deferred because ImGui_ImplVulkan_RemoveTexture is immediate and AddonsWindow::Close runs mid-Draw. AddonsWindow now only memoizes paths in mThumbnailPaths instead of owning Vulkan textures.
  • Addon registry reads an auto-generated manifest.json. AddonManager::FetchManifest() reads one manifest per repository (tries main then master) with name, categories[] { id, name, description } and packages[] (skips status != "published", dedupes by id). ParseAddonObject maps the engine block (target, apiVersion, entrySymbol, binaryName, resolveMode), dependencies and buildTargets. Falls back to the legacy per-package package.json crawl. New AddonCategory struct, ContentMetadata::mCategory, GetCategories(). The Browse tab gets a Category: combo (default "All Categories") and groups cards under collapsing headers in registry order with "Other" last.
  • Scene import "Share Materials/Textures". SceneImportOptions::mShareAssets with mSharedAssetDir = "_Shared": M_ / T_ assets drop the per-scene stem, land in a shared sibling folder, and existing ones are reused rather than re-authored and re-saved. glTF extras now honour polyphase_material / material_path / octave_material, polyphase_collision / collision, and polyphase_hidden / hidden (PolyphaseNodeExtras::mMaterialPath / mCollision / mHidden, -1 = unset). ActionManager::ImportScene now returns the Asset*.
  • Controller server. POST /api/assets/import accepts scene: true plus sceneName, import{Meshes,Materials,Textures,Lights,Cameras}, enableCollision, applyGltfExtras, shareAssets and sharedFolder, and restores the asset directory afterwards so batch imports land as siblings. "Root" is now an alias for the world root in node lookups; spawning with a missing parent returns Parent node not found.
  • Game Preview camera combo gains (Active Camera) (sentinel mSelectedCameraIndex = -1, now the default) — the preview follows whichever camera is active instead of a pinned index.
  • Scripts panel reveal. EditorState::BrowseToScriptPath() + mRevealScriptPath; requests are retried for 1 s, force the Lua Scripts tab, clear the search filter, and compare on the .lua-stripped stem. The Create Script and Create C++ dialogs close on success.
  • Asset browser. Clicking a folder row selects it (SetAssetDirectory, ImGuiTreeNodeFlags_Selected); new Data Asset entry (DA_DataAsset) in the New Asset > Logic menu; FORCE_LINK_CALL(DataAsset).
  • Node::OnSpawnedInEditor() — called only from ActionSpawnNodes::Execute, never on reload or clone, for one-time authoring setup such as a spline's starter point.
  • Multi-selection actions across panels. New GatherHierarchyActionNodes (drops descendants of other selected nodes) and GatherAssetActionStubs helpers. Hierarchy: group drag-and-drop (payload stays a single Node*; drop targets re-derive the set), Move > Top/Up/Down/Bottom and Hier_ReorderUp/Down hotkeys act on sibling blocks, Delete uses EXE_DeleteNodes, right-click pulls the row into the selection, and single-select on an already-multi-selected row is deferred to mouse-up (sPendingSelectNode) so a group drag can start. Asset browser: multi-drag to folders, Save, Delete, Duplicate (auto-named when more than one). Scripts panel: Ctrl/Shift multi-select keyed by full path (display names collide across origins), Delete Selected, Copy Paths, multi-open.
  • ActionManager::LaunchDesktopBuild() (used by BuildData, FinalizeLocalBuild and PackagingWindow::FinalizeBuild) launches desktop builds directly, cd-ing into the packaged directory first because the game resolves Config.ini and assets relative to cwd; returns false with "Cannot run a %s build on this host" when the target does not match the host.
  • Build Dependency window adds devkitPPC / devkitARM toolchain checks — a devkitPro install without wii-dev/3ds-dev passed CheckDevkitPro but failed mid-make with a missing-compiler error.
  • CreateDirectoryRecursive promoted from a NativeAddonManager static into EditorUtils.{h,cpp}.

Scripting Runtime

  • require() in packaged builds. EngineScriptSearcher is appended to package.searchers (after the stock ones, so loose .lua on disk still wins in the editor) and resolves via new ScriptUtils::ReadScriptSource(fileName, outSource): embedded table → <project>/Scripts/ → engine Scripts/ (Content.pak-aware). Dotted module names fall through to Lua's own searchers. Embedded-script deobfuscation is factored into DecodeEmbeddedScript.
  • Reload-excluded boot scripts. ScriptUtils::IsReloadExcludedScript(fileName) — EngineStartup, Startup, StartLuaPanda, LuaPanda, DataAssetTemplate — honored by both per-file hot reload and ReloadAllScriptFiles. LoadScriptFile(fileName, className, reportMissingClass=true): disk-swept module scripts with no class table now log at debug level instead of error.
  • DatumType.Function exposed to Lua — an editor-only inspector button that calls the same-named method. Script GatherProperties entries accept a tooltip field → Property::mTooltip (editor-only, now copied in the copy ctor/assignment); property buttons show it on hover.
  • ThirdPersonController.lua: animation names are now inspector properties animationFall / animationJump / animationRun / animationIdle (defaults "Fall", "Jump", "Run", "Idle"), so a rig with different clip names no longer needs a script copy.
  • Target screens from Lua. Node:SetTargetScreen(int), Node:GetTargetScreen() (routes through GetEffectiveTargetScreen, so it returns the resolved screen rather than the raw property), Node:GetScreenResolution() → Vector; global enum TargetScreen = { Top = 0, Bottom = 1, All = 255 }. Renderer.GetScreenResolution(index) now requires the index.
  • Bounds from Lua. Node3D:GetAABB(), Node3D:GetHierarchyAABB(includeSelf = true), Primitive3D:GetAABB() / GetLocalAABB(), StaticMesh:GetBounds() / GetAABB(), SkeletalMesh:GetBounds() / GetAABB(). AABB tables carry min / max / center / extents / size; Primitive3D:GetBounds() adds min / max.
  • World:GetLoadedSceneName() → string, World:GetLoadedSceneNames() → array.
  • Spline3D: GetSplineLength(), GetClosestDistanceAlong(Vector), GetNumSplinePoints(), GetSplinePointPosition(i), GetSplinePointWorldPosition(i) (1-indexed).

Engine and Rendering

  • AABB struct in EngineTypes.h, kept separate from Bounds so DrawData stays small: default ±0.5 box, MakeInvalid / MakeFromCenterExtents / MakeLarge, IsValid / IsLarge, GetCenter / GetExtents / GetSize / GetRadius / GetVolume, Contains, Intersects (box, sphere), Encapsulate, Expand, Transform(mat4) (nine-multiply-add refit), ToBounds. Node3D::GetAABB() is virtual (degenerate box at world origin by default) and GetHierarchyAABB(includeSelf = true) unions the subtree. Primitive3D::GetAABB() = GetLocalAABB().Transform(mTransform); the default GetLocalAABB circumscribes the bounding sphere so subclass overrides are optional. Tight overrides: Box3D, Sphere3D, Capsule3D (Y-aligned, +radius caps), StaticMesh3D / SkeletalMesh3D (asset GetAABB(), with mBoundsScale / mBoundsRadiusOverride mirrored), InstancedMesh3D (union of transformed instances), Voxel3D. StaticMesh::mAABB and SkeletalMesh::mAABB are computed in ComputeBounds() and deliberately not serialized. BoundsDebugMode gains BoxAll and BoxSelected (SM_Cube debug draw, scale = half-size). Viewport focus (F) now frames GetHierarchyAABB().

  • 3DS dual-screen resolution order (Engine.cpp, Renderer.cpp, World.cpp). If world 1 has no root, world 0 is rendered again for screen 1 with mTargetScreenFilter = screenIndex (SUPPORTS_SECOND_SCREEN && !EDITOR); World::FindCameraForScreen() (subtree membership, prefers the main camera) is installed as the camera override for that pass. Node::GetEffectiveTargetScreen(sceneRoot) resolves up to the scene-root-level child; Renderer::PassesTargetScreenFilter() applies the filter to lights too (0xFF = all).

  • World::GetLoadedSceneName() / GetLoadedSceneNames() (mLoadedSceneName, mQueuedSceneName).

  • Spline3D gains GetNumSplinePoints, GetSplinePointPosition / GetSplinePointWorldPosition, SetSplinePointPosition, AddSplinePoint, ClearSplinePoints, GetSplineLength, GetClosestDistanceAlong (16 samples per segment, Catmull-Rom when mSmoothCurve and ≥4 points), SetCloseLoop / IsCloseLoop, SetSmoothCurve / IsSmoothCurve. Exported for addons.

  • AssetManager threadless mode. ProcessNextBeginLoad() is shared by the load thread and a synchronous drain in Update() (max 4 loads per frame) when SYS_CreateThread returns null, which is the case on single-threaded wasm. InputConstants.h console fallbacks are now #ifndef-guarded; PLATFORM_WEB is excluded from HttpBackend_Stub / HttpClient.

  • C3D. GFX_CreateTextureResource retries Tex3DS_TextureImport on the linear heap when VRAM is full (with a warning) and names failures; mColorScaleEnum is reset to GPU_TEVSCALE_1 each frame; SYS_AlignedFree fixes a malloc/free mismatch. MaterialBase::GetLiteFallback and MaterialLite::Create warn once when falling back to white.

  • *Fallback camera (beta.18).* World::EnsureFallbackCamera() (new member Camera3D* mFallbackCamera, accessor GetFallbackCamera()), called once per World::Update after the queued-scene swap so a scene loaded this frame is evaluated with its own cameras registered. Returns immediately when IsHeadless() (cooks must not gain a node). When mRootNode != nullptr and GetActiveCamera() == nullptr (deliberately GetActiveCamera(), not mActiveCamera, so the editor camera and mCameraOverride do not trigger it) it calls SpawnDefaultCamera(), names it Fallback Camera (default settings at (0, 0, 10)) and logs once: World: scene '<name>' has no Camera3D -- using a transient fallback camera. Add a Camera3D to the scene; UI-only scenes need one too. RegisterNode now also yields the active slot when mActiveCamera == mFallbackCamera, so a real camera arriving later (scene load, spawned prefab, streamed sector) takes over; retirement (retired->Detach()) is deferred to the next EnsureFallbackCamera() because detaching mid-RegisterNode is unsafe; UnregisterNode nulls mFallbackCamera as it already did mActiveCamera. Root cause of the symptom: with mActiveCamera == nullptr, Renderer::Render skipped the scene pass and GFX_RenderPostProcessPasses() — the only full-window GFX_SetViewport / GFX_SetScissor paint — and ran a bare RenderPassId::Clear over the scene viewport, so UI-only scenes drew offset into a corner with black bands (reproducible on Android; desktop often masked it).

  • *UI pass scissor (beta.18).* Renderer::Render now calls GFX_SetScissor(viewportX, viewportY, viewportWidth, viewportHeight) alongside the viewport set, immediately before GFX_BeginRenderPass(RenderPassId::Ui). It previously inherited the scene scissor (window viewport × Resolution Scale, from GetSceneViewport), so with Resolution Scale below 1.0 the whole UI was clipped into that rect until some drawable widget set its own in Widget::Render; non-drawable widgets (Canvas, plain Widget) never do.

  • *CSS overflow (beta.18).* ApplyCSSProperty (UITypes.cpp) handles hidden or clip → EnableScissor(true) and visible → EnableScissor(false). Previously only hidden was handled, so a stylesheet could never override the scissor default that Button, Canvas, InputField, ScrollContainer, Slider and Window set in Create(). (The FAQ and skill prose from the same commit still call overflow: visible a no-op; the code is authoritative.)

  • *Vulkan Image layout on mobile GPUs (beta.18).* Image::mLayout and ciImage.initialLayout default VK_IMAGE_LAYOUT_PREINITIALIZED → VK_IMAGE_LAYOUT_UNDEFINED: every image the class creates is OPTIMAL-tiled, DEVICE_LOCAL and never host-written, and PREINITIALIZED is only meaningful for LINEAR-tiled host-written memory. The old path issued a barrier with VK_ACCESS_HOST_WRITE_BIT under a stage mask containing no HOST stage — ill-formed, forgiven by desktop drivers, treated as undefined by Adreno / Mali (texture-compression metadata), sampling black on the first upload. TransitionImageLayout's two PREINITIALIZED → TRANSFER_SRC/DST_OPTIMAL branches use srcAccessMask = 0 like the UNDEFINED branches. Image::Update restored savedLayout unless it was exactly PREINITIALIZED and Image::Clear unless exactly UNDEFINED; each missed the other sentinel, and both are illegal as a barrier newLayout; a shared savedIsSentinel / originalIsSentinel test now covers both and lands on SHADER_READ_ONLY_OPTIMAL. New signature void Image::Update(const void* srcData, bool waitForCompletion = false): the staging copy and transitions are three fire-and-forget one-time command buffers with no fence — fine for a per-frame stream (frame 2 self-heals), wrong for a texture uploaded once and sampled immediately; true calls vkQueueWaitIdle(GetVulkanContext()->GetGraphicsQueue()), and CreateTextureResource() (the Texture::Create() initial upload) passes true. Named use case: a webcam snapshot bound straight to a Quad.

  • Texture::SetRetainPixels(bool) / GetRetainPixels() (beta.17) (bool mRetainPixels = false, placed in the existing 3 bytes of padding before mUvMax so sizeof(Texture) and every member offset are unchanged and already-built addon DLLs stay ABI-compatible). Non-editor Texture::Create() unconditionally did mPixels.clear(); shrink_to_fit() after the GPU upload, so GetPixels() was empty in a packaged build while the identical code worked in the editor. The clear is skipped when set, and UpdatePixels() keeps the CPU copy mirrored while it is on (restructured around a keepCpuCopy flag, always true under EDITOR). For runtime-generated textures read back on the CPU (webcam snapshot PNG-encoded and uploaded, procedural image saved); costs width * height * 4, so it stays off for draw-only feeds.

  • StaticMesh::Import (beta.18) adds aiProcess_GenSmoothNormals to the ReadFile flags (alongside aiProcess_FlipUVs | aiProcess_JoinIdenticalVertices | aiProcess_Triangulate) and null-checks mesh->mNormals at both vertices[i].mNormal assignment sites in StaticMesh::Create (fallback glm::vec3(0, 0, 1)), matching the existing texcoord guards. A bare Wavefront .obj with no vn lines — hit with an AI-generated mesh — left mNormals null and segfaulted.

  • POLYPHASE_THREAD_LOCAL (beta.18) (Engine/Source/System/SystemConstants.h): plain static on PLATFORM_XBOX360 || PLATFORM_WEB, thread_local elsewhere. libxenon's _start never establishes a thread pointer (r2 on PowerPC32) yet the ELF still carries a PT_TLS segment for the engine's thread_locals, so the first access dereferenced an uninitialised thread pointer — a silent memory fault during boot inside Widget::MarkDirty() (its recursion-depth guard) on the very first widget constructed, on emulator and hardware alike. Converted: Widget::MarkDirty's sMarkDirtyDepth and sCycleWarnCount, AssetManager::DiscoverDirectory's sDiscoverTickCounter (both files now include System/SystemConstants.h). Degrades cleanly because SYS_CreateThread cannot create a thread on those targets. Bullet gets the same treatment via POLYPHASE_NO_TLS, defined by the Xbox 360 build-target addon's makefile (not in-tree): btThreads.cpp THREAD_LOCAL_STATIC → static, btQuickprof.cpp #undef BT_HAVE_TLS.

Addon API

  • POLYPHASE_PLUGIN_API_VERSION 8 → 9. EditorUIHooks::EditorImage_Load / GetSize / Invalidate (ABI-additive; engine-owned, no per-HookId release — RemoveAllHooks deliberately leaves the cache alone). EditorUIHooks mHooks{} is value-initialised.

  • New POLYPHASE_API exports: Capsule3D, InstancedMesh3D, Spline3D, Script::SetScriptProperties, AssetManager::GetAssetStubByPath / LoadAssetByPath, GFX_GetVulkanAddonHandles, ActionManager::Get / SaveScene / CreateNewScene / ImportScene, GetEditorState, EditorState::GetEditorCamera / OpenEditScene / SetAssetDirectory / GetAssetDirectory, EditorAddUniqueAsset, DrawAssetProperty, CreateAABBTableLua. The CI smoke addon (Tools/CI/TestBuildAddon/com.polyphase.smoke.material) exercises them.

  • Skeletal introspection on the plugin C API — see Animation above.

  • *Exports (beta.17 / beta.18).* The ContentPak mount-stack API, AssetManager::DiscoverAssetRegistry / RegisterAsset, the AssetDir, Sha256 and Canvas classes, ActionManager::OpenScene / ImportAsset / ImportAssetCombined / RetargetAnimation, and IsHeadlessService() are now POLYPHASE_API — see the Content Pak and Headless Service sections.

  • Controller-server addon routes (beta.18) answer through a heap std::vector<char> of kAddonResponseBufferSize = 256 * 1024 (ControllerServerRoutes.cpp) instead of a char responseBuffer[4096] on a Crow worker stack, passed as responseBuffer.data() / (int32_t)responseBuffer.size(). The com.polyphase.format.io.glb chunked bundle download needed ~600 requests for a ~2 MB file at 4 KB. One heap allocation per addon-route request.

  • *Android camera permission bridge (beta.18), PolyphaseActivity.java.* private static final int CAMERA_PERMISSION_REQUEST_CODE = 4242, private volatile int cameraPermissionState = -1, public boolean hasCameraPermission() (checkSelfPermission(Manifest.permission.CAMERA)), public void requestCameraPermission() (short-circuits to 1 if already granted, else resets to -1 and calls requestPermissions), public int getCameraPermissionState() (-1 pending, 0 denied, 1 granted), onRequestPermissionsResult override. Poll-based by design: requestPermissions() is asynchronous and native code cannot block a capture thread on a user dialog; the addon's WebcamBackend_Android.cpp calls once and polls. Template manifest adds <uses-permission android:name="android.permission.CAMERA" /> and <uses-feature android:name="android.hardware.camera" android:required="false" /> / android.hardware.camera.any — required="false" because this is the shared engine template and camera-less devices must still install.

  • **InjectNativeAddonsIntoCmake platform key (beta.18).** ActionManager::BuildPhase1 passed "android", but NativeModuleMetadata::ResolveExtras matches nativePerPlatform keys case-sensitively and every addon's package.json uses "Android", so extraDefines, extraLibs and extraLibDirs were dropped for every Android build on every project; extraIncludeDirs from a platform-common block still resolved, which masked it (headers compiled, defines did not). Now "Android". Editor-only code (#if EDITOR), so it needs an editor rebuild, not a repackage. Visible in the regenerated AddonInject.cmake: POLYPHASE_ADDON_DEFINES → POLYPHASE_WITH_CAMERA2=1, POLYPHASE_ADDON_LIBS → camera2ndk, mediandk (bare names — POLYPHASE_ADDON_LIBS feeds target_link_libraries directly, never -l flags).

Engine Infrastructure

  • Portable FileWatcher. The Windows-only ReadDirectoryChangesW-over-IOCP implementation was replaced with a polling watcher built on std::thread, stat() and the SYS_* directory API, so it behaves identically on every editor-capable platform. kPollIntervalMs = 500, sliced into kPollSliceMs = 50 chunks so Shutdown joins promptly. New FileSnapshot { lastSeenTime, lastEmittedTime, seenThisScan } and std::atomic<bool> mNeedsRebaseline — the first scan after a watch-set change or a re-enable records mtimes without emitting, so re-enabling hot-reload does not fire a reload for every file edited while it was off. A change is emitted only once its mtime has held steady for a full interval, so a half-written file is never dispatched. New UnwatchAll.

  • Datum addon-toolchain overload guard. The Datum(int) overload condition became PLATFORM_3DS || (defined(POLYPHASE_PLATFORM_ADDON) && !defined(POLYPHASE_ADDON_INT32_IS_INT)), adding an opt-out for addon toolchains where int32_t is int and the extra overload would collide with Datum(int32_t).

  • ENDIAN_SWAP auto-detects big-endian via __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ in addition to PLATFORM_DOLPHIN || PLATFORM_N64, covering addon targets that have no Platform enum value.

  • ASSET_VERSION_CURRENT bumped 35 to 38 — 36 skeletal mesh sections, 37 bone mask, 38 soundwave streaming.

  • Version string bumped to 6.2.0-beta.11 in Constants.h.

  • FileWatcher::WatchDirectory extension filter (WatchDir::extensions, default {".lua"}, empty = all).

  • SYS_CreateDirectory (Windows/Linux/Android/Dolphin/3DS): failures other than EEXIST now log a warning with the path; Windows no longer warns on EEXIST, and the POSIX platforms were previously silent.

  • Engine.vcxproj / .filters: added Editor\CSharp\CSharpManager.{h,cpp} (new Source Files\Editor\CSharp filter), SpotLight3d.{h,cpp}, SpotLight3d_Lua, WebSocket_Lua, and the seven Network\WebSocket* files; both files gained a UTF-8 BOM.

  • Version string in Constants.h is 6.2.0-beta.18 (bumped beta.16 → beta.17 on 2026-09-08 and beta.17 → beta.18 on 2026-09-14).

  • AssetManager::SaveAsset (beta.18) walks mParentDir upward collecting every missing ancestor into std::vector<AssetDir*> missingDirs and creates them root-to-leaf. The previous single-level CreateDir() on stub.mDirectory->mPath failed with ENOENT on the innermost path of a brand-new AssetDir chain (a cook pipeline creating AssetIO/<uuid>/<mesh>/ in one shot) and the asset silently never saved.

  • **Sha256 forced linkage (beta.17).** Sha256 (Engine/Utils/Sha256.h) is POLYPHASE_API but has no Node/Asset registration, so FORCE_LINK_CALL never covered it, and its only in-engine caller is EngineRuntimeValidator.cpp, whose chain is not reachable from every configuration. MSVC only pulls an .obj out of a static library when something already linked references it, so Sha256.obj was dropped and the export never existed — LNK2019 on Sha256::HashHex in com.polyphase.format.io.glb's PlatformBundler. Engine::ForceLinkage() now includes Utils/Sha256.h and calls (void)Sha256::HashHex(&forceLinkSha256Dummy, 1) unconditionally. That in turn broke the 3DS, GameCube and Wii links: console Makefiles enumerate SOURCES explicitly rather than globbing, and Source/Engine/Utils was never listed. It now follows Source/Engine/Timeline/Tracks in Engine/Makefile_3DS, Makefile_GCN and Makefile_Wii. The polyphase-buildtarget skill gained the rule with a verification snippet (five built-in Engine/Makefile_* plus eleven out-of-tree BuildTarget-* Makefiles; PSP and N64 carry two copies each; ordinary addons are unaffected because they file(GLOB_RECURSE) their own tree and link the prebuilt engine).

  • *Android Log To File (beta.18), Log.cpp.* New #elif PLATFORM_ANDROID branch in OpenLogFile() prefixes the log name with engineState->mSystem.mActivity->internalDataPath (app-private internal storage, no permission needed), mirroring the ms0:/PSP/GAME/POLYPHASE/ PSP branch. The relative path used before fails silently in a NativeActivity, which has no meaningful writable cwd, leaving mLogFile null and no file anywhere. Retrieve with adb shell run-as <applicationId> cat files/<ProjectName>.log.

Addons and Build Targets

Everything below lives in its own repository under github.com/Polyphase-Labs and is discovered through the official registry (Polyphase-Engine---Official-Addons), whose auto-generated manifest.json the Addons Manager Browse tab now reads directly. Versions, plugin API levels and maturity are as of 2026-09-05. Items marked in progress boot and render but are not yet feature-complete; the commit log is the source for that judgement, since several READMEs lag their repositories.

Build Targets

  • PlayStation 2 — com.polyphase.build.target.ps2 (v1.0.0, apiVersion 4, target id homebrew.ps2, display Sony PS2 (PS2SDK + gsKit), category Retro Consoles). Compiles via PS2SDK 2024+ (mips64r5900el-ps2-elf-gcc) with gsKit and dmaKit through Makefile_PS2; audsrv.irx is embedded into the ELF via bin2c. Output is a bare .elf plus an optional mkisofs .iso (ps2.makeIso=1, -iso-level 1, so 8.3 filename mangling applies) for PCSX2 and FreeMcBoot. Windows native (ps2dev ships a Windows bundle), Linux/macOS native, WSL as an opt-in. Graphics_PS2GS renders 640x448 NTSC interlaced (GS_PSM_CT24 + GS_PSMZ_16S): textured and vertex-color static meshes, CPU-skinned skeletal meshes, instanced meshes (one draw per instance), 3D text, TileMap2D / Terrain3D / Voxel3D vertex-color meshes, particles (4→6 vertex expansion), UI widgets and font atlas. CPU per-vertex lighting with ambient, one directional, four point lights and — as of 2026-08-31 — spot lights, with GS overbright MODULATE; tint, emissive, vertex color, two-sided, four blend modes, masked blend mode and translucent depth sort (draw order, 2026-08-29); GS hardware fog plus a horizon-gradient skybox fog. Audio is an 8-voice plus 4-stream EE software mixer feeding audsrv, paced by audsrv_wait_audio; the mix loop is all-integer 16.16 fixed-point because the EE FPU is single-precision. Input is libpad DualShock 2 with pressure and vibration; saves go to memory card via libmc with icon.sys. Network is a stub. No shadows, post-processing or light bake. 32 MB EE RAM, embedded assets off, -DGLM_FORCE_UNRESTRICTED_GENTYPE for the non-IEC559 FPU, -j4 default because engine TUs peak 1–1.5 GB each. PAL and cdrom0: boot deferred.
  • PlayStation Portable — com.polyphase.build.target.psp (v1.0.0, apiVersion 4, homebrew.psp, Sony PSP (PSPSDK), category Handheld). Compiles via PSPSDK (psp-gcc, links pspgum_vfpu + pspvfpu) through Makefile_PSP; PSPDEV resolved from the profile, the environment, then /usr/local/pspdev, /opt/pspdev, $HOME/pspdev, /mnt/c/pspdev. Packs ELF + PARAM.SFO + XMB tiles (ICON0.PNG, ICON1.PMF, PIC0.PNG, PIC1.PNG, SND0.AT3) into EBOOT.PBP via pack-pbp, with a Build-to-ISO mode that stages PSP_GAME/. Launches in PPSSPP or deploys to hardware via pspsh. Linux/macOS native; Windows through WSL2 with automatic /mnt/c path translation. Graphics_PSPGU is fixed-function sceGu: textured static meshes (32-byte vertices), sceGuLight0..3 hardware vertex lighting plus ambient, Lit/Unlit/Toon (Toon maps to Lit), four blend modes, back-face culling; textures are padded to power-of-two with SetUVMax cropping and honour the asset's filter and wrap. Since the README was written the addon has gained an sceAudio mixer thread, CPU-skinned skeletal meshes repacked per frame, instanced meshes, particles, 2D UI with texel-space texcoords, save data under ms0:/PSP/SAVEDATA/<discId>/, out-of-tree builds under Intermediate/PSP/, and — 2026-08-29 — Content Pak support. Constraints: 32 MB RAM (PSP-1000), 2 MB VRAM with roughly 1.3 MB left for textures after the 480x272 double buffer and 16-bit Z, double is software-emulated, sceGum* corrupts issued draws (use sceGuSetMatrix), no .d dependency tracking, -j4 default.
  • Dreamcast — com.polyphase.build.target.dreamcast (v1.0.0, apiVersion 4, homebrew.dreamcast, Dreamcast (KallistiOS), category Retro Consoles). In progress. Compiles via kos-c++ through Makefile_Dreamcast, out-of-tree in Intermediate/Dreamcast/, with three toolchain routes selectable in Target Options: DreamSDK on Windows via dreamsdk-runner.exe, a native KallistiOS install sourcing $KOS_BASE/environ.sh, or KallistiOS under WSL. GetCompileCommand writes polyphase_build.sh and runs it under the chosen shell. Output is a KOS .elf wrapped into Packaged/homebrew.dreamcast/<name>.cdi or .gdi via mkdcdisc (scramble → makeip → mkisofs -C 0,11702 → cdi4dc), plus a Packaged/selfboot/ folder with 1ST_READ.BIN for mastering a retail-BIOS disc with BootDreams. Config.ini is rewritten to 640x480. Options: Region (NTSC-U / NTSC-J / PAL), Disc Format, Parallel Jobs. Runs in flycast (default, DC_EMULATOR override), lxdream or redream; hardware via dc-tool-ip -x to a dcload-ip console. Commit history through 2026-07-22: builds and runs, PowerVR2 mesh rendering with scene and asset loading from disc, static meshes, skeletal meshes, UI, audio playback with a streaming path optimized to play any size clip, and point lights. Only EmbeddedScripts.cpp is linked (16 MB RAM); assets load from /cd/. Logging goes to SCIF serial and fault PCs map through sh-elf-addr2line. The README's platform matrix still describes the Phase 1 state and is out of date.
  • PlayStation 3 — com.polyphase.build.target.ps3 (v1.0.0, apiVersion 4, homebrew.ps3, Sony PS3 (PSL1GHT), category Retro Consoles). In progress. Cross-compiles for the Cell PPU via the ps3dev / PSL1GHT toolchain (ppu-gcc) through Makefile_PS3, links a .elf, fake-signs it into a directly bootable .self, and wraps it into an installable .pkg (make_self_npdrm + sfo + pkg + package_finalize). Launches in RPCS3 or deploys via ps3load. Linux/macOS native; Windows through WSL2. Graphics_PS3RSX with Cg vertex and fragment shaders for mesh, particle and UI, generated into Ps3Shaders_gen.h; Ps3LibcLocks.cpp and a compat/ shim cover toolchain gaps. Commit history 2026-07-18 to 07-23: bootable on emulator, engine loads with a test cube, scene and mesh loading, fog, UI rendering, a memory fix, and stabilization of UI-in-scene builds. Engine-side, Stream::Scan was reworked to hand-parse the %[^=]=%[^\n] shape because PS3's newlib vsscanf faults on scanset conversions.
  • Nintendo 64 — com.polyphase.build.target.n64 (v1.0.0, apiVersion 4, homebrew.n64, Nintendo 64 (libdragon), category Retro Consoles). In progress. libdragon toolchain through Makefile_N64; Runtime/N64/ carries Graphics_N64RDP, Audio_N64, Input_N64, Network_N64, System_N64, a N64SoftMath.c software-math shim and a StdThreadingStub.h. Commit history 2026-05-23 to 05-26: packages to a ROM and draws, debug rotating cube, static meshes load and tick, static meshes lit. Textures not yet.
  • Original Xbox — com.polyphase.build.target.xbox (v1.0.0, apiVersion 4, homebrew.xbox, Original Xbox (nxdk), category Retro Consoles). In progress. nxdk toolchain through Makefile_Xbox; Runtime/Xbox/ carries Graphics_NV2A with Cg vertex and pixel shaders, audio, input, network and system backends, and a Compat/ libc shim. Single commit 2026-07-23: loads scenes with directional lighting, static meshes and textures; geometry still broken.
  • Nintendo DS — com.polyphase.build.target.nds (v1.0.0, apiVersion 3). Scaffold only. A single Source/ComPolyphaseBuildTargetNds.cpp and package metadata; no buildTargets entry, no runtime.
  • Web (WebGL2) — com.polyphase.build.target.webgl2 (v1.0.0, apiVersion 4, web.webgl2, Web (WebGL2), category Web). Compiles the engine plus Runtime/Web into a single-threaded WebAssembly module with Emscripten (emcc, emmake, Makefile_Web), packages cooked content (Content.pak or loose tree) into a MEMFS preload bundle with file_packager.py, and serves Packaged/web.webgl2/ (index.html, <name>.html/.js/.wasm, <name>.data) on http://localhost:6931/. Runs from any static host — no SharedArrayBuffer, COOP or COEP. Options: Optimization (O0/O2/O3/Oz), Initial Memory (256 MB floor, growth on), Parallel Jobs (8), Server Port, Network Relay URL, Makefile override. Embedded profiles are rejected; Lua is always embedded in the wasm. Windows native (emsdk probed from %EMSDK%, C:\emsdk; needs GNU make from devkitPro MSYS2 or C:\msys64), Linux/macOS native, WSL fallback. Renders through WebGL2 / ESSL 300: static meshes, CPU-skinned skeletal meshes, 3D text, particles, UI, debug lines, fog, ambient + directional + point lights; shader-graph materials degrade to standard shading; voxel, terrain, tilemap and light baking not implemented. Audio via Emscripten OpenAL (static, panning, pitch, loop, streaming; starts after a user gesture). Input: keyboard, mouse with pointer lock, touch, gamepads. Saves are session-local MEMFS (IDBFS planned). HTTP via fetch() in Http_Web.cpp (no Http::SendSync, CORS applies, VerifySsl and MaxRedirects ignored). Multiplayer tunnels NET_* over WebSocket to the Node 16+ relay in Tools/relay/ (wss:// under https). The engine-side prerequisites — AssetManager threadless drain, InputConstants #ifndef guards, PLATFORM_WEB exclusion from the HTTP stub — landed in the beta.11 gap.
  • Web (WebGPU) — com.polyphase.build.target.webgpu (v1.0.0, apiVersion 4, web.webgpu, Web (WebGPU), category Web). Same Emscripten pipeline with Makefile_WebGPU and --use-port=emdawnwebgpu (Dawn; the first build fetches emdawnwebgpu_pkg once). Staging under Build/WebGPU / Intermediate/WebGPU, port 6932, options under webgpu.*; coexists with the WebGL2 target in one project. Requires Chrome/Edge 113+, Safari 18+ or Firefox 141+; unsupported browsers get a shell message. Graphics_WebGPU implements the 91-function GFX_* surface with WGSL ubershaders in WebGPUShaders.h: static meshes, CPU-skinned skeletal meshes, text meshes, terrain, tilemaps, particles, UI, debug lines, fog, ambient + one directional + eight point lights, unlit / dynamic / baked light modes, blend modes, alpha masking. Stubs: shadows, voxels, splats, post-process, hit-check, GPU timestamps; no mipmap generation. The async device is acquired in Module.preRun behind a run dependency; one render pass per frame in GFX_EndFrame; per-draw uniforms on a 256-aligned dynamic-offset ring in 256 KB chunks; pipeline cache keyed on program, layout, blend, depth and topology (about 15 pipelines); TRIANGLE_FAN expanded on the CPU.
  • Android TV — com.polyphase.build.target.androidtv (v0.1.0, apiVersion 4, polyphase.androidtv, Android TV, category Android). Stages the engine's Standalone/Android project into Intermediate/BuildTargets/AndroidTV/, applies Overlay/AndroidManifest.tv.xml (LEANBACK_LAUNCHER, touchscreen not required, leanback feature, @drawable/tv_banner), patches build.gradle and strings.xml, runs gradlew assembleRelease natively, and copies the APK to <project>/Build/AndroidTV/<projectName>-android-tv.apk (.aab via bundleRelease). Options: Application ID, App Label, Version Name/Code, Gradle Task, Signing Mode (debug), Min/Target/Compile SDK (24/33/33), TV Banner PNG, SDK/NDK roots, Device Serial; Force Rebuild wipes staging. Run on Device issues adb install -r and adb shell monkey. Needs Android SDK, NDK, JDK 17+; on Windows needs Git Bash or WSL for the build script. Release keystore signing not wired; .aab install needs bundletool.
  • Android Universal — com.polyphase.build.target.android.universal (v0.1.0, apiVersion 4, polyphase.androiduniversal, Android Universal, category Android). Derived from the TV addon with the overlay removed; patches abiFilters and builds lib/arm64-v8a/libstandalone.so and lib/armeabi-v7a/libstandalone.so into one APK at <project>/Build/AndroidUniversal/<projectName>-android-universal.apk. Default ABI Filters arm64-v8a,armeabi-v7a, overridable to arm64-v8a or arm64-v8a,x86_64. On Windows staging can move to %LOCALAPPDATA%\PolyAndroidUniv\<projectName>\Android to dodge MAX_PATH during CMake/Ninja. Fixes INSTALL_FAILED_NO_MATCHING_ABIS on 32-bit-only Allwinner, Rockchip and MediaTek tablets; the native payload roughly doubles, and armeabi-v7a can expose 32-bit compile errors in third-party C/C++. The engine's Android backend is Vulkan-only, so pre-Vulkan devices will install but not run.
  • Linux ARM64 — com.polyphase.build.target.linux-arm64 (v0.1.0, apiVersion 4, polyphase.linux.arm64, Linux ARM64 (aarch64), category Linux, native.target = engine). Cross-compiles the existing Linux + Vulkan runtime with aarch64-linux-gnu-* against a user-generated Debian 12 sysroot (Scripts/make-debian12-arm64-sysroot.sh via mmdebstrap + qemu-user-static; includes libvulkan-dev, libxcb1-dev, libasound2-dev, libpulse-dev, libshaderc-dev), passing BUILD_SUFFIX=/arm64 so outputs land in Standalone/Build/Linux/arm64/Polyphase.elf and never collide with x64. Options: Sysroot path, Cross prefix, Parallel jobs (8), Extra CFLAGS/LDFLAGS (e.g. -mcpu=cortex-a76), Strip binary, WSL Distro. PostPackage optionally strips and writes LINUX_ARM64_README.txt. Targets Raspberry Pi 4/5 with Mesa Vulkan, Orange Pi 5, Pine64 and similar. Debian/Ubuntu native (crossbuild-essential-arm64), macOS via messense/macos-cross-toolchains, Windows via WSL2. The main trap is glibc/libstdc++ skew — modern hosts emit GLIBC_2.38 / GLIBCXX_3.4.32 references bookworm lacks; the recommended fix is Bootlin's aarch64--glibc--stable-2024.02-1 toolchain via Scripts/install-bootlin-toolchain.sh. No ARM64 editor; no new Platform enum value.
  • Project ZIP Archive — com.polyphase.build.target.zip.archive (v1.0.0, apiVersion 4, archive.zip, Project ZIP Archive, category Archive). Zips the open project into <project>/Packaged/archive.zip/<projectName>.zip (optionally _YYYYMMDD_HHMMSS) with a vendored miniz-based library; always excludes .git/, Packaged/, Intermediate/, Build/. Options: Use .gitignore (on; supports plain names, *.ext, trailing-slash directories and leading / anchors, not ! negation or **), Compression 0–9 (6), Include Assets (on). Because the packaging pipeline has no skip-compile hook, GetCompileCommand drops a ~2 KB placeholder at Build/archive.zip/<projectName>.zip to satisfy the post-compile integrity gate and PostPackage overwrites it with the real archive; basePlatform is Linux solely to skip the Windows-only PE check. Requires apiVersion ≥ 4.

Gameplay Systems

  • FPS Framework — com.polyphase.engine.fps (v1.0.0, apiVersion 4; depends on com.polyphase.engine.combat.core ^1.0.0 and com.polyphase.system.rpg.mission.core ^1.0.0; optional integration with Dialogue Core). Input bindings for Shoot1/2/3, Reload, weapon swap, pause and Z-lock; navmesh-driven enemy AI; missions with story briefs; main, pause and settings menus. C++ MissionRegistry and Fps_Lua bind the FPS global. Lua modules: FPSFramework.lua, FPSInput.lua, WeaponArchetypes.lua (Pistol, Rifle, BurstRifle, Shotgun, SMG, Sniper), EnemyArchetypes.lua (Grunt, Veteran, Sniper, Shotgunner, Heavy), Mission.lua, StoryBrief.lua. API: FPS.SetupDefaultInput(), FPS.Input.Rebind(ctx, action, bindings), FPS.IsZLockEnabled / ToggleZLock / SetZLockTarget / GetZLockTarget, FPS.GetSetting / SetSetting, FPS.GetGameState / SetGameState / TogglePause, FPS.Mission.*, FPS.StoryBrief.Show{title, text, speaker, duration} / Peek / Pop. Reference node-script classes FrameworkPlayer, FrameworkWeapon, EnemyAI (Idle → Patrol → Chase → Attack → Dead with moveSpeed, sightRange, accuracy tunables), MainMenu, PauseMenu, SettingsMenu copy into <Project>/Scripts/FPS/ because the node-script picker only scans the project.
  • Combat Core — com.polyphase.engine.combat.core (unpublished). Damage, hit resolution and combat state shared by the FPS and RPG systems. Listed in the registry as a dependency; the repository is currently empty.
  • Mission Core — com.polyphase.system.rpg.mission.core (v1.0.0, apiVersion 4). Genre-neutral mission framework: C++ MissionRegistry, MissionAsset, MissionAssetRegistry, Mission_Lua. Runtime: Mission.Register{id, title, brief, objectives}, Unregister, Get, List, SetObjectiveState(missionId, objId, "Active"|"Completed"|"Failed"|"Hidden"), AddObjectiveProgress, AreAllObjectivesDone, ClearRuntime. Sugar in Scripts/Mission.lua: Mission.Define{...}, Start, Progress, SetObjective, Complete, Fail, Current, AutoCountKillsOnObjective(objId, predicate). Assets: Mission.GetAsset(id) (id, title, brief, briefSpeaker, sceneName, order, locked, objectives[], loadout[]), ListAssets, LoadAllAssets; flow in Scripts/MissionFlow.lua: Mission.Flow.Pick / Accept / Begin / Cancel / CurrentMissionId. SignalBus events Mission.Started / Completed / Failed / ObjectiveProgressed / ObjectiveChanged, Mission.Briefing.Requested / Accepted / Cancelled, Mission.Flow.Started / MissionCompleted. Editor: Asset Browser → New → Mission → MissionAsset with Mission, Scene, Objectives and Loadout sections. OnUnload clears both registries; the Lua global is rebuilt on every load.
  • Items Core — com.polyphase.system.items.core (v1.0.0, apiVersion 4). ItemInfo asset (schema v1; Identity, Behavior, Category, Signals, FX sections: name, category, autoUse, per-category effect parameters, icon, world mesh, pickup sound and VFX) backed by a C++ ItemInfoRegistry with live-pointer lookup, eager-loaded on first tick in shipped builds. Lua: Items.GetItemInfo(name) → displayName, category, amount; Items.ListItems; Items.Category and Items.Apply(player, itemName, count) in the project-side Scripts/FPS/Items/Items.lua sugar layer so Heal / Ammo / Shield / Weapon handlers are overridable. Emits FPS.Item.Pickup / FPS.Item.Used on the SignalBus. Create Asset > Items > ItemInfo.
  • RPG Loot — com.polyphase.system.rpg.loot (v1.0.0, apiVersion 4; talks to Items Core by item-name strings at the Lua layer, no C++ dependency). LootEntry asset (Item Name, Weight, Min/Max Count, condition tag) and LootTable asset (schema v2: Table Name, Rolls, Roll With Replacement, guaranteed and weighted LootEntry arrays; v1 parallel-array shape rejected). Lua global Loot: C++ GetTable, ListTables, HasTable; project-side Scripts/FPS/Loot/Loot.lua adds Loot.Roll(tableName, { tags = {...} }) → {item, count} list, Loot.GiveReward(player, tableName, ctx), Loot.SpawnPickup(itemName, count, position), Loot.AddToTable overlays. Create Asset > Loot > LootEntry / LootTable; dockable Window > Loot Table Editor. Roll logic deliberately lives in Lua for debuggability.
  • Dialogue Core — com.polyphase.system.character.dialogue.core (v0.1.0, apiVersion 3; ships a prebuilt Windows x64 DLL as a release asset; optional companion com.polyphase.system.character.dialogue.ink converts compiled *.ink.json to .dialogue at editor time). DialogueAsset imported from .dialogue JSON (start, speakers[] with id / name / portrait, nodes[] with id / type / speaker / portrait / text / jumpTargetId, node types line and end). DialogueRunner3D (Node3D) drives a conversation and emits signals (runner:StartDialogue()); DialogueBoxWidget renders line, portraits, choices and press-to-continue, bound via a Runner Path property. Lua: instance methods plus a global Dialogue.* module (Dialogue.SetGlobalString is referenced by the FPS addon). Editor: a preview window that steps through a dialogue without a scene, and a validator for missing speakers, unreachable nodes and broken jumps. Console-safe by construction (no RTTI, no exceptions); not yet validated on 3DS/Wii/GameCube hardware. Deferred: visual graph editor, NodeGraph dialogue nodes, Yarn / full Ink runtime bridge. The LanternsAndLies-Demo-Game repository is built on it.
  • World Stream — com.polyphase.system.worldstream (v0.1.0, apiVersion 3; Windows, Linux, GameCube, Wii, 3DS). Sector streaming: WorldStreamSource3D (attach to the player, camera or party leader), WorldStreamSector3D (Mode DistanceRadius or TriggerVolume, radii, extents, Scene Override, Always Resident), and a WorldStreamSector asset (sectorId, sceneName, priority, unloadMode, alwaysResident, memoryKB) importable from a key=value .wssector file. Sectors load when a source enters the load radius and unload after Unload Delay seconds outside the unload radius. Lua: WorldStream.SetEnabled(bool), SetCallback("OnWorldStreamSectorLoaded", fn(sectorId)), LoadSector(id, bool), UnloadSector(id), UnloadAll(), GetStats() → activeSectors, queuedSectors, estimatedMemoryKB. Editor: Create Asset → WorldStream → World Sector; View → World Stream window with platform policy, sector state, distance to nearest source and manual load/unload. Console defaults: one load and one unload per frame, two active sectors.

Formats

  • Video Player — com.polyphase.formats.video (v1.0.0, apiVersion 3; Windows, Linux, GameCube, Wii, 3DS). Announced in beta 9; since then it has gained Wii/GameCube playback via built-in software decoders (PCV1 recommended; THP plays with limited audio because the DSP-ADPCM LPC encoder is disabled in favour of Nintendo's fixed coefficient table), 3DS PCV1 playback, an editor-only cook step with Tiny / Balanced / Quality / Custom presets and a Test Cook for Platform action, and audio as the master clock so loop and sync behave identically everywhere. VideoClip asset (.mp4 / .mov / .webm / .mkv / .avi / .m4v), VideoPlayer3D node with SetVideoClip, SetLoop, Play, GetTexture and an OnReady signal. Windows uses a vendored LGPL FFmpeg shared build; Linux via pkg-config (FFmpeg ≥ 4.0). N3MV (3DS hardware H.264) cooks but its runtime decoder is scaffold-only.
  • MIDI — com.polyphase.formats.midi (v1.0.0, apiVersion 3; Windows, Linux, 3DS, Wii, GameCube). MidiSequence asset (.mid / .midi / .kar) and SoundFontBank asset (.sf2), both registered via RegisterImportExtension. MidiPlayer3D node: SetMidiFilePath, SetSoundFontFilePath, SetLooping, SetVolume, Play / Pause / Stop / SetTime, live NoteOn / NoteOff / SetProgram, and a procedural builder Midi.CreateSequence{...}; path setters try LoadAsset(name) first. Backend is embedded TinySoundFont (MIT) plus the midifile parser (BSD-2), one tsf instance per player so console voice budgets stay enforceable; a MidiSequencer worker thread pushes PCM at 50 chunks/s via Audio_SubmitStreamBuffer. Per-platform caps via POLYPHASE_MIDI_MAX_VOICES / SAMPLE_RATE / CHANNELS: 3DS 24 voices at 22050 Hz, Wii and GameCube 16 voices at 32000 Hz. Editor: View → MIDI → Asset Viewer, SoundFont Viewer (preset table with click-to-preview keyboard), Piano Roll (note rects, zoom, pan, channel filter, transport, playhead); inspector toggles Pin in Piano Roll and Use for Preview.
  • Webcam — com.polyphase.formats.webcam (v1.0.0, apiVersion 3). WebcamPlayer3D node opens a device, runs a background capture thread and pushes RGBA frames into a Texture: SetCameraName(substring), Open(), Start(), GetTexture(), GrabFrame() (a stable, independent Texture per call); signals OnReady, OnError; inspector properties for camera by name or index, preferred resolution and FPS, mirror X, flip Y. Connector scripts WebcamQuadConnector.lua and WebcamMaterialConnector.lua. Windows: Media Foundation (RGB32 / NV12 / YUY2 with MF-side conversion). Linux: V4L2 with mmap, YUYV and RGB24 only (MJPEG-only cameras fail Open). 3DS: libctru CAM (Inner, Outer-Left, Outer-Right, Outer-Stereo), verified on retail hardware. Wii/GameCube: compile-clean stubs that emit OnError. Editor: Developer → Webcam Devices window. Hot-reload safe — threads, devices and textures are torn down before DLL unload.
  • Gaussian Splatting — com.polyphase.formats.gaussiansplat (v0.1.0, apiVersion 4; repository currently private). GaussianSplatAsset from .ply (Inria: position, opacity, scale, rotation, SH0 colour) and .splat (Antimatter15 32-byte binary); .ksplat rejected. Own GSPLAT_ASSET_VERSION_CURRENT, independent of the engine's. GaussianSplat3D node under Add Node → VFX with a Splat Asset slot and Max Visible Splats (default 20000). Lua: GaussianSplat.Load / Unload / IsLoaded / GetStats (liveNodeCount, visibleSplats, cpuMemoryKB) / SetGlobalSplatCap. Editor: Tools > Gaussian Splatting > Bake Collision Proxy / Bake Console Fallback / Log Splat Stats; File > Import Gaussian Splat. Windows/Linux build with POLYPHASE_GSPLAT_VULKAN=1; GameCube/Wii/3DS with POLYPHASE_GSPLAT_FALLBACK_ONLY=1 (metadata loads, renderer no-ops). The README still describes the MVP renderer (one debug cube per splat, CPU back-to-front sort, cap 4000), but a 2026-09-04 commit adds a compute-based tile rasterizer (TileRasterizer.cpp, ~1,070 lines) alongside the existing splat renderer — SplatTilePreprocess, SplatTileEmit, SplatTilePrefix, SplatTileScatter, SplatTileRender compute passes and a SplatTileComposite fullscreen pass — with node and Lua bindings to select it. SH bands 1–3 dropped on save; collision bake is bounds-only.

Editor Tooling

  • Level Builder Core — com.polyphase.editor.levelbuilder.core (v0.1.0, plugin apiVersion 3, Level Builder C ABI v13; hot-reload safe). The framework half: the dockable Level Builder window (Brush / Palette / Placement / Debug tabs plus dynamic extension tabs registered by siblings; the static Mode tab was retired in favour of viewport-mode activation through the engine's AddViewportMode hook), a separate dockable Kit Editor window (kit metadata, piece add/remove, per-piece editor, Save), name-keyed tool / brush / snap-provider / palette registries, the core-owned LBKitRegistry (every kit under <project>/Kits/ and <project>/Assets/Kits/, folder-mode kit.json + pieces/*.json storage, .kit zip import/export via bundled miniz), viewport preview and raycast dispatch, and the brush spawn-function router. Siblings resolve the single export LevelBuilderCore_GetAPI via GetProcAddress / dlsym with a strict apiVersion == LEVEL_BUILDER_CORE_API_VERSION check; a mismatch silently disables the sibling's tab, so every sibling must be rebuilt after a core rebuild. ABI history: v2 viewport hover/click, v3 core-owned kit registry, v4 spawn-fn registry, v5 socket mutation, v6 kit sharing metadata and .kit pack/unpack, v7 paint-brush drag input and placed-piece enumeration, v10 piece-level mutation (Kit_AddPiece / Kit_RemovePiece), v11 sourceAssetFilter on enumeration, v12 asset name in LBVisitFn, v13 RegisterRebuildFromWorldFn.
  • Level Builder Tools — com.polyphase.editor.levelbuilder.tool.core (v0.1.0, apiVersion 3). Generic brushes — Line, Box, BoxFill, NoiseFill, Replace, Paint, MaskFill — that route placements through the active sibling's registered spawn function so they stay kit-agnostic and work in both Modular and Grid modes.
  • Modular Level Builder — com.polyphase.editor.levelbuilder.modular (v0.1.0, apiVersion 3; depends on Level Builder Core; not yet in the registry manifest). Registers tool Modular Placement, brush Modular Single, snap provider Modular Socket and palette Modular. The single-piece brush spawns a StaticMesh3D for StaticMesh assets or a full prefab via World::SpawnScene for Scene assets. The socket-aware snap provider picks the closest compatible socket on a placed piece and aligns the active piece's matching socket to it; R-yaw is absolute world facing; the R key rotates 90° (gated on !ImGui::GetIO().WantTextInput). Modular tab: kit dropdown with Reload, project-root override and New / Rename / Delete; thumbnailed piece cards; Piece Properties (name, category, icon, size, sockets, Add / Remove Piece, Auto-Detect Size, Apply / Save Kit / Revert) with a drag-target asset slot routed through Kit_SetPieceAsset; a sockets sub-editor; rotation slider; click-in-viewport placement with a World Pos fallback; Debug toggles for sockets, bounds and names. With Edit Sockets on, each socket gets a three-axis transform gizmo (red X, green Y via Viewport_GetMouseWorldRay, blue Z, magenta centre for free X/Z drag) pushing through Kit_SetSocketPosition. A persistent placed-piece registry rebuilds from the live scene tree on mode activation via the v13 hook. Kit storage via core: folder-mode (preferred), single-file legacy JSON (auto-upgraded on first save), Polyphase-native Kenney packs (<Kits>/<Pack>/Models/Polyphase/*.oct + Previews/*.png, auto-recognized), and .kit zip bundles.
  • Grid Level Builder — com.polyphase.editor.levelbuilder.grid (v0.1.0, apiVersion 3; depends on Level Builder Core; not yet in the registry manifest). Uniform-grid block placement — Roblox / Minecraft-style, or prop-on-grid for top-down and first-person dungeon kits. Registers tool Grid Placement, brush Grid Single, snap provider Grid Snap (configurable XYZ cell size, no sockets). Grid tab: kit dropdown sharing core's Kit_* registry, cell-size sliders, grid overlay toggle, text-list piece picker; viewport overlay draws wire cubes at each cell within a camera-relative radius. Registers GridSpawnAtTransform so every tool.core brush works in Grid mode, and rebuilds its placed-piece registry from the world on activation. Missing: per-grid kit subset flag, configurable rotation snap (90° Y only), separate vertical cell size, per-piece collision/overlap toggles, thumbnail picker.
  • Houdini Engine and HDA — com.polyphase.thirdparty.houdini.hda (v1.0.0, apiVersion 9, native.target = editor; Windows only). Imports .hda / .hdalc / .hdanc / .otl; a Houdini main menu with Houdini Engine (session, installs, conversion settings, log) and HDA Instances (libraries, parameters, inputs, bake) windows, plus Houdini → Load HDA. Loads libHAPIL.dll at runtime with version-selected Hapi21Backend / Hapi20Backend (Houdini 21.x / Engine 8, or 20.0 / Engine 6.2; Engine or full license, not Apprentice), all HAPI calls on one worker thread, headless HARS over named pipe or SessionSync over pipe/TCP. Inputs: StaticMesh3D, Node3D groups (up to four inputs), StaticMesh assets, Spline3D curves, point welding, vertex normals and UVs, Cd / Alpha, materials via shop_materialpath / polyphase_material, optional texture-to-Cd sampling. Output: mesh parts → StaticMesh assets and StaticMesh3D nodes, curves → Spline3D, point-cloud instancing via polyphase_asset_path (collapses to InstancedMesh3D for four or more uniform-scale points). Nothing Houdini-specific persists into scenes or builds; no Lua or REST surface. Requires an engine built after 2026-08-01 for Spline3D export. Latest commit adds vertex-color input.

Registry, Examples and Agents

  • Official registry — Polyphase-Engine---Official-Addons. package.json lists the addon repositories, registry.config.json carries categories and hand-authored overrides, and tools/build-manifest.mjs (Node 18+) pulls each repo's package.json plus GitHub metadata into manifest.json, rebuilt by a workflow on every commit, nightly, on workflow_dispatch, and on an addon-updated repository dispatch that an addon repo can fire the moment it publishes. Categories: Examples, Systems, Editor, Formats, Build Targets, Third Party. CreateYourOwnAddonsRepo.md documents standing up a studio, team or personal registry with the same tooling, including the GitHub Actions permissions the workflow needs.
  • Examples. Hello-World-Polyphase-Addon (a single Text Widget), Voxel-Builder-Polyphase-Example, VideoPlayer-Polyphase-Demo, LanternsAndLies-Demo-Game (Dialogue Core showcase), Websocket-Client---Polyphase with Websocket-Server---Basic-Example (the WsEchoTest pair), 3DS-Screens-Example---Polyphase (separate scenes per screen, with screenshots), Serial-Connection-Polyphase-Example.
  • Agents. Polyphase-InfoAgent-OpenClaw, an OpenClaw information agent for the engine, and Polyphase-CodeAgent-OpenClaw, a coding agent that creates feature branches and pull requests against it.

Bug

  • WAV files imported silent or as garbage. SoundWave::Import read the data size at fixed offset 40 and the PCM at 44, assuming the data chunk immediately followed fmt; DAWs and C2PA tools insert LIST / INFO chunks before it. The importer now walks word-aligned chunks through the shared ParseWav, and asserts became logged failures.

  • Stereo durations reported double. mNumSamples is interleaved, but GetDuration divided by the sample rate as if it were frames. Now uses GetNumFrames(); the same fix applied to XAUDIO2_BUFFER::PlayBegin in Audio_Windows.cpp and AudioManager::GetVoicePcmInfo.

  • Nearest-filter textures rendered as linear on 3DS. Three causes: Texture::IsMipmapped() / GetMipLevels() still generated averaged mips for FilterType::Nearest, which blur pixel art when minified; Graphics_C3D used a GPU_LINEAR mip filter, so trilinear made Nearest look Linear at 400x240; and the cook and LQ-downsample path (ResizeForCook) smooth-resampled Nearest textures instead of point-sampling. All three now honour Nearest. Font atlases: Filter Type / Wrap Mode / Mipmapped changes now reach the baked texture via Texture::RecreateResource(), and LoadStream re-applies the font's filter and wrap over stale atlas settings; Font "Mipmapped" used HandleAssetPropChange and now uses Font::HandlePropChange.

  • Creating a project crashed the Linux editor (ImGui_ImplVulkan_RemoveTexture mid-frame). GamePreview::CreateRenderTargets and Disable, and SecondScreenPreview::Disable, freed the ImGui descriptor set immediately; ApplyProjectDefaults reaches that path from OpenProject after DrawPanel has already queued an ImGui::Image with the old handle, so the backend bound a dead set. RADV segfaulted; Windows tolerated it. New EditorImageCache::RetireTexture() defers release to the next frame's RetirePending.

  • BuildTargetRegistry string pointers dangled on Linux. emplace_back reallocation left earlier mDesc string pointers pointing into moved-from small-string storage; RepointDescriptorStrings() now runs after every register and unregister.

  • Scene save and open were pathologically slow with linked scenes. GatherSubSceneOverrides instantiated the linked scene once per visited node, so a large prefab triggered hundreds of full builds and a progress bar that appeared to loop. One default instance is now shared per subtree (Scene::AddNodeDef, Node::Clone, CacheEditSceneLinkedProps) via the new defaultSceneRoot parameter.

  • Headless packages shipped no engine textures. ImportEngineAssets() was skipped when headless, so PNG-backed engine textures had no stubs to cook and the game crashed on its first UI quad with a null mWhiteTexture. It now runs headless too.

  • A texture cook flipped every image written after it. stbi_flip_vertically_on_write is global and was left set, so later SMDH icon, CIA banner and Android icon writes came out upside down. Reset after the write.

  • Editing the 3DS title or icon reported "up to date". BuildCache's manifest key returned "" for canonical targets; it is now "opts" plus the option hash whenever options exist.

  • Addon builds failed on vulkan/vulkan.h with no hint when %VULKAN_SDK% / $VULKAN_SDK was unset and expanded to literal text. Generated scripts now embed ResolveVulkanIncludeDir() and preflight rejects the build with an explanation.

  • Texture fixup modal saves were silent no-ops. TextureImportFixupModal called GetAssetStub(name + ".oct"), but the asset map is keyed by bare name. Fixed to GetAssetStub(name).

  • Keyboard axis emulation was inverted. Stick Y is up-positive; W / I moved to the positive keys, S / K to negative.

  • Startup asserted when Vulkan validation layers were unavailable. VulkanContext::CreateInstance now warns and continues, which is what a shipped .app with no SDK on the layer path needs.

  • InputPromptMap "Any" entries became Mac. Platform::Count was 7 before Mac was appended; pre-v43 files remap raw 7 to Platform::Count.

  • The gamepad axis + button was unclickable in InputMapWindow::DrawGamepadAxes (column width 70 → 150). The addon shadow-copy retry ran an rmdir every frame while a .pdb was pinned; now paced and bounded. BuildDependencyWindow::CheckDevkitToolchain catches DEVKITPPC pointing at \bin, previously a cryptic make failure.

  • Script hot-reload did nothing after opening a project. mScriptHotReload is per-project, read from the project's Config.ini, but FileWatcher::mEnabled was synced from it exactly once at engine Initialize() — which runs before any project is open. Opening a project with hot-reload enabled left the watcher queueing events that Update() discarded every frame, while the preferences checkbox showed it as on. LoadProject now calls SetEnabled(IsScriptHotReloadEnabled()) before registering the Scripts watch, and skips watching entirely when headless. Launching with a -project argument masked the bug, because the project config was read early enough for the one sync to catch it.

  • Hot-reload watches stacked across project switches. CloseProject never unwatched, so each LoadProject appended the project Scripts tree and every addon Scripts tree again, and closed projects kept reporting paths. CloseProject now calls FileWatcher::UnwatchAll.

  • The App Settings Script Hot Reload checkbox never reached the live watcher. It wrote config->mScriptHotReload in place, bypassing SetScriptHotReloadEnabled — the only path that pushes state into the watcher — so it took effect on the next editor launch and disagreed with the identical checkbox in Preferences > General. Now routed through the setter.

  • Builds under OneDrive or Controlled Folder Access silently shipped truncated packages. File writes failed without surfacing an error, producing empty embedded assets that booted to a black screen on GameCube and Wii, where there is no filesystem fallback. New VerifyBuildDirWritable creates the directory, writes and deletes a one-byte probe, and BuildPhase1 runs it against both the packaged directory and Intermediate/ before the cook, aborting with a message naming OneDrive and Controlled Folder Access as the likely cause. Breaking for out-of-tree platform backends: SYS_CopyFile changed from void to bool across Windows, Linux, Android, Dolphin and 3DS, each confirming the destination exists after the copy, because copy is a CMD intrinsic whose exit code is unreliable and cp runs through SYS_Exec. BuildPhase1 accumulates the result over the four .octp and Config.ini stagings and aborts on failure. The default new-project path also moved off Documents to %USERPROFILE%/PolyphaseProjects, which is not OneDrive-redirected.

  • Headless cooks segfaulted right after Cooking assets.... Engine-asset discovery was gated on !mUseAssetRegistry, but AssetRegistry.txt is an output of packaging and never an input to the cook. With the flag on, the root asset directory had no Engine child and BuildData dereferenced a null FindEngineDirectory(), leaving a truncated build log as the only clue. Discovery is no longer gated on the flag, with a null guard in the save lambda and an explicit readable failure message.

  • Docker builds linked stale host object files. External/Bullet/Intermediate/ and External/Assimp/Intermediate/ were copied into the container with mtimes preserved, so container make treated host-compiled objects as up to date and the Makefile wildcard globs linked them in. When the host ran a newer toolchain the objects referenced symbols absent in the container image and the editor link failed. Both paths added to .dockerignore.

  • Addon-target console builds fell back to directory scanning. Console builds force the asset registry on for load speed, but the check was a Platform enum switch over Android, GameCube, Wii and 3DS — addon-target consoles are POLYPHASE_PLATFORM_ADDON builds that match none of those. On PSP that is not merely slow: sceIoDread silently drops entries when file I/O is interleaved, so some assets never registered and the configured default scene was not found. ReadEngineConfig now forces the registry on for any addon-platform build.

  • Consoles booted with no UI when deployed into a per-project folder. Shipped engine content sits at Engine/<sub> relative to the working directory, but console packages are routinely deployed into a per-title folder on an SD card, which puts Engine/ inside the project folder while the working directory stays at the card root — so every engine asset, including default textures, materials and fonts, was missing. Resolved through the new GetEngineContentDir. Embedded builds masked it.

  • Packaged console builds could not find their own project file. BuildPhase1 staged <project>.octp and Config.ini only to the package root, while the standalone LoadProject() opens <projectName>/<projectName>.octp. Both files are now staged to both locations.

  • AssetManager::DiscoverAssetRegistry mis-resolved Engine/ paths on per-project console deploys. Registry lines beginning with Engine/ are now rewritten through GetEngineContentDir. Only the on-disk path is rewritten; RegisterAsset derives the lookup name from the basename, so name-based lookups are unaffected.

  • Unreadable assets tripped a bounds assert. DiscoverDirectory and RefreshDirectory called Asset::ReadHeader without checking the Stream::ReadFile result, so an empty stream faulted inside Stream::Read. Both now check first and log AssetManager: skipping unreadable asset '%s'.

  • DataAsset scripts failed to load in packaged builds. LoadPropertiesFromLua used luaL_dofile, which bypasses both the embedded-script table and Static Content decoding. Now reads through Stream and luaL_loadbuffer / lua_pcall.

  • PS3 faulted parsing Config.ini and .octp. PS3's newlib vsscanf faults on the %[...] scanset conversion. Both engine call sites use exactly "%[^=]=%[^\n]", so Stream::Scan now parses that shape by hand under POLYPHASE_PLATFORM_ADDON with a vsscanf fallback for any other format. A missing va_end was also fixed.

  • Small textures cooked to black on 3DS. tex3ds rejects textures below 8x8, and such a texture came through black on device where Wii and GameCube showed a flat tint. CookTexture now clamps up to an 8-pixel hardware minimum and warns, naming the Downsample Factor and Force High Quality as the fix.

  • Build All crashed on the second profile. The queue's completion gate consulted only ActionManager::IsBuildRunning(), but the build thread exits before FinalizeLocalBuild joins it, and success auto-finalize runs in DrawBuildModal — which is called after PackagingWindow::Draw in the same frame. In that one-frame window the next profile reassigned a still-joinable std::thread, which terminates the process. IsAnyBuildInProgress now also gates on the build thread being joinable, for both the local and Docker paths.

  • The editor crashed at scene save or process teardown inside Property::~Property. A prior LoadStream had left a Property with a corrupted mExtra pointer inside mNodeDefs, so destructing the vector faulted. New Windows-only TryClearNodeDefs wraps clear() in structured exception handling and falls back to LeakResetNodeDefsVector, which placement-news an empty vector over the old header — intentionally leaking the buffer rather than running destructors over corrupt memory. Scene::~Scene deliberately logs nothing on that path, since it can fire during static teardown after the logger is gone.

  • A truncated or miscooked .oct caused gigabyte allocations or a delayed crash. Scene::LoadStream fed garbage uint32_t counts straight to resize(), splashing garbage through every Property and SubSceneOverride slot. Three guards at 65535 now clamp to zero and log, losing that node's overrides instead of taking the process down.

  • A cyclic widget hierarchy crashed the editor every frame. Renderer::DirtyAllWidgets calls Widget::MarkDirty on the world root each frame, and a mChildren list containing itself or an ancestor made the recursion non-terminating. Added a thread_local depth counter capped at 512 plus an explicit self check, warning at most four times to avoid log spam.

  • Property::GetSerializationSize evaluated mExtra unconditionally. Missing parentheses made A + B + C + cond ? x : 0 parse as (A+B+C+cond) ? x : 0, and the sum is always truthy, so the null check never applied. Wrapped so the conditional is an addend.

  • CopyPropertyValues produced invalid Datums. Copying an allocated-but-empty mExtra yielded a Datum with mType == Count that later serialization choked on. Now requires mExtra != nullptr && mExtra->IsValid(), matching the copy constructor, DeepCopy and WriteStream.

  • Scene::Instantiate dereferenced a null mExtra on legacy sub-scene overrides written before GatherNonDefaultProperties recorded node paths. Added the null check alongside the node-datum-type test.

  • Datum::PushBack(const std::string&) terminated the editor via _Xlen_string. GatherNonDefaultProperties walked a subscene-linked node whose Name Datum pointed at a std::string with a garbage internal size field. Strings reporting a size above 1 MiB are now rejected and default-constructed, with a log line.

  • A stale Asset* surviving a project switch crashed on the first PreRender. Quad::ResolveQuadTexture called asset->Is() on a dead vtable. It now checks AssetManager::IsAssetLive and, on failure, clears the reference without decrementing a refcount on freed memory.

  • Node::DisconnectSignal inserted an entry for every unknown signal name. mSignalMap[name].Disconnect(listener) default-constructs on lookup. Now checks the map is non-empty and the name is present first.

  • Play in Fullscreen produced a width x 0 window from a maximized editor. SYS_GetWindowRect reads the restored rect on Windows while mWindowWidth/mWindowHeight hold the maximized client size, so the computed chrome height went negative and collapsed the window. New SYS_RestoreWindow is called before measuring, and EndPlayInEditor re-maximizes from a saved flag.

  • Closing the window with X during fullscreen play-in-editor shut down the whole app. WM_CLOSE unconditionally set the quit flag. Under #if EDITOR, when playing in editor outside a separate game window, it now requests an end to play-in-editor and returns. Related: EditorHotkeyMap::Destroy() moved after EditorState::Shutdown(), because Shutdown can end play-in-editor, which pumps ImGui frames that reach the main menu bar's hotkey queries.

  • Autocomplete dropdowns reopened immediately after a selection. The post-commit suppression latch compared against a lastInputText static shared by every picker drawing in the same frame, so it could not act as a per-id snapshot and cleared instantly. Now snapshots into a dedicated committedInputText.

  • Multiple asset pickers leaked keystrokes between each other. DrawAutocompleteDropdown was rewritten around a singleton active-dropdown id so exactly one is open at a time and the others short-circuit before BeginPopup, removing the global key-state mutation that caused it.

  • ListView rows did not span the list and scrolling never activated. Covered by ScrollContainer::SetContentWidget and the CreateItem cross-axis fill default above.

  • ListViewItemWidget::HandlePointerEvents used this after free. User Lua routinely rebuilds the list from an item callback, freeing the item before EmitSignal, CallFunction and the hover-state write ran. It now holds a weak self pointer and revalidates after every OnItem* call. Clear and RebuildItems also check IsDestroyed() before destroying.

  • LineEdit margins changed with editor camera zoom. Two causes: Widget::UpdateRect's margin arm added mOffset and subtracted mSize raw, but those are authored pixels while the parent rect is in rendered space; and GetParentWidth/GetParentHeight returned scaled letterbox pixels, so container widgets laid out differently in Game Preview than in Scene2D. Margins are now multiplied by mAbsoluteScale and the accessors return the authored target dimensions.

  • ArrayWidget spacing and stretch layout were reworked as part of the flex rewrite; axisTotal is derived from the self rect divided by the axis scale rather than from GetWidth/GetHeight, because the Game View letterbox shortcut in GetParentWidth returns authored rather than scaled pixels.

  • Fog stopped at the skybox. material.mApplyFog became tri-state — 0 off, 1 distance fog, 2 sky fog — with a new ApplyFogSky in Fog.glsl that fades by the view ray's elevation rather than by distance, since the sky sits beyond the fog far plane and distance fog would flood the whole dome. kHorizonFalloff = 0.25 gives roughly a 14-degree fade band, full fog at and below the horizon clearing toward the zenith, so fogged terrain continues seamlessly into the sky. Sky detection is implicit — unlit, depth test disabled, negative sort priority — so no new per-material flag was needed. Skybox3D::Create and the legacy Mesh3D::LoadStream sky fixup now enable it.

  • 3DS fog was broken and its exponential path dead-coded. The LUT builder forced data[0] = 0.0f, making the nearest depth read as fully fogged and corrupting the whole curve, and the delta half of the array was mis-indexed. The 3DS fog LUT is depth-indexed and stores per-sample clarity, with samples in data[0..127] and consecutive deltas in data[128..255]. GFX_SetFog was rewritten to that layout and now applies 1 - clamp(powf(20, -fogAlpha), 0, 1) for FogDensityFunc::Exponential, matching the Vulkan approximation.

  • ImGui asserted on first dock of an addon-registered window. getRootDock() could return a container, whose empty label produced an empty ID in the tabbar's InvisibleButton. The tab slot now descends through containers to the first real tab.

  • Statically linking two addons collided on the plugin entry point. PolyphasePlugin_GetDesc is dllexport in the editor's hot-reload DLL build, which NativeAddonManager picks up via GetProcAddress. The shipped Standalone build links the addon's translation units without that define, so a plain PolyphasePlugin_GetDesc resolved as dllimport — C2491 on MSVC — and two statically linked addons collided on the same symbol. A dual-entry pattern now emits a per-addon suffixed entry point for the static path. The suffix comes from a sanitizer that passes letters, digits and underscores through and rewrites everything else, notably the . and - in dotted addon ids, and it must stay aligned with ActionManager::SanitizeAddonIdForSymbol, which emits the matching declaration into Generated/AddonPlugins.cpp.

  • Addons failed to load with Windows error 126 after a build. The addon's post-build copy step drops companion DLLs — FFmpeg and similar load-time dependencies — beside the freshly linked addon DLL, but MOD_Load searches only the loaded DLL's own directory via LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR. Anything sitting in the source directory but not in the shadow-copy directory vanished from the search path. The shadow copy now mirrors every regular file from the source directory, skipping the addon DLL and PDB already copied and any subdirectories. Load-side failures are also routed through a single helper so HasUnacknowledgedBuildFailures picks them up and the build-failure modal opens with the verbatim error.

  • Near-zero node scale crashed collision traversal. btScaledBvhTriangleMeshShape::processAllTriangles reciprocates every scale axis when mapping the query AABB, so a zero or near-zero axis produced infinite or NaN bounds; the quantized BVH then cast those to unsigned short — undefined behavior on MSVC — and traversal visited garbage leaves until the triangle callback faulted. StaticMesh3D, InstancedMesh3D and PaintManager now pad any axis below 1e-4 to that magnitude, preserving sign so negative-scale mirroring still flips correctly.

  • Changing a texture's Filter, Wrap or Mipmapped setting crashed with a device-lost error. Texture::HandlePropChange rebuilds the image behind an unchanged Texture*, and every pointer-keyed ImGui descriptor cache — the inspector preview, voxel/terrain atlas, brush mask, TilePicker, TextureAtlasViewer, SpriteAnimationAtlasEditor — kept a descriptor bound to the freed view and sampler. The GPU faulted, ReadTimeQueryResults got VK_ERROR_DEVICE_LOST, and then asserted and froze the machine. Texture::GetResourceGeneration() (bumped in Create, Destroy and prop change) now keys every cache, with a DeviceWaitIdle before removal; ReadTimeQueryResults logs and disables GPU profiling instead of asserting.

  • Large textures drew black on 3DS with no diagnostic. Tex3DS_TextureImport fails when VRAM (about 6 MB, with render targets taking about 2 MB) is full. Now retried on the linear heap, and freed with SYS_AlignedFree to match the allocation.

  • 3DS Target Screen only worked on hardware. The filter ran only via separate worlds, so an editor-authored scene rendered an empty bottom screen, cameras inside bottom scenes reported screen 0, and lights from both screens lit both — the doubled diffuse and specular blew GX and C3D to white. Fixed by the resolution-order, camera-override and light-filter work above; the Scene panel and SecondScreenPreview now use the same subtree lookup and honour 0xFF; the Target Screen row is hidden in the inspector unless IsSceneLinked(false).

  • Arrays did not show up in the Properties panel. Property::DeepCopy cleared mIsVector for internal-storage copies while keeping the external mVector pointer. mIsVector now always copies and mVector is null when storage is internal.

  • Game Preview did not follow camera switches. It rendered a pinned mCachedCameras[index]; the default is now the active camera — GetActiveCamera() in play-in-editor, GetMainCamera() otherwise.

  • Spline broke the 3DS build and grew a phantom point. glm::clamp(idx, 0, …) mixed int and int32_t, a template-deduction failure on the 3DS toolchain — literal cast added. And the starter point1 created in Create() reappeared on every scene reload; it moved to OnSpawnedInEditor().

  • The editor crashed during long operations. EditorProgress::Step pumps full frames, so a tab click mid-Scene::Instantiate opened and closed scenes and left the outer operation holding freed Node* and Property pointers (an access violation in WeakPtr::IsValid via Datum::DeepCopy). Added mProgressPumping / IsInsideProgressPump() guards in OpenEditScene / CloseEditScene; CacheEditSceneLinkedProps builds into a local vector; MapReplacedNodeDescendants fixes Node-property refs into respawned sub-scenes; per-index GetNode(nodeIdx) / SetNode(…, nodeIdx).

  • A deleted asset directory left dangling pointers. DeleteAssetDir now scrubs mTabCurrentDir, history, mRevealAssetExpandDirs and mMouseHoveredAssetDir; DrawAssetsContextPopup returns itemDeleted so DrawAssetDirTree unwinds without touching the freed dir. SYS_MoveDirectory now returns bool on all platforms so a failed move does not update the in-memory tree.

  • A REST spawn of an unknown node type crashed the editor. EXE_SpawnNode(const char*) now checks Node::FindFactory and returns null.

  • Lua stack leaks. ScriptFunc::Call / CallR never popped the ref table, and CallLuaFunc left the pcall error object on the stack. Both fixed.

  • Network runt packets and unknown message types. Packets shorter than header + 1 are skipped; an unknown message type now breaks out of the packet instead of spinning forever.

  • AssetManager destructor null-checks the load thread.

  • Ctrl+R re-fired during Reload All Scripts. EditorProgress::Step pumps full editor frames while a blocking operation runs, but input state does not advance, so IsActionJustTriggered(Edit_ReloadScripts) stayed latched on the original press and the handler re-ran the C# transpile (a ~1.5 s dotnet spawn) plus directory refreshes between every script load. Fixed with a !GetEditorState()->mProgressPumping guard.

  • Play In Standalone on the Linux editor built a Windows package. PlayTarget::Standalone always called BuildAndRunWithProfile(Platform::Windows); now Platform::Linux under #else. The Linux packaged binary extension was "" and is now ".elf".

  • Create Project failed when parent directories were missing. CreateNewProject used SYS_CreateDirectory (single level) for the project root, Assets, Scripts and Scenes; now uses CreateDirectoryRecursive.

  • Addon shadow-copy startup noise. StageShadowCopy walked every path prefix calling SYS_CreateDirectory, mkdir-ing the bare drive root and already-existing components, producing _mkdir error: Permission denied plus a run of File exists warnings per addon at launch, and ignored failures so an uncreatable shadow directory surfaced later as a confusing copy error. Now uses CreateDirectoryRecursive and returns outError = "could not create shadow dir ..." on failure.

  • SceneNodeDef::mParentBone was int8_t, silently corrupting bone attachments on rigs with more than 127 bones (fingers and twist bones). Widened to int32_t under v39.

  • Gizmo drags on bone-attached nodes exploded the scale. The gizmo writes through SetTransform(worldMatrix), which back-solves position, scale and rotation every drag frame regardless of gizmo mode. Node3D::SetWorldScale divided the extracted world scale by only the parent node's scale, never the bone term — and the bone matrix carries mInvRootTransform, the FBX/glTF unit-conversion scale, 100× on Mixamo rigs — so each frame stored S_bone · S_local and the next frame extracted S_bone² · S_local. Every gizmo frame multiplied the scale by the bone factor, and because SetTransform calls all three setters it fired on translate too. Now uses Maths::ExtractScale(GetParentTransform()), which is provably identical for non-attached nodes.

  • Node3D::SetWorldRotation double-counted the parent rotation for bone-attached nodes: it multiplied parent->GetWorldRotationQuat() by GetBoneRotationQuat(), but that getter already bakes in the mesh node's world matrix. Invisible at identity rotation, wrong as soon as the character turned. Now derives from GetParentTransform().

  • Node3D::GetParentTransform returned identity on a subclass. The else if (GetType() == SkeletalMesh3D::GetStaticType()) was an exact-type compare with no else, so any SkeletalMesh3D subclass — or a stale bone index — teleported the node to the world origin. Now seeds from the parent transform and uses As<SkeletalMesh3D>().

  • SkeletalMesh3D::GetBoneTransform returned an uninitialized glm::mat4 for an out-of-range index; now identity.

  • ForwardSpec.frag ignored mIntensity for directional and point lights; now multiplies mColor * mIntensity like Forward.frag.

  • ScriptUtils::RunScript leaked Lua stack slots. LUA_MULTRET results and error objects were never popped, so every reload sweep grew the main stack; lua_settop now restores the snapshot.

  • Hot-reloading a boot script such as StartLuaPanda.lua reopened the debugger socket and stole the Lua hook from LuaDebugger; excluded via IsReloadExcludedScript.

  • Dolphin (Wii/GameCube) TCP connect always reported failure. IOS connects are asynchronous: net_connect returns -EINPROGRESS immediately, and the old rc == 0 test read a healthy in-progress connect as a hard failure. Fixed with IsConnectInProgress(); note libogc's lwip/arch.h numbering (EINPROGRESS=115) is not what the compiled call returns — on device it is newlib's -119, so <errno.h> is used. NET_SocketConnect is now ConnectAsync plus a poll loop (NET_DOLPHIN_CONNECT_TIMEOUT_MS 15000) that restores blocking mode.

  • Dolphin socket creation silently produced bogus handles. net_socket returns a negated errno, not -1, so an error like -22 passed the NET_INVALID_SOCKET test; new CreateDolphinSocket() normalizes to -1 and logs. The stream socket now uses IPPROTO_IP (IOS rejects IPPROTO_TCP). <poll.h> is included only under PLATFORM_WII because libogc2's GameCube tree has no poll.h. NET_Initialize now logs ip/mask/gateway and warns when there is no default gateway, since off-subnet connects fail instantly rather than timing out.

  • SYS_OpenDirectory (3DS/Dolphin): removed the "Could not open directory" error spam for optional directories probed at startup (Packages/, Scripts/, engine-content fallback) and the closedir(nullptr) call, which is undefined behaviour on newlib.

  • macOS: Cmd shortcuts dead in text fields, held keys typed once, keyboard lost after dialogs and fullscreen (beta.18) — three root causes, see Input fixes under macOS Platform.

  • UI-only scenes drew offset into a corner with black bands (beta.18). No Camera3D meant no scene pass and no full-window paint; a transient fallback camera now fills the gap, see Engine and Rendering.

  • UI clipped when Resolution Scale < 1.0 (beta.18). The UI render pass inherited the scene scissor; it sets its own now.

  • ContentPak::MountMemory crashed on every call (beta.18). sMounts.push_back(std::move(mount)) was followed by LogDebug("ContentPak: mounted '%s' from memory (…)", mount->mDebugName.c_str()) through the moved-from unique_ptr; it died inside std::string's SSO check and looked like string corruption. The log moved above the push_back.

  • PS2 hardware: not a valid pak, scrambled index, _request_end null-packet crash, audio glitching during streaming reads (beta.18) — shared SIF RPC buffer, unaligned cdvdman reads, recycled FILE buffer; see Content Pak Mount Stack and PS2 Hardware I/O.

  • Xbox 360 faulted silently on the first widget (beta.18) — thread_local with no TLS runtime; POLYPHASE_THREAD_LOCAL and POLYPHASE_NO_TLS.

  • Runtime-generated textures sampled black on Adreno / Mali (beta.18) — PREINITIALIZED initial layout with a host-write barrier under no HOST stage, plus an unfenced one-shot upload; see Engine and Rendering.

  • Android Log To File produced no file (beta.18) — relative fopen in a NativeActivity; now internalDataPath.

  • Every native addon's Android nativePerPlatform block was ignored (beta.18) — "android" vs the case-sensitive "Android" key; editor rebuild required.

  • The Android build failed on VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR (beta.17) — not declared by the NDK's vulkan_core.h; the block is #if PLATFORM_MAC.

  • Headless Linux (-serve, SSH, CI) aborted before it could serve (beta.17) — ImGui::GetIO() / GetStyle() asserts with no context in EditorImguiDraw, EditorState::Update and ThemeModule; xcb_poll_for_event on a null connection in SYS_Update; snd_pcm_avail(nullptr) in AUD_Update; the render loop against a null VulkanContext in Engine::Update; EditorProgress pumping a renderer that did not exist. All guarded on IsHeadless() / null device.

  • Headless imports stranded (beta.17) — the texture and WAV fixup modals queued rows nobody could answer, leaving mPixels empty and the save skipped; they now auto-pad / auto-resample and warn.

  • Saving into a new nested asset folder was a silent no-op (beta.18) — single-level CreateDir() in SaveAsset; ancestors are created root-to-leaf.

  • Importing a mesh with no normals segfaulted (beta.18) — mNormals null on a bare .obj; aiProcess_GenSmoothNormals plus null guards.

  • The Logging toggle and a project's Config.ini logging flag had no effect until restart (beta.18) — EnableLog is now called on change and in LoadProject.

  • A scene file duplicated outside the editor opened with the old name in its tab (beta.18) — the serialized Name won over the stub name; LoadAsset reconciles them.

  • A skeletal mesh loaded from disk reported the unit AABB (beta.17) — LoadStream restored the sphere, not mAABB; Create() calls ComputeBounds().

  • LNK2019: Sha256::HashHex in addon builds, then undefined Sha256::HashHex on 3DS / GameCube / Wii (beta.17) — MSVC dropped the unreferenced object; forced linkage; console Makefiles gained Source/Engine/Utils.

  • compile.bat hung packaging on any shader failure (beta.17) — pause at the end of a script the editor streams; and cd without /d never reached the script from another drive. A missing glslc no longer fails a package when Engine/Shaders/GLSL/bin is complete.

  • Installers/package_windows_sdk.py crashed with NameError: name 'os' is not defined (beta.17) at the Vulkan-header staging step on every Windows SDK release run; import os added.

  • A GCC-built editor emitted a corrupt Mac PkgInfo. The generated shell line printf 'APPL????' > "$APP/Contents/PkgInfo" contains the ??' trigraph, which GCC rewrites to ^, so the emitted packaging script wrote garbage; now 'APPL?\?\?\?', same output bytes. BannerGltfExporter.h gained #include <cstdint> (libstdc++ does not provide the fixed-width types transitively where MSVC does), and a */ inside a MacBundlePackager.h doc comment (MacOS/Addons/*.dylib) that terminated the comment early was reworded.

  • SYS_Log on GameCube / Wii re-expanded % in log lines (beta.18) — the formatted buffer was passed as the format string; and the libogc v1 / libogc2 SYS_Report / SYS_Reportv split was backwards relative to its own comment.

  • Addon REST routes silently truncated responses over 4 KB (beta.18) — 256 KB heap buffer.

  • A disc-image build profile could be saved with a layout that does not boot (beta.18) — polyphase.discContent locks Embedded off / Static Content on / Content Pak on.

Deprecated

  • The C++ project type. The project-type radio buttons are commented out — both Lua and C++ in Create Project, and C++ in Clone From Git — so new projects are Lua-only. The underlying mProjectType and mCloneProjectType fields and their code paths remain for now. The recent-projects button was also relabelled Browse... to Open Project.

  • SYS_CopyFile returning void. The signature is now bool on every platform backend and callers are expected to check it. Out-of-tree platform backends must update.

  • Renderer.GetScreenIndex() and Renderer.GetActiveScreenResolution() removed from Lua. Use Node:GetTargetScreen() / Node:GetScreenResolution() on the node, or Renderer.GetScreenResolution(index) with an explicit index.

  • SYS_MoveDirectory returning void. Now bool on every platform backend, alongside SYS_CopyFile.

  • ContentPak::Mount returns MountHandle (was bool); Unmount(MountHandle) (was no-arg); FindEntry(path, MountHandle&, uint32_t&, uint32_t&) (was three-arg); GetPakPath(MountHandle) (was no-arg) (beta.17). Addon callers must update; UnmountAll() replaces the old unconditional unmount, and IsMounted() now means "at least one mount".

  • POLYPHASE_NO_TLS (beta.18) must be defined by any build target for a platform with no TLS runtime (Xbox 360) so Bullet's btThreads.cpp / btQuickprof.cpp drop thread_local; engine code should declare statics reachable on such targets with POLYPHASE_THREAD_LOCAL, not thread_local.

  • Image::Update(const void* srcData, bool waitForCompletion = false) (beta.18) — defaulted, source-compatible; call sites that upload once and sample immediately should pass true.

Licence

  • The repository licence is now MIT-PoU — the MIT License with a Proof-of-Usage Condition (PR #50, ebellumat/proof-of-usage-licence). Every MIT permission is unchanged. The one added condition is addressed to operators of automated systems (training corpora, RAG indexes, agents browsing at inference time, code assistants, evaluation harnesses): record the usage as a row in PROOF_OF_USAGE.md and print a matching provenance hash — SHA-256("SystemName:OperatorName:ISODate:TargetRepositoryURL") — in the product's credits. No payment, no takedown, no restriction on what may be built. New files: AGENTS.md, PROOF_OF_USAGE.md, proof-of-usage.json (PoU/1.0); LICENSE and README.md updated. For humans reading, forking, learning from or shipping with the engine, nothing changes.

Chore

Documentation

  • New: Documentation/Development/SetupEnvironment/Mac.md, Documentation/Development/Platforms/Mac/Overview.md and Packaging.md, Documentation/Development/OcclusionCulling.md (concepts, workflow, bake internals, memory table with a worked example, cell limits, C++ / Lua / REST API), all in mkdocs.yml. Updated: PackagingFlow.md (Mac and CIA rows), Platforms/3DS/Overview.md (SMDH and installable CIA), BuildProfiles.md (3DS targets table), Compiling.md and Terminal.md (Mac steps), Windows.md and Linux.md (DEVKIT* verification by full path, CIA tools), CustomBuildTarget.md and NativeAddon.md (seven built-in targets, MacBundlePackager.cpp as the reference PostPackage, Mac / PSP in nativePerPlatform), CICD.md (header staging, the not-yet-automated macOS .dmg), README.md. Lua docs: Lua/Systems/Renderer.md, Lua/Nodes/3D/Primitive3D.md, Lua/Misc/World.md.

  • .llm/Platforms.md and .llm/Spec.md add macOS (and note the Makefiles are canonical over CMake on desktop); .llm/Graphics.md inserts step 3b OcclusionCull; .llm/Addons.md documents ResolveVulkanIncludeDir(), RunBuildPreflight(), ClassifyBuildFailure(), the "Native Addon Problem" modal, AddonDependencyResolver, cascading removal and update detection. Skills: polyphase-addon gains troubleshooting rows for C1083 vulkan/vulkan.h and missing dependencies, polyphase-buildtarget counts seven targets, polyphase-controller documents the occlusion REST routes.

  • Documentation/Development/SkeletalAnimation.md — full skeletal animation guide covering multi-section meshes, clip extraction and import, avatars and both retarget tiers, including the Tier-2 math and an honest limitations list.

  • Documentation/Development/BoneMask.md — bone masks, the eight animation slots, layer modes and the Bone Mask Editor.

  • Documentation/Development/StaticContent.md — Static Content and Content Pak: container and archive layouts, the decode chokepoints, cook ordering, the Embedded interaction, and explicit "obfuscation, not DRM" messaging guidance.

  • Documentation/Development/StaticContent-BuildTargetBrief.md — what Static Content and Content Pak mean for build-target addon maintainers, the DrawProfileOptions opt-in recipe, and a target classification table. Note that basePlatform is a cook-compatibility anchor, not a statement about the runtime backend.

  • Documentation/Development/PackagingFlow.md gains steps [8b] Static Content / Content Pak and [8c] Romfs copy (3DS). Documentation/Development/CustomBuildTarget.md and Documentation/index.md updated; .llm/AssetSystem.md and .llm/Spec.md refreshed; .llm/SkeletalAnimation.md and .llm/BoneMask.md added.

  • Documentation/Lua/Assets/SkeletalAnimationAsset.md and Documentation/Lua/Assets/HumanoidAvatarAsset.md added. .claude/skills/polyphase-buildtarget/SKILL.md expanded.

  • .github/workflows/docs.yml action versions bumped to clear Node 20 deprecation warnings.

  • 3DS Documentation/Development/Platforms/3DS/Screens.md (resolution order, Lua notes); Documentation/Development/SetupEnvironment/Linux.md (RPM/AppImage host tools); Documentation/Development/NativeAddon.md (exported surface); Lua World.md / Node.md / Renderer.md; mkdocs.yml adds Bone Mask and Static Content pages.

  • Documentation/Info/CSharpScripting.md — C# scripting user guide. Tools/PolyphaseSharp/README.md — architecture.

  • Documentation/Lua/Networking/WebSocket.md — full Lua reference, limits table, Godot WebSocketPeer correspondence table, "keep a reference on self" warning. Documentation/Development/Networking/WebSocket.md — platform matrix, file map, seam rationale, NET_ additions, downgrade opt-in table, testing steps. Tools/ws-echo/README.md.

  • Documentation/Development/BoneSockets.md — quick start (attach, place, promote to socket, reuse), direct authoring, draw/sheathe scripting, notify-driven swaps, effects at a socket without attaching, gotchas. .llm/BoneSockets.md — internals, the three transform bug fixes, notify semantics. Documentation/Lua/Nodes/3D/Node3D.md and SkeletalMesh3D.md gained the socket, time and notify entries; Documentation/Lua/Nodes/3D/SpotLight3D.md added. BoneMask.md and SkeletalAnimation.md cross-link. mkdocs.yml, Documentation/Lua/README.md, .llm/Spec.md (asset version 39) updated.

  • Documentation/Development/SetupEnvironment/Windows.md, Linux.md and Documentation/index.md rewritten for the devkitPro steps: wii-dev / 3ds-dev are the meta-packages that pull in the actual compilers (devkitPPC's powerpc-eabi-g++, devkitARM's arm-none-eabi-g++) — the libogc2 packages are only the libraries, and GameCube compiles with devkitPPC too; the pacman.conf entry is called out as file content, not commands, with both mirror Server lines belonging in the file; each command is now its own fenced block; verification commands added.

  • (beta.17 / beta.18) Documentation/Info/FAQ.md (+159 lines): ## No Camera3D in the scene under the black-screen section, and a new # Android section — Seeing logs from an Android build, Log To File produces no file on Android, Parts of the UI are missing — UI offset into a corner, or large black bands, What Scissor does, and when to turn it off, A runtime-generated texture (photo snapshot, video frame, procedural image) renders black on Android but is fine on Windows, A native addon behaves as if its platform code isn't there on Android, Which changes need the editor rebuilt before packaging for Android?, An Android feature needs a runtime permission (camera, microphone, location). Documentation/Development/SetupEnvironment/Mac.md notes that build-mac installs the same SDK through .github/actions/install-vulkan-sdk-mac, that bumping the version means the doc, the action default and the actions/cache key, and that uploading the unmodified vulkansdk-macos-<ver>.zip to a vulkan-sdk-<ver> GitHub Release makes CI independent of LunarG's CDN retention. .llm/Editor.md gains the CharacterCreator/ row. Skills: polyphase/SKILL.md restructured (fallback-camera section, Scissor reference); polyphase-widget gains scissor and fallback-camera guidance plus a Common Gotchas section (NewTransientAsset<Texture>(), SetRetainPixels before Create(), SetAnchorMode not sizing a widget without SetFullScreen / SetMargins, alpha-0 container cascading, Lua Create() vs Start() ordering, Signal::Emit not prepending the emitter, SetScriptFile path doubling); polyphase-addon gains the case-sensitive nativePerPlatform.Android troubleshooting row and the Android runtime-permission pattern; polyphase-buildtarget gains the explicit-SOURCES Makefile rule with a Python verification snippet.

Refactors and Maintenance

  • BuildDependencyWindow.cpp rewritten whole-file (CRLF churn) alongside the new checks; ProfilingWindow, BuildProfile::GetPlatformOutputExtension / PlatformUsesShaderFiles and BuiltInBuildTargets::kMacId extended for Mac; JsonSettings skips the legacy OctaveEditor migration on Mac; EditorHotkeyMap presets under ~/Library/Application Support/PolyphaseEditor/HotkeyPresets. Engine.vcxproj lists the new Mac sources (.mm as None items) and the occlusion, CIA and Mac packager files.

  • ActionManager extended for mesh material and texture import, animation extraction and import, retargeting, the build-writability probe, content obfuscation and packing, and node-type replacement (roughly 1.6k LOC net).

  • EditorImgui reorganized for the categorized Create Asset menu, the Other-node categories, the quick-spawn toolbars, the Script Panel context menu and its three modals, the game view border, and the autocomplete dropdown rewrite (roughly 1.3k LOC net).

  • SkeletalMesh3d.cpp restructured around slot-based blending, masking, additive layering and binary-search keyframe lookup.

  • AudioManager gains the streaming source pool, the platform-conditional chunk sizing and the optional I/O thread.

  • PackagingWindow gains the Static Content and Content Pak checkboxes and the Build All queue.

  • Widget gains fill anchors, the parent-rect override, the Game Preview letterbox path and the recursion guard; ArrayWidget rewritten around two-pass flex layout.

  • FileWatcher rewritten as a portable polling watcher.

  • Several *Platform_*Types.h regenerations under Standalone/Generated/; AddonInject.cmake paths and the Android demo namespace retargeted to a file-browser sample.

  • .gitignore and .dockerignore housekeeping.

  • Tools/generate_lua_stubs.py accepts Ns::Func in REGISTER_TABLE_FUNC, tracks per-table registrations and emits separate stubs for multi-table binders (Http → HttpResponse and similar).

  • Engine.vcxproj / filters and Makefile_Linux add the MemorySnapshot, Packaging and EditorImageCache sources.

  • Standalone/Generated/ regenerated (~280k lines churn, generated code).

  • Tools/PolyphaseSharp/External/CSharp.lua vendored (~40k lines). Tools/PolyphaseSharp/Tests/ committed with its work/ outputs and a prebuilt lua.exe.

  • Engine/Assets/Scenes/Blueprints/SC_Demo_*Pawn.oct, Levels/SC_Demo_*.oct and demo materials resaved at asset version 39.

  • .claude/settings.local.json and .vscode/settings.json housekeeping.

  • (beta.17 / beta.18) POLYPHASE_VERSION_STRING 6.2.0-beta.16 → beta.17 (21997de92 and 9820acc85 are byte-identical bumps on two parallel branches, neither an ancestor of the other) → beta.18 (inside cd0d422a9). Standalone/Generated/Generated/EmbeddedAssets.cpp / EmbeddedScripts.cpp regenerated (~283k lines); nine Engine/Assets M_Demo_* / M_SkyboxDay .oct files re-saved with no size change; Standalone/Generated/PolyphasePlatform_{Audio,Input,Network,System}Types.h flipped from the PSP target to the PS2 target and a four-line polyphase_xbox360_build.sh committed — regenerated-on-build files carrying absolute developer-machine paths. Standalone/Android template identity (build.gradle namespace / applicationId, strings.xml app name) changed twice by local test projects and reverted to com.you.polyphaseb / Polypohase test in 7cd338f53 (the typo is in the source); AddonInject.cmake regenerated empty. EditorImgui.cpp, Engine.cpp and System_Linux.cpp rewritten wholesale by CRLF/LF normalisation in 3612e567f (65 real added lines across six files). .github/workflows/release.yml create-release needs / download pattern / asset list extended for Mac. Engine.vcxproj + filters, Makefile_Linux and Makefile_Mac list Source/Editor/CharacterCreator.

Next
Next

Changelog - v6.2.0-beta9