Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ pix.zip
obj/
bin/
.vs/
vcpkg_installed/
vcpkg_installed/
__pycache__/
5 changes: 5 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"recommendations": [
"vadimcn.vscode-lldb"
]
}
48 changes: 48 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
// Debug the Android app from VS Code with the CodeLLDB extension (vadimcn.vscode-lldb).
// Build and install first (SpectrumAndroid.sln F7, or build/driver.py android build + package --install).
// The task starts the app and lldb-server and writes projects/android/<mode>/attach.lldb (with the
// app's pid); the commands below mirror build/platforms/android/debug.py: the target is the device's app_process64,
// editor breakpoints stay pending until `target symbols add` loads the unstripped libspectrum.so.
"version": "0.2.0",
"configurations": [
{
"name": "Spectrum Android (debug, full symbols)",
"type": "lldb",
"request": "custom",
"preLaunchTask": "android: app + lldb-server (debug)",
"initCommands": [
"platform select remote-android",
"platform connect unix-abstract-connect:///com.spectrum/platform.sock",
"settings append target.exec-search-paths ${workspaceFolder}/projects/android/debug/lib",
"settings set target.max-children-depth 1",
"settings set plugin.jit-loader.gdb.enable off"
],
"targetCreateCommands": [
"target create /system/bin/app_process64"
],
"processCreateCommands": [
"command source ${workspaceFolder}/projects/android/debug/attach.lldb"
]
},
{
"name": "Spectrum Android (profile, line tables only)",
"type": "lldb",
"request": "custom",
"preLaunchTask": "android: app + lldb-server (profile)",
"initCommands": [
"platform select remote-android",
"platform connect unix-abstract-connect:///com.spectrum/platform.sock",
"settings append target.exec-search-paths ${workspaceFolder}/projects/android/profile/lib",
"settings set target.max-children-depth 1",
"settings set plugin.jit-loader.gdb.enable off"
],
"targetCreateCommands": [
"target create /system/bin/app_process64"
],
"processCreateCommands": [
"command source ${workspaceFolder}/projects/android/profile/attach.lldb"
]
}
]
}
9 changes: 9 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
// .ixx (C++ module interface) is not a language the debugger extensions claim, so VS Code would
// refuse breakpoints in it: treat it as C++ and allow breakpoints in any file.
"files.associations": {
"*.ixx": "cpp"
},
"debug.allowBreakpointsEverywhere": true,
"cmake.sourceDirectory": "C:/Users/Bohdan/Documents/GitHub/Spectrum/custom-overlay/skribidi"
}
38 changes: 38 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "android: app + lldb-server (debug)",
"detail": "Restarts the app on the phone and starts lldb-server; READY means launch.json can attach. Stop the task to shut the server down.",
"type": "shell",
"command": "python",
"args": ["${workspaceFolder}/build/platforms/android/debug.py", "--mode", "debug", "--server", "--launch"],
"isBackground": true,
"problemMatcher": {
"pattern": { "regexp": "^never matches$" },
"background": {
"activeOnStart": true,
"beginsPattern": "^never matches$",
"endsPattern": "^READY:"
}
},
"presentation": { "reveal": "silent", "panel": "dedicated" }
},
{
"label": "android: app + lldb-server (profile)",
"type": "shell",
"command": "python",
"args": ["${workspaceFolder}/build/platforms/android/debug.py", "--mode", "profile", "--server", "--launch"],
"isBackground": true,
"problemMatcher": {
"pattern": { "regexp": "^never matches$" },
"background": {
"activeOnStart": true,
"beginsPattern": "^never matches$",
"endsPattern": "^READY:"
}
},
"presentation": { "reveal": "silent", "panel": "dedicated" }
}
]
}
99 changes: 99 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,105 @@ Open `projects/Spectrum.sln` in Visual Studio 2026. Three configurations: **Debu

Executables output to `bin/Debug`, `bin/Profile`, `bin/Retail`. The working directory for running is `workdir/`.

### Platforms (Android, later others)
Where things live: platform **source code** (entry point, stand-ins for APIs the platform lacks, packaging files) is in
`sources/Platform/<Name>/`, like `sources/HAL/API/<Backend>/` is for graphics backends; the **scripts** that build and run it are
`build/platforms/<name>/<command>.py`, started through `python build/driver.py <platform> <command> [args]`;
`build/clang_modules.py` is the platform-neutral part of a Clang modules build (Ninja helpers, change-only writes, the cached
dependency scan) for the next Clang platform to reuse. Generated output is `projects/<platform>/`. Windows is Sharpmake/MSBuild
only and has no driver. A new platform: a folder in `sources/Platform/`, a folder in `build/platforms/` with its commands, an entry in
`BuildPlatforms` / a solution in `main.sharpmake.cs`.

### Writing platform-specific code
**No `#ifdef _WIN32` / `__ANDROID__` / `SPECTRUM_PLATFORM_*` to pick between platforms** (and no "not `_WIN32`" meaning Android).
Platform differences live in folders named after the platform, and the build picks the folder:

- **Folder per platform inside the layer that needs it**: `sources/HAL/API/Vulkan/Windows/`, `.../Android/`,
`sources/Core/Windows/`, `sources/Core/Android/`. The shared code calls a small function (or macro) with one
definition per platform; it never branches on the platform itself. If a current function mixes shared logic with a
platform part, split it so the platform part is its own function (e.g. the swapchain's `hal_create_window_surface` /
`hal_query_window_extent`, defined in `HAL/API/Vulkan/<Platform>/HAL.Vulkan.Surface.cpp`).
- **Windows builds skip the other platforms' folders** (`BuildPlatforms.NonWindowsFolders` in `main.sharpmake.cs`, applied to
every project through `SourceFilesBuildExcludeRegex`); **the Android build skips `Windows` folders**
(`OTHER_PLATFORM_FOLDERS` in `build/platforms/android/build.py`) and compiles `Android` ones with everything else. A new
platform folder name goes into both lists. New files need `generate_project.bat` (Sharpmake enumerates the sources).
- **Platform code that needs OS headers is a plain (non-module) `.cpp`** with `extern "C++"` declarations on the module side, like
`png_bridge.cpp` and the surface files: `<windows.h>`, `vulkan_android.h`, `png.h` stay out of the module. On Android add such
a file to `PLAIN_TU` in `build.py` if it includes `<vulkan/vulkan.h>` (otherwise the script rewrites the include).
- **Macros needed in the forced include** (`Core/Defines.h` must stay declaration-free, Clang rejects declarations ahead of
`module X;`): a header with the same name in each platform folder (`Core/Windows/Defines.Platform.h`,
`Core/Android/Defines.Platform.h`), found through the include path (Sharpmake adds `Core/Windows`, `build.py` adds
`Core/Android`). `ASSERT` expands to `CORE_ASSERT_FAIL`, defined there; call `CORE_ASSERT_FAIL(msg, __FILE__, __LINE__)`
for an unconditional fatal error instead of `::Core::assert_fail`.
- **First ask whether the difference has to exist.** Several ifdefs were just MSVC-only conveniences: `Exceptions` stores its
own message and overrides `what()` on every platform; the unused Windows `m_fenceEvent` member in the Vulkan `Event` was
deleted. A difference that disappears needs no folder.
- Not an ifdef problem, keep as is: feature tests (`__has_include`, `__cpp_*`), `HAL_BACKEND_*` (graphics API choice), and
`DEV`/`LEAK_TEST_ENABLE` (build configuration).
- Still to migrate (use the folder approach when touching them): the `PLATFORM_RULES` text rewrite in `build.py` for
`VK_USE_PLATFORM_WIN32_KHR` / `VK_KHR_WIN32_SURFACE_EXTENSION_NAME`, and the `SPECTRUM_PLATFORM_ANDROID` / `__ANDROID__` blocks in
`crossguid.ixx`, `TextEngine.cpp` and the tests.
- Vulkan/STL headers are never `#include`d in module code: `import vulkan;` / `import stl.core;`. Macros and `static const` values a
module cannot export are listed in `Modules/vulkan/vulkan_exports.inc` (generated by `gen_exports.py` there).

### Android
`generate_project.bat` also generates `projects/SpectrumAndroid.sln` (configs **Debug/Profile/Retail-Android**).
It holds one NMake project, `SpectrumAndroid`, which depends on `Tools` (so the Windows `spectrum_tool` and the engine
layers below it are built first, like the Windows solution). F7 then runs `driver.py android prepare` (arm64 vcpkg ports,
and the shader/texture/asset precompile with `spectrum_tool`, each skipped while its inputs are unchanged), `... build
--target app` and `... package` (APK in `projects/android/<mode>/spectrum.apk`); a clean `projects/` works from a
single F7. Clean/Rebuild wipe that mode's dir only (not the vcpkg install or the shader cache). F5 runs
`build/platforms/android/run.py` (install, launch, stream `logcat -s Spectrum`). `device.py` picks the phone: one connected
phone is used silently;
with none or several (or `--pick`) a small window opens to select, connect (ip:port) or pair (ip:port + code) one, and a paired
phone advertising over mDNS is connected automatically. `run.py` reinstalls (uninstall, then install) when the installed app
was signed by a different debug keystore. `spectrum_tool` has been seen to crash while shutting down after writing
everything (`assets` command); prepare.py accepts that only when the tool printed its success line. It cannot be an MSBuild/AGDE
build because AGDE has no C++ module / header-unit support; `build.py` does the real compiling.
**Debugging on the device:** run
`python build/driver.py android debug --mode debug -b SomeFunction` (a console lldb): it starts the NDK's `lldb-server` inside the debuggable app,
attaches the NDK `lldb` in a console, and loads the unstripped `projects/android/<mode>/lib/libspectrum.so` for
symbols. Source-level breakpoints, stepping, stacks and locals work in `debug` mode (built with full `-g`; `package.py`
strips the DWARF from the APK copy, the unstripped one stays in `lib/`); `profile` has line tables only (no locals).
Gotchas: `frame variable` on big objects crashes the NDK's Windows lldb at the default depth, so debug.py sets
`target.max-children-depth 1` (use `frame variable -D N x` for more); SIGSEGV/SIGBUS are passed to the app without
stopping because ART raises them itself (`--crashes` stops on ones from our library, not fully reliable).

**Editor breakpoints (VS Code):** Visual Studio's own Android attach needs an AGDE project as the startup project (not
possible here), so use VS Code with the CodeLLDB extension: open the repo folder, pick "Spectrum Android (debug, full
symbols)" in Run and Debug, F5. `.vscode/tasks.json` runs `debug.py --server --launch` (app + lldb-server, writes
`projects/android/<mode>/attach.lldb` with the pid) and `.vscode/launch.json` replays the same lldb commands as `debug.py`.
Gutter breakpoints stay pending until `target symbols add` loads the unstripped library after attach.

**Tests on the phone:** `python build/driver.py android test [--mode debug] [--filter Name]` installs the APK, pushes
`workdir/test_references` (the same golden PNGs as Windows; PNG encode/decode on Android is libpng via
`sources/Platform/Android/png_bridge.cpp` + `HAL.Android.TextureData.cpp`), runs the app in test mode and prints failures;
failing images (`*_actual.png`, `*_diff.png`) and the full log land in `projects/android/test_results/` and `test_log.txt`.
On Android a missing reference is a failure, not "save and pass". Per-platform references are not used yet: the
tests share the Windows ones and the default tolerance (2 per channel) absorbs GPU rounding.
**Test shaders are files, not strings:** a phone has no shader compiler, so every shader a test uses must be a PSO declared in
Prism (`sources/Prism/defs/gpu_tests.prism`, HLSL in `workdir/shaders/test/`) and used as `gfx.set_pipeline<PSOS::Tests::Name>()`,
so `spectrum_tool shaders` precompiles it with the rest. An inline-HLSL `SimpleGraphicsPSO` in a test builds an empty pipeline
on the device, and the draw is silently skipped (no error): the symptom is a render of just the clear colour.

Same commands from a shell: `python build/driver.py android build --mode profile --target app`,
`... android package --mode profile --install`, `... android run --mode profile`.
`generate_project.bat` also writes `projects/android/targets.json` (the engine layer stack, from `EngineLayers` in
`main.sharpmake.cs`, which the Windows projects use too) and `projects/android/vcpkg.json` + `vcpkg-configuration.json` (the
arm64-android subset of the root manifest; the port list is `AndroidMetadata.VcpkgPorts`). `build.py` needs targets.json, so
run `generate_project.bat` first on a fresh checkout (`prepare.py` runs the vcpkg install, ~4 min the first time).
`build.py` caches
dependency scans in `projects/android/<mode>/scan_cache.json` (a no-change build takes ~8 s).

### Kill MSBuild after every build
After any `msbuild` invocation you run, always kill the lingering worker
(`/nodeReuse:true` keeps `MSBuild.exe` alive after the build). It locks files,
blocks Visual Studio / AGDE / other installers, and often prevents Claude Code
from being restarted. Pass `-nodeReuse:false` when building, and also run:
```powershell
Get-Process MSBuild,VBCSCompiler -ErrorAction SilentlyContinue | Stop-Process -Force
```

## Architecture

### Layer Stack (bottom to top)
Expand Down
104 changes: 104 additions & 0 deletions build/clang_modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Platform-neutral pieces of a Clang C++ modules build (header units, clang-scan-deps, Ninja).

Used by the platform drivers under build/platforms/<name>/ (Android today). A platform supplies the toolchain
(compiler, flags, sysroot), what to compile and its source adaptations; what is here does not depend on it:

* path helpers for Ninja and change-only file writes (a rewrite bumps the mtime and recompiles dependents),
* finding Ninja,
* the dependency scan with a cache (P1689 results keyed on file, command line and headers).

Meant to grow as a second Clang platform (macOS, Linux) is added: header-unit building and the Ninja
emission in the Android driver are the next candidates to move here.
"""
import hashlib
import json
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path


def fwd(p):
return str(p).replace("\\", "/")


def ninja_path(p):
return fwd(p).replace("$", "$$").replace(" ", "$ ").replace(":", "$:")


def find_ninja():
"""The Ninja that ships with Visual Studio, else whatever `ninja` is on PATH."""
vs = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Microsoft Visual Studio"
for p in vs.glob("*/*/Common7/IDE/CommonExtensions/Microsoft/CMake/Ninja/ninja.exe"):
return p
return "ninja"


def write_text(path, text, encoding="utf-8"):
"""Write only when the content differs. Everything generated goes through here: a rewrite bumps
the mtime, and Ninja then recompiles everything that depends on the file, which for a patched
header or combined header unit is the whole tree."""
data = text.encode(encoding)
if path.exists() and path.read_bytes() == data:
return
path.write_bytes(data)


def run(cmd, **kw):
return subprocess.run([str(c) for c in cmd], capture_output=True, text=True, **kw)


def headers_fingerprint(roots, suffixes=(".h", ".hpp", ".inl")):
"""One hash for a set of header trees: path + mtime + size of every header under the roots."""
h = hashlib.sha1()
for root in roots:
for f in sorted(Path(root).rglob("*")):
if f.suffix in suffixes:
st = f.stat()
h.update(f"{f}|{st.st_mtime_ns}|{st.st_size}\n".encode())
return h.hexdigest()


def scan_p1689(units, cache_file, header_roots, jobs):
"""Runs clang-scan-deps (P1689 format) over `units` = [(path, src, cmd)] in parallel.

Returns [(path, rule, returncode, stderr)] in the order given; `rule` is the scanner's first rule (what
the file provides / requires), empty when the scan failed.

A result depends on the file, the exact command line and, in principle, on the headers it includes, so
the cache key covers all three. Headers are fingerprinted as one set, not per file: an edited header
rescans everything (the cost of no cache), while the common edit (a .cpp/.ixx body) rescans only that
file. Failed scans are not cached: they are rerun, and so reported, every time.
"""
cache_file = Path(cache_file)
try:
cache = json.loads(cache_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
cache = {}
stamp = headers_fingerprint(header_roots)
fresh, hits = {}, [0]

def scan(unit):
path, src, cmd = unit
st = Path(src).stat()
key = hashlib.sha1(("\0".join(map(str, cmd)) + f"|{st.st_mtime_ns}|{st.st_size}|{stamp}").encode()).hexdigest()
hit = cache.get(str(path))
if hit and hit["key"] == key:
fresh[str(path)] = hit
hits[0] += 1
return path, hit["rule"], hit["rc"], hit["err"]
r = run(cmd)
try:
data = json.loads(r.stdout)
rule = data["rules"][0] if data["rules"] else {}
except Exception:
rule = {}
if rule:
fresh[str(path)] = dict(key=key, rule=rule, rc=r.returncode, err=r.stderr)
return path, rule, r.returncode, r.stderr

with ThreadPoolExecutor(jobs) as ex:
results = list(ex.map(scan, units))
print(f" {hits[0]} of {len(results)} from cache")
cache_file.write_text(json.dumps(fresh), encoding="utf-8")
return results
Loading
Loading