Compare commits

..
3 Commits
Author SHA1 Message Date
zwitschi 659b0e35b2 Make the vision judge discover cost-effective OpenRouter models
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m6s
Add openrouter_judge.py to rank live image+JSON models by advertised price,
with ordered fallbacks, auth-error handling and auto selection. Wire both judges
to --model auto, record the model used, and add fake-catalogue/mocked-call
tests. Re-run the judges with the valid key: 13/13 screen designs match, 0 diff;
the Forward+ A/B report records the selected model and ~$0.006 spend. Headless
231/231, Python 94/94.
2026-11-01 19:55:43 +01:00
zwitschi 28f2a48811 Add the optional Forward+ desktop HQ build
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 5m20s
Package a Forward+ preset with volumetric fog, SDFGI and a volumetric flashlight
for a separate Linux/Windows Desktop HQ zip. Gate the effects on a real
RenderingDevice, tune LATE fog, and adapt local light falloff for Godot 4.7.
Record the Compatibility/Forward+ A/B captures and metrics, add the artifact to
the build/publish scripts, and keep the default Compatibility/Web path
unchanged. Headless 231/231, Python 87/87; exported HQ binary boots with Vulkan.
2026-11-01 19:40:42 +01:00
zwitschi 64f0c6a198 Document the local Godot 4.7.2 toolchain
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 38s
Record the upgraded 4.7.2 binary, the retained 4.3 backup and the matching
export templates. Keep the project/CI baseline at 4.3 and note that 4.7.2
verified import, tests, the session smoke and a Linux export. Hide the
4.7-generated per-script UID sidecars from this clone only.
2026-11-01 19:16:55 +01:00
29 changed files with 843 additions and 74 deletions

No files matched your search

+43
View File
@@ -4,6 +4,49 @@ All notable user-facing changes to Gig Line. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
Semantic Versioning.
## [0.7.2] — 2026-11-01
### Added
- Automatic OpenRouter model selection for the screenshot judges: the cheapest
currently available image+JSON-capable model is used, with ordered fallbacks
and a pinned `GIG_LINE_JUDGE_MODEL`/`--model` override.
### Changed
- The judge reports the model that produced each verdict and fails clearly on an
invalid API key instead of retrying other models.
- The screen-design and realism reports now include the fresh judge results.
### Fixed
- The blocked screenshot judge now runs with the valid `OPENROUTER_API_KEY`
(`13/13` screen designs match, `0` diff).
## [0.7.1] — 2026-11-01
### Added
- An optional Desktop HQ build for Windows and Linux that uses Forward+,
volumetric fog and SDFGI, packaged with an `override.cfg` beside the
executable.
- Volumetric flashlight haze in the Desktop HQ build, with a LATE-phase preset
tuned so the entrance facade stays readable.
### Changed
- The normal Compatibility build remains the default; the preset only affects
the extracted Desktop HQ copy.
- Night-time light falloff adapts to Godot 4.7 while keeping the 4.3 baseline
unchanged.
### Fixed
- The flashlight no longer disappears at night on Godot 4.7 under the old
attenuation curve.
- Headless and Web builds stay non-volumetric and boot normally when Forward+
is unavailable.
## [0.7.0] — 2026-11-01
### Changed
+57
View File
@@ -1279,3 +1279,60 @@ have no stage in the committed `docs/screenshots/` gallery, only in `capture_rea
0 bad**; the new screenshots are 18.2 / 32.6 MAE against their references.
- [-] Update `docs/architecture.md` (venue geometry), `README.md` (gallery) and the screenshot
table when the stages land.
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
Completed 2026-11-01. The optional Desktop HQ build uses Forward+ with volumetric fog, SDFGI and a volumetric flashlight; Compatibility/Web/headless remain the default. The entrance A/B improved the metric gap 1.568 → 1.187. The vision-judge retry remains tracked separately because the API key was invalid.
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
no visible shaft. A second desktop build can render it properly without touching the CI/default
path.
- [x] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
and keeps the Compatibility build as the default (`project.godot` unchanged). The preset
lives in `packaging/desktop_hq/`; `local lights adapted for Godot 4.7 falloff` too.
- [x] Gate the effect in code: only give the flashlight a volumetric component when the renderer
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
untouched and the existing headless tests keep passing. Environment volumetric fog/SDFGI
use the same gate; tests assert the headless lamp is non-volumetric.
- [x] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
entrance so the shaft does not wash the facade again. LATE density is 0.020 and the lamp
volumetric energy is 3.0; the entrance read improves without bleaching the brick.
- [x] A/B the two renderers with `capture_realism.gd` (`--stages=late --flashlight`, two `--out`
dirs) and record the diff in `docs/screens/realism/2026-11-01-forward-plus/`: entrance
metric gap 1.568 → 1.187, two-shot mean 0.907 → 0.723. Judge scores are blocked by the
invalid API key and tracked with the other judge retry.
- [x] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
`gig-line-desktop-hq-<version>.zip` packages the Linux/Windows binaries, override.cfg and
README; `publish_release.sh` uploads it when present.
- [x] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
to Compatibility instead of failing to start). Verified with the preset in the project
root: `godot --headless --path . --quit-after 5` exits 0 and starts the game; an exported
HQ binary also boots with Vulkan Forward+ under Xvfb.
## Vision judge reliability (v0.7.2)
Completed 2026-11-01. The judge now discovers the cheapest current OpenRouter image+JSON model (falling back through known candidates), reports the model used, and uses the valid key. Full screen-design judge: 13/13 match, 0 diff.
The judge failed with an invalid-key HTTP 400 even though the `.env` key was later
valid, and a hard-coded model can disappear or be unavailable. Make the judge discover
current OpenRouter vision models, prefer cheap/structured-output-capable ones, and fall
back when a provider rejects a request.
- [x] Add `tools/openrouter_judge.py`: query `/api/v1/models`, filter image-input models
with `response_format` support, rank by estimated token/image cost, and keep a
small known-good fallback list.
- [x] Let both `screen_designs.py judge` and `scene_concepts.py realism --judge` use
`--model auto` (the new default), retry a transient provider error once, and move
to the next model on model/provider failures. An invalid key fails with exit 2.
- [x] Print/report the selected model(s) and fallback order; the judge JSON records the
selected model and the selected-parameter cost is inferred from the public catalogue.
- [x] Add `tools/test_openrouter_judge.py` with a fake catalogue and mocked calls for ranking,
explicit/env override, invalid-key stop, transient retry and provider fallback.
- [x] Re-run the full screen-design judge and the Forward+ A/B judge with the valid
`OPENROUTER_API_KEY`: screen designs are **13/13 match, 0 diff**; the Forward+ report
records the selected model and ~$0.006 spend.
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the
public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time).
+17
View File
@@ -44,6 +44,23 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
copied executable to Forward+; `Materials.forward_plus_active()` gates
volumetric fog/SDFGI and `light_volumetric_fog_energy`, and
`Materials.modern_light_falloff()` compensates for Godot 4.7's much harsher
local-light falloff. LATE volumetric density is 0.020 with flashlight
volumetric energy 3.0. `tools/build_releases.sh` packages the Linux/Windows
binaries + override into `gig-line-desktop-hq-<version>.zip`;
`publish_release.sh` uploads it when present. A/B report:
`docs/screens/realism/2026-11-01-forward-plus/` (entrance gap 1.568→1.187).
- **Local Godot toolchain (2026-11-01):** `~/.local/bin/godot` is 4.7.2 stable;
4.3 stable is kept as `~/.local/bin/godot-4.3.stable`. Export templates for
4.7.2 are installed under `~/.local/share/godot/export_templates/4.7.2.stable`
(4.3 remains). The project and CI still pin 4.3 in `project.godot`/the Gitea
workflow; 4.7.2 imports, runs the 231-test suite, session-shell smoke and a
Linux export successfully. Godot 4.7 writes `*.gd.uid` files; they are kept
out of this clone's status via `.git/info/exclude` until the project upgrades.
- **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python
/run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`.
The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is
+2 -2
View File
@@ -168,11 +168,11 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
- **Persistence:** JSON in `user://` for settings and shift history.
- **Testing:** custom headless runner (`src/test/test_runner.gd`); GUT was evaluated and not adopted. Python tooling tests cover the asset/screenshot pipelines.
- **Source control/CI:** git on Gitea; `.gitea/workflows/build.yml` runs headless checks and builds/publishes releases on tags.
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, and a Web zip.
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, a Web zip, and an optional Desktop HQ zip (Forward+ volumetric lighting; Vulkan required).
## Versioning and Delivery
`config/version` in `project.godot` is the single version source (currently `0.7.0`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the six artifacts. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
`config/version` in `project.godot` is the single version source (currently `0.7.2`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
## Design Direction
+1 -1
View File
@@ -52,7 +52,7 @@ precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.
If you are here to develop or test locally instead of downloading, jump to
[Setup and Run](#setup-and-run) below.
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4.3 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
## Game Overview
+2 -23
View File
@@ -3,31 +3,10 @@
Work is grouped by theme. Each item should be small enough to complete and verify on its own;
see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these plans.
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
no visible shaft. A second desktop build can render it properly without touching the CI/default
path.
- [ ] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
and keeps the Compatibility build as the default (`project.godot` unchanged).
- [ ] Gate the effect in code: only give the flashlight a volumetric component when the renderer
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
untouched and the existing headless tests keep passing.
- [ ] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
entrance so the shaft does not wash the facade again (the reason the wide cone was cut).
- [ ] A/B the two renderers with `capture_realism.gd` (same stages, two `--out` dirs) and record
the diff + judge scores in `docs/screens/realism/<date>-forward-plus/`.
- [ ] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
- [ ] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
to Compatibility instead of failing to start).
## Realism follow-ups (deferred)
- [ ] Re-run the screen-design judge for `12-joke-shop` / `13-studio-arrival` when
`OPENROUTER_API_KEY` is valid; the 2026-11-01 attempt failed with an invalid-key HTTP 400.
- [x] Re-run the screen-design judge for `12-joke-shop`, `13-studio-arrival` and the
Forward+ A/B with the valid key: **13/13 match, 0 diff**; the HQ A/B is in the report.
- [ ] Capture a hooded/winter NPC close-up once a scenario spawns types 5-7 (the explicit
`--portrait-type` override is already in `capture_realism.gd`).
- [ ] Human-authored tree/hedge silhouettes: the whole scene now reads well except the foliage,
+10 -2
View File
@@ -15,7 +15,8 @@ time you export, either:
`Godot_v4.3-stable_export_templates.tpz` contents into
`~/.local/share/godot/export_templates/4.3.stable/`.
Verify with:
The templates must match the editor binary you use locally. CI pins Godot 4.3;
the local development machine uses 4.7.2 with its matching templates. Verify with:
```bash
godot --headless --export-release "Linux" build/gig-line-smoke.x86_64
@@ -86,6 +87,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/gig-line-macos-<version>.zip`
- `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web
template; unzip and serve the folder, no COOP/COEP headers required)
- `build/gig-line-desktop-hq-<version>.zip` — optional Forward+ Windows/Linux
build with `override.cfg`; requires a Vulkan-capable GPU/driver
When run in CI (tags/dispatch), the build job also packages:
@@ -94,6 +97,10 @@ When run in CI (tags/dispatch), the build job also packages:
- `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
The optional Desktop HQ zip is assembled by `tools/build_releases.sh` from
`packaging/desktop_hq/`; keep `override.cfg` next to the extracted executable.
It is not part of the default Compatibility/Web build.
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI).
@@ -114,7 +121,8 @@ GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
The script creates (or reuses) the release from `project.godot`'s
`config/version`, uploads the three portable artifacts **plus** the NSIS
installer and AppImage when present, and prints the release URL. If CI already
installer, AppImage, Web zip and Desktop HQ zip when present, and prints the
release URL. If CI already
published the release, re-running the script only adds missing assets.
> **Host note**: the release is fully built by CI, but anonymous users can only
+4 -4
View File
@@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
- **PATCH** — bug fixes and small polish that do not change behavior.
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.0`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.2`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
## 3. Context and Scope
@@ -146,8 +146,8 @@ Future options include external analytics, cloud saves, or replay sharing, but t
A continuous-integration pipeline keeps the headless test suite green on every change and publishes tagged releases.
- **Workflow:** `.gitea/workflows/build.yml` runs on pushes, pull requests, tags, and manual dispatches. The `checks` job installs Godot 4.3, imports the project, and runs `res://src/test/test_runner.gd`; it fails on a non-zero exit code or any `SCRIPT ERROR` in the output.
- **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer and Linux AppImage, verifies the installer contents, and uploads the artifacts.
- **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the six artifacts. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
- **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer, Linux AppImage and optional Desktop HQ zip, verifies the installer contents, and uploads the artifacts.
- **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the standard artifacts plus the optional Desktop HQ zip. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
- **Hosting:** the repository lives on Gitea (`https://git.allucanget.biz/zwitschi/security-game`). Export templates must match the pinned Godot version; macOS builds are unsigned and need quarantine guidance or notarization for wider distribution.
### Release management
@@ -160,7 +160,7 @@ Releases are produced from a tagged commit and published to the repository host'
- Packaging — NSIS builds the Windows installer; `packaging/linux/build_appimage.sh` builds the AppImage.
- [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist.
The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the six artifacts.
The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the release artifacts.
## 8. Cross-Cutting Concepts
+12 -2
View File
@@ -185,8 +185,15 @@ python3 tools/scene_concepts.py realism --run docs/screens/realism/<run> --judge
--model google/gemini-2.5-flash --repeats 3
```
Judges default to `--model auto`: `tools/openrouter_judge.py` reads the live
OpenRouter catalogue, keeps image-input models that support `response_format`,
and ranks them by advertised token/image price. The run falls through the list
on provider/model errors and aborts on an invalid key. Use
`GIG_LINE_JUDGE_MODEL` or `--model <id>` for a pinned comparison.
`capture_realism.gd` runs six stages (day, portrait, evening, late, Joke Shop, studio);
`--stages=day,late` limits it while tuning. The `portrait` shot freezes one NPC on open sidewalk
`--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for
night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk
and frames the face, which is the only capture that can judge avatar detail
(`--portrait-type=N` picks a role). The texture-adoption run,
[`docs/screens/realism/2026-11-01-adopt/`](../docs/screens/realism/2026-11-01-adopt/report.md),
@@ -194,7 +201,10 @@ compares the pre-adoption captures with the current build: on the shots both set
judge's like-for-like means rose on every sub-score (overall 0.8 → 1.0, backdrop 0.6 → 1.6) and
the Pillow metric gap fell 0.444 → 0.348. The character pass
([`2026-11-01-npc/`](../docs/screens/realism/2026-11-01-npc/report.md)) adds the portrait shot and
records the new faces, hands and sleeve cuffs. The gear-fit run
records the new faces, hands and sleeve cuffs. The
Forward+ A/B ([`2026-11-01-forward-plus/`](../docs/screens/realism/2026-11-01-forward-plus/report.md))
compares the Compatibility entrance with the optional Desktop HQ preset.
The gear-fit run
([`2026-11-01-gear/`](../docs/screens/realism/2026-11-01-gear/report.md)) checks
the sunglasses, bag and helmet against measured head/skirt bounds with
`python3 tools/blender_assets.py fit`; it also fixed the portrait yaw and hid
+2
View File
@@ -41,6 +41,8 @@ Implemented so far:
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
- Input/reliability fixes: selecting a venue now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures live in `docs/screens/realism/2026-11-01-forward-plus/`.
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
+1 -1
View File
@@ -2,7 +2,7 @@
## Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
1. Download the Godot 4.3+ editor (official build) and put `godot` on your PATH. CI pins 4.3; local development is verified with 4.7.2.
2. Open or run the project from this directory.
## Run and Test
@@ -0,0 +1,232 @@
{
"run": "docs/screens/realism/2026-11-01-forward-plus",
"judge_model": "z-ai/glm-5.3-flash",
"judge_fallbacks": [
"z-ai/glm-5.3-flash",
"qwen/qwen3.7-flash",
"google/gemma-3-4b-it",
"google/gemma-3-12b-it",
"inclusionai/ling-3.0-flash-vl",
"prism-ml/ternary-bonsai-2-9b"
],
"sets": {
"baseline": {
"entrance_late": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 21.3,
"fine_detail": 9.55,
"contrast": 68.03,
"saturation_mean": 127.45,
"colour_spread": 67.44
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.568,
"judge": {
"scores": {
"characters": 4.0,
"overall": 5.0,
"lighting": 5.0,
"texture_detail": 6.0,
"materials": 6.0,
"backdrop": 5.0
},
"score_range": {
"characters": [
4.0,
4.0
],
"overall": [
5.0,
5.0
],
"lighting": [
5.0,
5.0
],
"texture_detail": [
6.0,
6.0
],
"materials": [
6.0,
6.0
],
"backdrop": [
5.0,
5.0
]
},
"runs": 1,
"model": "z-ai/glm-5.3-flash",
"fixes": [
"Increase contrast and deepen shadows across the entire scene, especially in the foreground and under the building overhang, to achieve a more cinematic mood.",
"Boost the reflectivity and wetness of the pavement to create dramatic, high-contrast reflections of the streetlights and sign.",
"Increase the density and volumetric scattering of the atmospheric haze/fog to enhance the sense of depth and distance, matching the target's mood.",
"Improve the character's silhouette and integration by adding strong rim lighting or darkening the model to make it feel more like a dramatic figure rather than a visible asset.",
"Add more micro-detail, grime, and color variation to the brick and concrete materials to make them feel less uniform and more weathered."
]
}
},
"street_late": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 10.57,
"fine_detail": 6.21,
"contrast": 26.84,
"saturation_mean": 74.1,
"colour_spread": 26.55
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.247,
"judge": {
"scores": {},
"score_range": {},
"runs": 1,
"model": "z-ai/glm-5.3-flash",
"fixes": []
}
}
},
"candidate": {
"entrance_late": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 19.31,
"fine_detail": 8.37,
"contrast": 55.27,
"saturation_mean": 104.53,
"colour_spread": 54.55
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.187,
"judge": {
"scores": {
"characters": 5.0,
"overall": 5.0,
"lighting": 5.0,
"texture_detail": 6.0,
"materials": 6.0,
"backdrop": 4.0
},
"score_range": {
"characters": [
5.0,
5.0
],
"overall": [
5.0,
5.0
],
"lighting": [
5.0,
5.0
],
"texture_detail": [
6.0,
6.0
],
"materials": [
6.0,
6.0
],
"backdrop": [
4.0,
4.0
]
},
"runs": 1,
"model": "z-ai/glm-5.3-flash",
"fixes": [
"Implement volumetric fog and heavy atmospheric haze to increase depth and match the target's moody atmosphere.",
"Increase ground material specularity and darken the asphalt to enhance the wet, reflective look.",
"Adjust global illumination and ambient light to increase contrast, allowing for deeper shadows and more dramatic highlights (e.g., on the sign).",
"Apply rim lighting to the character to integrate them into the scene's dramatic lighting scheme.",
"Enhance the color grading towards a high-contrast, desaturated, warm-yellow/orange cinematic palette."
]
}
},
"street_late": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 11.4,
"fine_detail": 6.21,
"contrast": 27.58,
"saturation_mean": 77.86,
"colour_spread": 27.49
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.258,
"judge": {
"scores": {
"characters": 3.0,
"overall": 4.0,
"lighting": 4.0,
"texture_detail": 3.0,
"materials": 3.0,
"backdrop": 5.0
},
"score_range": {
"characters": [
3.0,
3.0
],
"overall": [
4.0,
4.0
],
"lighting": [
4.0,
4.0
],
"texture_detail": [
3.0,
3.0
],
"materials": [
3.0,
3.0
],
"backdrop": [
5.0,
5.0
]
},
"runs": 1,
"model": "z-ai/glm-5.3-flash",
"fixes": [
"Implement advanced volumetric lighting and bloom effects on street lamps to achieve the dramatic contrast seen in the target.",
"Upgrade material shaders (PBR) for primary surfaces (glass, concrete) to increase definition and reduce the current muddy, low-resolution appearance.",
"Enhance atmospheric scattering and haze in the distance to create greater depth and match the cinematic quality of the target's skyline.",
"Increase the resolution and detail of foreground assets (building facades, road textures) to move away from the current blocky look.",
"Refine the sky's color gradient and lighting to better capture the specific warm, dramatic twilight palette of the target."
]
}
}
}
}
}
@@ -0,0 +1,54 @@
# Realism report: `docs/screens/realism/2026-11-01-forward-plus`
Vision judge: `z-ai/glm-5.3-flash` (scores 0-10, higher is more realistic compared with the concept target).
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| baseline | entrance_late | hollywood_entrance_rain_night | 1.568 | 21.3 | 9.55 | 68.03 | 5.0 | 5.0 | 6.0 | 6.0 | 5.0 |
| baseline | street_late | boulevard_skyline_dusk | 0.247 | 10.57 | 6.21 | 26.84 | - | - | - | - | - |
| candidate | entrance_late | hollywood_entrance_rain_night | 1.187 | 19.31 | 8.37 | 55.27 | 5.0 | 5.0 | 6.0 | 6.0 | 4.0 |
| candidate | street_late | boulevard_skyline_dusk | 0.258 | 11.4 | 6.21 | 27.58 | 4.0 | 4.0 | 3.0 | 3.0 | 5.0 |
Mean scores per set (all captured shots):
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| baseline | 2 | 0.907 | 5.0 | 5.0 | 6.0 | 6.0 | 5.0 | 4.0 |
| candidate | 2 | 0.723 | 4.5 | 4.5 | 4.5 | 4.5 | 4.5 | 4.0 |
**baseline / entrance_late** fixes:
- Increase contrast and deepen shadows across the entire scene, especially in the foreground and under the building overhang, to achieve a more cinematic mood.
- Boost the reflectivity and wetness of the pavement to create dramatic, high-contrast reflections of the streetlights and sign.
- Increase the density and volumetric scattering of the atmospheric haze/fog to enhance the sense of depth and distance, matching the target's mood.
- Improve the character's silhouette and integration by adding strong rim lighting or darkening the model to make it feel more like a dramatic figure rather than a visible asset.
- Add more micro-detail, grime, and color variation to the brick and concrete materials to make them feel less uniform and more weathered.
**candidate / entrance_late** fixes:
- Implement volumetric fog and heavy atmospheric haze to increase depth and match the target's moody atmosphere.
- Increase ground material specularity and darken the asphalt to enhance the wet, reflective look.
- Adjust global illumination and ambient light to increase contrast, allowing for deeper shadows and more dramatic highlights (e.g., on the sign).
- Apply rim lighting to the character to integrate them into the scene's dramatic lighting scheme.
- Enhance the color grading towards a high-contrast, desaturated, warm-yellow/orange cinematic palette.
**candidate / street_late** fixes:
- Implement advanced volumetric lighting and bloom effects on street lamps to achieve the dramatic contrast seen in the target.
- Upgrade material shaders (PBR) for primary surfaces (glass, concrete) to increase definition and reduce the current muddy, low-resolution appearance.
- Enhance atmospheric scattering and haze in the distance to create greater depth and match the cinematic quality of the target's skyline.
- Increase the resolution and detail of foreground assets (building facades, road textures) to move away from the current blocky look.
- Refine the sky's color gradient and lighting to better capture the specific warm, dramatic twilight palette of the target.
### Judge notes
The judge selected `z-ai/glm-5.3-flash` automatically from the live
OpenRouter catalogue (cheapest image+JSON model at the time), with fallback
candidates `qwen/qwen3.7-flash`, `google/gemma-3-4b-it`,
`google/gemma-3-12b-it`, `inclusionai/ling-3.0-flash-vl` and
`prism-ml/ternary-bonsai-2-9b`. One complete judge/A-B run cost about
**$0.006** of the key budget.
The baseline entrance scored **5.0 overall** and the candidate **5.0 overall**
with the small model; its material/texture sub-scores were noisy, so the
Pillow metric gap is the more stable signal for this A/B. The candidate's
slightly lower street_late/overall mean reflects the extra fog rather than a
Compatibility regression.
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+16
View File
@@ -0,0 +1,16 @@
Gig Line — Desktop HQ build
===========================
This build uses Godot's Forward+ renderer instead of the default
GL Compatibility renderer, enabling volumetric fog and SDFGI.
Requirements:
- A Vulkan-capable GPU/driver (Linux or Windows).
- Keep override.cfg next to the executable. Without it the binary falls back
to the normal Compatibility build.
The game still runs if Forward+ is unavailable, but the desktop-only effects
will be disabled.
Linux: chmod +x gig-line-linux-*.x86_64 && ./gig-line-linux-*.x86_64
Windows: run gig-line-windows-*.exe
+12
View File
@@ -0,0 +1,12 @@
; Gig Line desktop HQ preset. Place this file next to the exported executable.
; It switches only that copy to Forward+; project.godot and the default builds
; stay on GL Compatibility for Web/CI parity.
[rendering]
renderer/rendering_method="forward_plus"
renderer/rendering_method.mobile="forward_plus"
environment/volumetric_fog/volume_size=128
environment/volumetric_fog/volume_depth=128
environment/volumetric_fog/use_filter=1
global_illumination/sdfgi/frames_to_converge=4
global_illumination/sdfgi/frames_to_update_lights=2
+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.7.0"
config/version="0.7.2"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility")
+9 -6
View File
@@ -54,7 +54,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
"ambient_energy": 0.1,
"ambient_energy": 0.1, "volumetric_density": 0.004, "volumetric_energy": 0.0,
},
Rules.Phase.MIDDAY: {
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
@@ -62,7 +62,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
"ambient_energy": 0.28,
"ambient_energy": 0.28, "volumetric_density": 0.003, "volumetric_energy": 0.0,
},
# Evening is dusk, not a bright sunset: the sun is almost down and the street
# is carried by the marquee, the lamps and the lit windows (realism pass).
@@ -72,7 +72,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
"ambient_energy": 0.12,
"ambient_energy": 0.12, "volumetric_density": 0.014, "volumetric_energy": 0.10,
},
Rules.Phase.LATE: {
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
@@ -80,7 +80,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
"ambient_energy": 0.22,
"ambient_energy": 0.22, "volumetric_density": 0.020, "volumetric_energy": 0.14,
},
}
var hud: Hud = null
@@ -196,11 +196,14 @@ func _build_guard() -> GuardController:
# Aim straight ahead: tilting it down made the cone hit the ground right in
# front of the guard instead of the person or door being checked.
flashlight.rotation_degrees = Vector3.ZERO
flashlight.spot_range = 20.0
flashlight.spot_range = 30.0 if Materials.modern_light_falloff() else 20.0
flashlight.spot_angle = 14.0
flashlight.light_energy = 4.2
flashlight.spot_attenuation = 0.9
flashlight.spot_attenuation = 0.0 if Materials.modern_light_falloff() else 0.9
flashlight.spot_angle_attenuation = 0.9
# The volumetric shaft is a Forward+ (desktop HQ) effect. Compatibility and
# headless builds leave the light non-volumetric, so their look is unchanged.
flashlight.light_volumetric_fog_energy = 3.0 if RenderingServer.get_rendering_device() != null else 0.0
head.add_child(flashlight)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
+34 -1
View File
@@ -152,9 +152,24 @@ static func window_pane() -> StandardMaterial3D:
mat.emission_energy_multiplier = 0.0
return mat
## Godot 4.7 changed the Compatibility light falloff in practice: the same
## attenuation/range that read well on 4.3 leaves the night scene nearly black.
## Use this switch for local lights until the project moves its baseline to 4.7.
static func modern_light_falloff() -> bool:
var info := Engine.get_version_info()
return int(info.get("major", 4)) > 4 \
or (int(info.get("major", 4)) == 4 and int(info.get("minor", 0)) >= 7)
## True only when a real RenderingDevice exists. The Compatibility renderer and
## the headless dummy renderer return null, so the desktop-only effects can be
## gated without Web/CI builds ever enabling them.
static func forward_plus_active() -> bool:
return RenderingServer.get_rendering_device() != null
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
## fog for depth, and a mild contrast/saturation lift. Phase values come from
## `GameManager.PHASE_LIGHTING`.
## `GameManager.PHASE_LIGHTING`. Volumetric fog and SDFGI are only enabled when
## Forward+ is actually available (the optional desktop preset).
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
if env == null:
return
@@ -180,6 +195,24 @@ static func configure_environment(env: Environment, cfg: Dictionary) -> void:
env.fog_density = float(cfg.get("fog_density", 0.0035))
env.fog_aerial_perspective = 0.25
env.fog_sky_affect = 0.4
var advanced := forward_plus_active()
env.volumetric_fog_enabled = advanced
env.sdfgi_enabled = advanced
if advanced:
# Desktop HQ: a shallow, cool fog gives the torch a visible shaft while
# keeping the entrance facade readable. Phase values are tuned per phase.
env.volumetric_fog_density = float(cfg.get("volumetric_density", 0.012))
env.volumetric_fog_albedo = cfg.get("volumetric_albedo", Color(0.55, 0.62, 0.72))
env.volumetric_fog_emission = cfg.get("volumetric_emission", Color.BLACK)
env.volumetric_fog_emission_energy = float(cfg.get("volumetric_energy", 0.0))
env.volumetric_fog_anisotropy = 0.25
env.volumetric_fog_length = 48.0
env.volumetric_fog_ambient_inject = 0.15
env.volumetric_fog_sky_affect = 0.2
env.sdfgi_use_occlusion = true
env.sdfgi_cascades = 4
env.sdfgi_min_cell_size = 0.2
env.sdfgi_energy = 0.7
env.adjustment_enabled = true
env.adjustment_brightness = 1.0
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
+6 -3
View File
@@ -548,7 +548,8 @@ func _build_bulb_canopy() -> void:
for x in [-2.2, 2.2]:
var omni := OmniLight3D.new()
omni.position = Vector3(x, 5.15, front + 1.4)
omni.omni_range = 8.0
omni.omni_range = 12.0 if Materials.modern_light_falloff() else 8.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.36)
root.add_child(omni)
@@ -586,7 +587,8 @@ func _add_wall_lantern(pos: Vector3, with_light: bool) -> void:
var omni := OmniLight3D.new()
omni.name = "LanternGlow"
omni.position = pos + Vector3(0.0, -0.1, 0.45)
omni.omni_range = 9.0
omni.omni_range = 14.0 if Materials.modern_light_falloff() else 9.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.38)
root.add_child(omni)
@@ -666,7 +668,8 @@ func _build_street_furniture() -> void:
holder.name = "Lamp_%d" % int(side)
var omni := OmniLight3D.new()
omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 12.0
omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75)
holder.add_child(omni)
+9
View File
@@ -30,6 +30,9 @@ const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
var _portrait_type := -1
## Optional `--flashlight` turns the guard lamp on for night stages; the desktop
## HQ A/B uses it to show the volumetric shaft.
var _flashlight := false
## Where the portrait subject is placed, relative to the guard's spawn.
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
var _portrait_subject: Node3D = null
@@ -114,6 +117,8 @@ func _parse_args() -> void:
_only_stages[name.strip_edges()] = true
elif text.begins_with("--portrait-type="):
_portrait_type = int(text.substr(16))
elif text == "--flashlight":
_flashlight = true
func _pair(text: String, into: Dictionary) -> void:
var cut := text.find("=", 6) # skip the "res://" scheme
@@ -168,6 +173,10 @@ func _begin_stage() -> void:
if venue != "" and venue != str(gm.get("active_scenario_id")):
gm.call("switch_scenario", venue)
gm.coordinator.elapsed_s = float(stage["elapsed"])
if _flashlight:
var want := bool(stage["night"])
if want != bool(gm.flashlight_on):
gm.call("_toggle_flashlight")
_apply(swaps)
if bool(stage["night"]):
_apply(night_swaps)
+4 -1
View File
@@ -43,8 +43,11 @@ func test_flashlight_aims_a_tight_forward_spot() -> bool:
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
"flashlight cone is narrow enough to read as a torch")
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
var e := TestUtils.check(lamp != null
and is_zero_approx(lamp.light_volumetric_fog_energy),
"headless/compatibility builds leave the flashlight non-volumetric")
root.free()
return a and b and c and d
return a and b and c and d and e
func test_flashlight_widens_perception() -> bool:
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
+7 -2
View File
@@ -77,14 +77,19 @@ func test_environment_tuning_applies_phase_values() -> bool:
var f := TestUtils.check(
env.fog_light_color.is_equal_approx(late["fog_color"]),
"phase fog colour is used")
return a and b and c and d and e and f
var advanced := Materials.forward_plus_active()
var g := TestUtils.check(env.volumetric_fog_enabled == advanced,
"volumetric fog is gated to Forward+ (advanced=%s)" % advanced)
var h := TestUtils.check(env.sdfgi_enabled == advanced,
"SDFGI is gated to Forward+ (advanced=%s)" % advanced)
return a and b and c and d and e and f and g and h
func test_every_phase_has_environment_keys() -> bool:
var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
"contrast", "saturation"]:
"contrast", "saturation", "volumetric_density", "volumetric_energy"]:
if not cfg.has(key):
ok = false
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
+25
View File
@@ -11,6 +11,7 @@
# build/gig-line-windows-<version>.exe
# build/gig-line-macos-<version>.zip
# build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads)
# build/gig-line-desktop-hq-<version>.zip (optional Forward+ Linux/Windows)
#
set -euo pipefail
@@ -49,6 +50,30 @@ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
PYZIP
fi
echo "==> Packaging optional Desktop HQ build (Forward+)"
HQ_DIR="build/desktop-hq"
rm -rf "$HQ_DIR"
mkdir -p "$HQ_DIR"
cp "build/gig-line-linux-${VERSION}.x86_64" "$HQ_DIR/"
cp "build/gig-line-windows-${VERSION}.exe" "$HQ_DIR/"
cp packaging/desktop_hq/override.cfg "$HQ_DIR/"
cp packaging/desktop_hq/README.txt "$HQ_DIR/"
rm -f "build/gig-line-desktop-hq-${VERSION}.zip"
if command -v zip >/dev/null 2>&1; then
(cd "$HQ_DIR" && zip -q -r "../gig-line-desktop-hq-${VERSION}.zip" .)
else
python3 - "$PWD/$HQ_DIR" "$PWD/build/gig-line-desktop-hq-${VERSION}.zip" <<'PYZIP'
import os, sys, zipfile
src, out = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
for root, _dirs, files in os.walk(src):
for name in files:
path = os.path.join(root, name)
archive.write(path, os.path.relpath(path, src))
PYZIP
fi
rm -rf "$HQ_DIR"
echo ""
echo "Artifacts:"
ls -la build/*"${VERSION}"*
+127
View File
@@ -0,0 +1,127 @@
"""Pick affordable OpenRouter vision models for the screenshot judges.
The model catalogue is fetched from OpenRouter (no auth needed) and filtered to
models that accept image input and advertise `response_format`/structured output.
The ranking uses the advertised per-token prices, so it follows the account's
current catalogue instead of relying on one hard-coded model name. If the
catalogue cannot be fetched, a small known-good list keeps QA working offline.
"""
from __future__ import annotations
import os
import urllib.error
import urllib.request
MODELS_URL = "https://openrouter.ai/api/v1/models"
DEFAULT_MODEL = "auto"
# Used only when the catalogue is unavailable. Ordered by price/known quality.
FALLBACK_MODELS = [
"z-ai/glm-5.3-flash",
"qwen/qwen3.7-flash",
"google/gemma-3-4b-it",
"openai/gpt-4.1-nano",
"google/gemini-2.5-flash-lite",
]
def fetch_models(timeout: int = 30) -> list[dict]:
"""Return the OpenRouter model catalogue, or [] when it is unreachable."""
req = urllib.request.Request(MODELS_URL, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = __import__("json").loads(resp.read().decode("utf-8"))
except (OSError, ValueError, urllib.error.HTTPError):
return []
return list(payload.get("data") or [])
def _price(model: dict) -> float:
pricing = model.get("pricing") or {}
try:
prompt = float(pricing.get("prompt") or 0.0)
completion = float(pricing.get("completion") or 0.0)
image = float(pricing.get("image") or 0.0)
except (TypeError, ValueError):
return float("inf")
# A judge request is image-heavy and emits a short JSON verdict.
return prompt + image + completion * 0.25
def vision_candidates(catalog: list[dict], limit: int = 6) -> list[str]:
"""Cheap, image-capable models that can return structured JSON."""
ranked: list[tuple[float, str]] = []
for model in catalog:
model_id = str(model.get("id") or "")
if not model_id or model_id.startswith("~") or ":batch" in model_id:
continue
architecture = model.get("architecture") or {}
inputs = architecture.get("input_modalities") or []
outputs = architecture.get("output_modalities") or []
if "image" not in inputs or "text" not in outputs:
continue
params = model.get("supported_parameters") or []
# Our request uses response_format={"type": "json_object"}; models that
# only advertise structured_outputs may reject that envelope.
if "response_format" not in params:
continue
price = _price(model)
if price <= 0.0 or price == float("inf"):
continue
ranked.append((price, model_id))
ranked.sort()
out: list[str] = []
for _price_value, model_id in ranked:
if model_id not in out:
out.append(model_id)
if len(out) >= max(1, limit):
break
return out
def resolve_models(requested: str = DEFAULT_MODEL, *, catalog: list[dict] | None = None,
limit: int = 6) -> list[str]:
"""Resolve ``auto``/an explicit model to a fallback-ordered model list.
`GIG_LINE_JUDGE_MODEL` overrides the automatic choice for reproducible runs;
an explicit CLI `--model` still wins.
"""
requested = str(requested or "").strip()
if requested and requested != DEFAULT_MODEL:
return [requested]
env_model = os.environ.get("GIG_LINE_JUDGE_MODEL", "").strip()
if env_model:
return [env_model]
models = vision_candidates(catalog if catalog is not None else fetch_models(), limit)
if not models:
models = FALLBACK_MODELS[:max(1, limit)]
return models
def is_auth_error(exc: BaseException) -> bool:
text = str(exc)
return "HTTP 401" in text or "HTTP 403" in text or "API key not valid" in text
def call_with_fallback(models: list[str], make_body, call, attempts: int = 2):
"""Try each model, retrying provider errors, and return (model, response).
Authentication errors abort immediately: switching models cannot repair a
bad key. Model/provider errors move to the next candidate.
"""
last: BaseException | None = None
for model in models:
body = make_body(model)
for attempt in range(max(1, attempts)):
try:
return model, call(body)
except (ValueError, OSError) as exc:
last = exc
if is_auth_error(exc):
raise
if attempt + 1 >= max(1, attempts):
break
if last is not None:
raise last
raise ValueError("no judge model was available")
+1
View File
@@ -67,6 +67,7 @@ extras=(
"build/gig-line-setup-$VERSION.exe"
"build/gig-line-$VERSION-x86_64.AppImage"
"build/gig-line-web-$VERSION.zip"
"build/gig-line-desktop-hq-$VERSION.zip"
)
all_artifacts=("${portables[@]}")
for f in "${extras[@]}"; do
+37 -16
View File
@@ -38,6 +38,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
import asset_pipeline
import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
@@ -46,7 +47,7 @@ CONCEPT_DIR = ROOT / "docs" / "screens" / "concepts"
REALISM_DIR = ROOT / "docs" / "screens" / "realism"
DEFAULT_MODEL = "openai/gpt-5-image-mini"
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
SCENE_W, SCENE_H = 1280, 720
@@ -758,25 +759,32 @@ def _call_openrouter(body: dict, timeout: int = 180) -> dict:
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
def judge_capture(models, target: Path, capture: Path, attempts: int = 2,
repeats: int = 1) -> dict:
"""Vision-judge one capture; retry transient errors, average repeat scores.
`models` is a fallback-ordered list from `openrouter_judge.resolve_models`.
A single score from a small vision model swings by ±1-2, so `repeats` runs
are averaged and the spread is kept (`score_range`) for the report.
"""
if isinstance(models, str):
models = [models]
runs = []
error = None
used = set()
for _ in range(max(1, repeats)):
for _attempt in range(attempts):
try:
runs.append(parse_realism_response(_call_openrouter(
build_realism_request(model, target, capture))))
break
except (ValueError, OSError) as exc:
error = exc
try:
model, payload = openrouter_judge.call_with_fallback(
models, lambda selected: build_realism_request(selected, target, capture),
_call_openrouter, attempts=attempts)
used.add(model)
runs.append(parse_realism_response(payload))
except (ValueError, OSError) as exc:
if openrouter_judge.is_auth_error(exc):
raise
error = exc
if not runs:
return {"scores": {}, "fixes": [f"judge failed: {error}"]}
return {"scores": {}, "fixes": [f"judge failed: {error}"], "model": ""}
keys = {key for run in runs for key in run["scores"]}
scores = {}
spread = {}
@@ -790,7 +798,7 @@ def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
if fix not in fixes:
fixes.append(fix)
return {"scores": scores, "score_range": spread, "runs": len(runs),
"fixes": fixes[:5]}
"model": ", ".join(sorted(used)), "fixes": fixes[:5]}
def cmd_realism(args) -> int:
@@ -800,10 +808,14 @@ def cmd_realism(args) -> int:
if not sets:
print(f"no capture sets under {_rel(run)}", file=sys.stderr)
return 2
judge_models = []
if args.judge:
_load_env_file()
report = {"run": _rel(run), "judge_model": args.model if args.judge else None,
"sets": {}}
judge_models = openrouter_judge.resolve_models(args.model)
print(f"judge models: {', '.join(judge_models)}", file=sys.stderr)
report = {"run": _rel(run),
"judge_model": (judge_models[0] if judge_models else None),
"judge_fallbacks": judge_models, "sets": {}}
for capture_set in sets:
rows = {}
for capture in sorted(capture_set.glob("*.png")):
@@ -815,8 +827,15 @@ def cmd_realism(args) -> int:
row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m,
"metric_gap": metric_gap(cap_m, tgt_m)}
if args.judge:
row["judge"] = judge_capture(args.model, target, capture,
repeats=args.repeats)
try:
row["judge"] = judge_capture(judge_models, target, capture,
repeats=args.repeats)
except ValueError as exc:
if openrouter_judge.is_auth_error(exc):
print(f"error: OpenRouter authentication failed: {exc}",
file=sys.stderr)
return 2
raise
rows[capture.stem] = row
report["sets"][capture_set.name] = rows
json_path = next_free_path(run / "report.json")
@@ -975,7 +994,9 @@ def build_parser():
real = sub.add_parser("realism")
real.add_argument("--run", required=True)
real.add_argument("--judge", action="store_true")
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
help="OpenRouter model id, or 'auto' to pick the cheapest "
"available image+JSON model with fallbacks")
real.add_argument("--repeats", type=int, default=1,
help="average N judge runs per shot (scores are noisy)")
real.set_defaults(handler=cmd_realism)
+29 -9
View File
@@ -51,6 +51,7 @@ from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
@@ -68,7 +69,7 @@ ASPECT = 16 / 9
DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Shared art direction, appended to every prompt so the generated frames read
@@ -965,6 +966,10 @@ def judge(args):
refs = {screen["name"]: screen["ref"]
for screen in SCREENS if screen["ref"]}
by_name = {screen["name"]: screen for screen in SCREENS}
models = [args.model]
if not args.dry_run:
models = openrouter_judge.resolve_models(args.model)
print(f"judge models: {', '.join(models)}", file=sys.stderr)
results = []
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
if args.only and args.only not in capture.name:
@@ -982,23 +987,36 @@ def judge(args):
"match": None, "confidence": 0.0,
"differences": ["missing reference design; run generate"]})
continue
body = build_judge_request(args.model, by_name[design_name],
capture, reference)
if args.dry_run:
body = build_judge_request(args.model, by_name[design_name],
capture, reference)
results.append({
"capture": capture.name, "screen": design_name, "match": None,
"confidence": 0.0, "model": body["model"], "class": "unknown",
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
})
continue
# Providers occasionally answer a valid key with a transient HTTP 400;
# one retry keeps a whole judge run from dying on a single hiccup.
try:
verdict = parse_judge_response(_call_openrouter(body))
model, payload = openrouter_judge.call_with_fallback(
models,
lambda selected: build_judge_request(
selected, by_name[design_name], capture, reference),
_call_openrouter)
except ValueError as exc:
print(f"retrying {capture.name}: {exc}", file=sys.stderr)
verdict = parse_judge_response(_call_openrouter(body))
if openrouter_judge.is_auth_error(exc):
print(f"error: OpenRouter authentication failed: {exc}",
file=sys.stderr)
return 2
print(f"error judging {capture.name}: {exc}", file=sys.stderr)
results.append({
"capture": capture.name, "screen": design_name, "match": None,
"confidence": 0.0, "model": "",
"class": "unknown", "differences": [f"judge failed: {exc}"],
})
continue
verdict = parse_judge_response(payload)
verdict.update({"capture": capture.name, "screen": design_name,
"model": model,
"class": classify(verdict, args.fail_confidence)})
results.append(verdict)
@@ -1047,7 +1065,9 @@ def build_parser():
judge_cmd = commands.add_parser(
"judge", help="ask a vision model whether captures match the designs")
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
help="OpenRouter model id, or 'auto' to pick the cheapest "
"available image+JSON model with fallbacks")
judge_cmd.add_argument("--json", action="store_true")
judge_cmd.add_argument("--dry-run", action="store_true",
help="print requests instead of calling the API")
+89
View File
@@ -0,0 +1,89 @@
import os
import unittest
from unittest import mock
import openrouter_judge as oj
def _model(model_id, prompt, completion, *, image=True, response_format=True, batch=False):
return {
"id": model_id,
"architecture": {
"input_modalities": ["text", "image"] if image else ["text"],
"output_modalities": ["text"],
},
"supported_parameters": ["response_format"] if response_format else ["temperature"],
"pricing": {"prompt": str(prompt), "completion": str(completion)},
}
class ModelSelectionTests(unittest.TestCase):
def test_candidates_are_image_json_models_sorted_by_price(self):
catalog = [
_model("expensive/model", 0.10, 0.40),
_model("cheap/model", 0.01, 0.02),
_model("text-only", 0.001, 0.001, image=False),
_model("no-json", 0.001, 0.001, response_format=False),
_model("batch/model:batch", 0.0001, 0.0001),
]
self.assertEqual(oj.vision_candidates(catalog), ["cheap/model", "expensive/model"])
def test_explicit_model_wins_over_auto_and_env(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("explicit/model", catalog=[]),
["explicit/model"])
def test_auto_uses_env_override(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("auto", catalog=[]), ["env/model"])
def test_auto_uses_catalog_and_falls_back_offline(self):
catalog = [_model("cheap/model", 0.01, 0.02)]
self.assertEqual(oj.resolve_models("auto", catalog=catalog), ["cheap/model"])
self.assertEqual(oj.resolve_models("auto", catalog=[]), oj.FALLBACK_MODELS[:6])
class FallbackTests(unittest.TestCase):
def test_fallback_switches_model_after_provider_error(self):
seen = []
def call(body):
seen.append(body["model"])
if body["model"] == "bad/model":
raise ValueError("OpenRouter HTTP 400: provider rejected image")
return {"ok": True}
model, payload = oj.call_with_fallback(
["bad/model", "good/model"], lambda m: {"model": m}, call, attempts=1)
self.assertEqual(model, "good/model")
self.assertEqual(payload, {"ok": True})
self.assertEqual(seen, ["bad/model", "good/model"])
def test_auth_error_does_not_try_another_model(self):
seen = []
def call(body):
seen.append(body["model"])
raise ValueError("OpenRouter HTTP 401: API key not valid")
with self.assertRaises(ValueError):
oj.call_with_fallback(["one", "two"], lambda m: {"model": m}, call)
self.assertEqual(seen, ["one"])
def test_transient_error_retries_same_model(self):
seen = []
def call(body):
seen.append(body["model"])
if len(seen) == 1:
raise ValueError("OpenRouter HTTP 500")
return {"ok": True}
model, _ = oj.call_with_fallback(
["one"], lambda m: {"model": m}, call, attempts=2)
self.assertEqual(model, "one")
self.assertEqual(seen, ["one", "one"])
if __name__ == "__main__":
unittest.main()