"""Build a local review page and provenance index from the selected candidates.

This only writes HTML/JSON/text; it does not alter any artwork.
"""
from pathlib import Path
import hashlib
import html
import json
from PIL import Image

ROOT = Path(__file__).resolve().parents[1]
CAST = json.loads((ROOT/'content/characters.json').read_text())
NAMES = {'ganoes':'Ganoes Paran, twelve','commander':'Bridgeburner commander',
         'fiddler':'Fiddle-carrying Bridgeburner, adolescent','surly':'Surly / Laseen',
         'claw':'Claw acolytes'}
WALKS = {'ganoes':1,'commander':1,'fiddler':2,'surly':2,'claw':1}
selected = []

def select(runtime, source, role):
    runtime = runtime.removeprefix('res://')
    selected.append({'role':role,'runtime':runtime,'source':f'art/source/{source}.png',
                     'prompt':f'art/source/{source}.prompt.txt','status':'candidate, unapproved'})

for name, character in CAST.items():
    for direction,source in character.get('walk_direction_sources',{'all':character.get('walk_source',f'walk_{name}_v{WALKS[name]}')}).items():
        select(character['walk_atlas'],source,f'{name}: {direction} walk source')
    if character.get('idle_atlas'):
        select(character['idle_atlas'],character.get('idle_source',f'walk_{name}_v{WALKS[name]}'),f'{name}: stable idle / head reference')
    if character.get('run_atlas'):
        for direction,source in character.get('run_direction_sources',{'all':character['run_source']}).items():
            select(character['run_atlas'],source,f'{name}: {direction} run source')
    for expression, path in character['portraits'].items():
        version=2 if name in ('fiddler','surly') else 1
        select(path,f'portrait_{name}_{expression}_v{version}',f'{name}: {expression} bust')
    for pose,path in character.get('poses',{}).items():
        select(path,character.get('pose_sources',{}).get(pose,f'pose_{name}_{pose}_v1'),f'{name}: {pose}')
for runtime,source,role in [
    ('art/source/mocks_hold_background_v2.png','mocks_hold_background_v2','Opening panorama'),
    ('art/environment/city_band_v4.png','mocks_hold_background_v2','Map distant-city band'),
    ('art/tiles/mocks_hold_tiles_v1.png','tiles_mocks_hold_v1','Masonry tile atlas'),
    ('art/tiles/mocks_hold_floor_quiet_v2.png','tiles_floor_quiet_v2','Quiet floor tile atlas'),
    ('art/props/citadel_portal_v1.png','prop_portal_v1','Square tower portal'),
    ('art/props/mocks_vane_v2.png','prop_vane_v2','Demon vane on pike tip')]:
    select(runtime,source,role)
used={item['source'] for item in selected}
sources=[]
for prompt in sorted((ROOT/'art/source').glob('*.prompt.txt')):
    source=prompt.with_name(prompt.name.replace('.prompt.txt','.png'))
    assert source.exists(),source
    with Image.open(source) as im:
        size=list(im.size)
    sources.append({'source':str(source.relative_to(ROOT)),
                    'prompt':str(prompt.relative_to(ROOT)), 'dimensions':size,
                    'sha256':hashlib.sha256(source.read_bytes()).hexdigest(),
                    'selection':'current unapproved candidate' if str(source.relative_to(ROOT)) in used else 'history / alternative, not selected'})
payload={'status':'All artwork remains unapproved. Selection means used in this review build only.',
         'new_art_tool':'Built-in ImageGen in Codex',
         'processing':'Aseprite: alpha cleanup, crop, uniform nearest-neighbour scale, head-based alignment, fixed head/core pixel patches for earlier walks; Fiddler v7 keeps complete source anatomy with no replacement head/back patch; Surly v5 uses her own revised head/neck reference per view; complete generated heads and arms for running and leaning, corrected opposite leg phases without mirroring equipment, two-pixel heel lift with fixed leg proportions and toe contacts, export. Running flight phases translate rigidly upward two pixels. Current repairs add facial-only expression masks, native black-hair recolouring, north heel shading, Surly opposite support, generated Fiddler north/south run keys and west/east walk keys. Python image use is read-only analysis.',
         'walk_motion':'Eight timed phases from four existing key drawings; stride-linked whole-figure pixel translation with preserved planted soles. No per-frame scaling or limb stretching.',
         'motion_references':['https://schwarnhild.itch.io/free-top-down-npc-01-walking-animation-in-4-directions','https://www.slynyrd.com/blog/2024/5/24/pixelblog-50-human-walk-cycle','https://www.slynyrd.com/blog/2025/3/24/pixelblog-55-top-down-character-animation'],
         'selected_assets':selected,'generation_sources':sources}
(ROOT/'art/CANDIDATE_MANIFEST.json').write_text(json.dumps(payload,indent=2)+'\n')

def link(path,label):
    return f'<a href="{html.escape(path)}">{html.escape(label)}</a>'

def figure(path,label,cls=''):
    return f'<figure class="{cls}"><a href="{path}"><img loading="lazy" src="{path}" alt="{html.escape(label)}"></a><figcaption>{html.escape(label)}</figcaption></figure>'

sections=[]
notes={
'ganoes':'Black hair and age come from the book. Indigo civilian tailoring is an interpretation of merchant nobility. Surprise is also available as an expression exercise.',
'commander':'Grey eyes, scarred left cheek/jaw, grey cloak and bridge brooch follow the text. Exact hair and stubble are interpretations. Check scar consistency in directional sprites.',
'fiddler':'Young, pockmarked, red-haired, mismatched worn armour, fiddle and scabbard. Revised face removes the first draft’s invented linear scars. Instrument damage needs clearer pixel work.',
'surly':'Revised sprite proportions: less spherical hair volume, a narrower head and jaw, and a closer-cropped silhouette. The same generated head/neck pixels are held through each direction’s walk. The comparison below is commander / previous Surly / revised Surly / Fiddler at the same scale. Plainness and exact hair/robe fidelity remain review points.',
'claw':'One reusable faceless black-robed model plays both guards. No large portrait, visible eyes or invented insignia.'}
for name,character in CAST.items():
    images=[]
    if name=='surly':
        images.append(figure('art/previews/surly_proportion_comparison_v5.png','Commander / previous Surly / revised Surly / Fiddler; rows south, west, east, north; exact 3×','wide'))
    for emotion,path in character['portraits'].items():
        images.append(figure(path.removeprefix('res://'),emotion.replace('_',' '),'portrait'))
    walk_version=3 if name in ('fiddler','surly') else 2
    prefix=character.get('walk_preview',f'art/previews/{name}_walk_south_v{walk_version}.gif')
    preview=prefix if prefix.endswith('.gif') else prefix+'_south.gif'
    images.append(figure(preview,'South walk, enlarged 3×','motion'))
    if name!='claw':
        for direction in ('west','east','north'):
            images.append(figure(prefix+'_'+direction+'.gif',direction.title()+' walk, enlarged 3×','motion'))
    if character.get('run_preview'):
        for direction in ('south','west','east','north'):
            images.append(figure(character['run_preview']+'_'+direction+'.gif',direction.title()+' running, enlarged 3×','motion'))
        images.append(figure(character['run_atlas'].removeprefix('res://'),'Four-direction run sheet','sheet'))
    images.append(figure(character['walk_atlas'].removeprefix('res://'),'All four direction rows: south / west / east / north','sheet'))
    for pose in character.get('poses',{}):
        images.append(figure(character.get('pose_previews',{}).get(pose,f'art/previews/{name}_{pose}_v1.gif'),f'{pose}: settles and holds in game','motion'))
        if pose in character.get('pose_sheets',{}):
            images.append(figure(character['pose_sheets'][pose],f'{pose}: neutral / small rise / held pose, 3×','wide'))
    sections.append(f'<section id="{name}"><h2>{NAMES[name]}</h2><p>{notes[name]}</p><div class="assets">'+''.join(images)+'</div></section>')
source_rows=''.join(f'<tr><td>{html.escape(a["role"])}</td><td>{link(a["runtime"],"Game file")}</td><td>{link(a["source"],"Source")}</td><td>{link(a["prompt"],"Exact prompt")}</td></tr>' for a in selected)

def audio_section():
    manifest_path=ROOT/'audio/manifest.json'
    if not manifest_path.exists():
        return ''
    audio=json.loads(manifest_path.read_text())
    cards=[]
    for music in audio['music']:
        cards.append(f'''<figure style="max-width:310px"><h3>{html.escape(music['title'])}</h3>
<p>{html.escape(music['description'])}</p><audio controls loop preload="none" src="{music['path']}"></audio>
<p class="small">{music['seconds']:.1f}s loop · {music['bpm']} BPM · {link(music['source'],'Editable MIDI')} · {link(music['score'],'Note arrangement')}</p></figure>''')
    effects=[]
    for file,title in [('step_stone_1','Stone footstep candidate'),('step_armour_1','Footstep with light equipment'),('cloth_shift','Cloth movement'),('armour_shift','Small metal movement'),('vane_creak','Rusty vane: synthesized friction study'),('ui_advance','Dialogue advance'),('text_tick','Optional dialogue tick')]:
        effects.append(f'<figure><figcaption>{title}</figcaption><audio controls preload="none" src="audio/sfx/{file}_v1.wav"></audio></figure>')
    return '''<section id="audio"><h2>Music and sound — first listening candidates</h2>
<p>Three original arrangements share a short theme. Listen against identical 30-second Godot staging, then compare the isolated loops. B — Chamber / On the Battlement is your chosen direction. All recordings and sound effects remain drafts. These are instrumental sketches rendered with FluidR3 GM, rather than a finished orchestral recording.</p>
<p><button type="button" onclick="chooseAudioStudy('jrpg')">Watch A · Melodic JRPG</button> <button type="button" onclick="chooseAudioStudy('chamber')">Watch B · Chamber</button> <button type="button" onclick="chooseAudioStudy('ambient')">Watch C · Dark ambient</button></p>
<p id="audio-study-name">B · Restrained chamber</p>
<video id="audio-study" controls preload="metadata" poster="art/previews/audio_study_v1.png" src="art/previews/audio_study_chamber_v1.mp4"></video>
<p class="small">Same movement, ambience and effects in every film. Switching candidates retains the scene position. This is a condensed staging study with no added dialogue; it is separate from the full book timeline. Films include sound. <a href="AUDIO_DIRECTION.md">Direction and reproduction notes</a>.</p>
<div class="assets">'''+''.join(cards)+'''</div>
<h3>Battlement ambience</h3><audio controls loop preload="none" src="audio/ambience/battlement_wind_v1.ogg"></audio>
<p class="small">30-second wind loop, filtered from Luke.RUSTLTD’s CC0 PureData wind. This is synthesized wind, not a field recording. The vane squeal is triggered separately at relevant book paragraphs. No nearby battle or fire sounds are added.</p>
<h3>Individual effect candidates</h3><div class="assets">'''+''.join(effects)+'''</div>
<p>In Godot, <kbd>F3</kbd> opens the audio panel: choose A/B/C or music off, adjust master/music/ambience/effects/dialogue volumes, and optionally enable quiet text ticks. <kbd>U</kbd> mutes everything. The scene pauses while the panel is open; music and wind continue for listening. Footsteps follow animation contact frames, including the faster running cycle.</p>
'''+figure('art/previews/prologue/audio_controls_v1.png','Godot audio panel; settings persist locally','wide')+'''
<p class="small">Foley: Kenney’s <a href="https://opengameart.org/content/50-rpg-sound-effects">CC0 RPG sound pack</a>. Wind: <a href="https://opengameart.org/content/wind1">Luke.RUSTLTD, CC0</a>. Instrument samples: FluidR3 GM, MIT. MIDI arrangements and the synthesized UI/vane effects were authored for this project. <a href="audio/CREDITS.md">Credits</a> · <a href="audio/manifest.json">Audio manifest</a>.</p></section>
<script>
let audioStudyRequest=0;
function chooseAudioStudy(id){
  const player=document.getElementById('audio-study');
  const time=Number.isFinite(player.currentTime)?player.currentTime:0;
  const request=++audioStudyRequest;
  player.pause();
  document.getElementById('audio-study-name').textContent={jrpg:'A · Melodic JRPG',chamber:'B · Restrained chamber',ambient:'C · Dark ambient'}[id];
  player.onloadedmetadata=()=>{if(request!==audioStudyRequest)return;player.currentTime=Math.min(time,Math.max(0,player.duration-.1));player.play().catch(()=>{});};
  player.src='art/previews/audio_study_'+id+'_v1.mp4';player.load();
}
document.addEventListener('play',event=>{
  if(!event.target.matches('audio,video'))return;
  document.querySelectorAll('audio,video').forEach(media=>{if(media!==event.target)media.pause();});
},true);
</script>'''
page='''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mock’s Hold — candidate review</title><style>
:root{color-scheme:dark;font:16px/1.55 system-ui,sans-serif;background:#151b24;color:#e9e0cf}body{max-width:1180px;margin:auto;padding:30px}h1,h2{line-height:1.2;color:#f3d8a5}h1{font-size:36px}h2{margin-top:0}a{color:#9dc8d4}nav{display:flex;gap:18px;flex-wrap:wrap}section{border-top:1px solid #475364;margin-top:34px;padding-top:28px}.badge{color:#efd08f;letter-spacing:.12em;font-size:12px}p{max-width:85ch}.assets{display:flex;flex-wrap:wrap;align-items:start;gap:16px}figure{margin:0;padding:12px;background:#202835;border:1px solid #3e4857;border-radius:6px}img{image-rendering:pixelated;max-width:100%;height:auto;display:block}figcaption{font-size:13px;color:#c4bcad;max-width:40ch;margin-top:8px}.portrait img{width:160px;height:192px;object-fit:contain}.motion img{width:144px;height:192px;object-fit:contain}.sheet img{width:auto;height:512px;max-width:100%;object-fit:contain}.wide{width:min(100%,640px)}.wide img{width:auto;max-width:100%}video{width:640px;max-width:100%;image-rendering:pixelated;background:#0c1017}audio{display:block;width:300px;max-width:100%;margin:10px 0}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:9px;border-bottom:1px solid #384354}code,kbd{background:#29323f;padding:2px 5px;border-radius:3px}li{margin:7px 0}.callout{border-left:3px solid #c2a46d;padding:8px 18px;background:#202631}.small{font-size:13px;color:#c4bcad}button{font:inherit;background:#344452;color:#fff;border:1px solid #79909a;padding:7px 12px;border-radius:4px;cursor:pointer}
</style><header><div class="badge">PRIVATE LEARNING PROJECT · FEEDBACK REVISION · 18 OCT 2026</div><h1>Mock’s Hold — prologue review</h1><p>A fully scripted top-down scene with matching cast studies, bust expressions, animation, a tile map and your supplied prologue dialogue. This page is a review board; the Godot project is the playable version.</p><nav><a href="#film">Watch</a><a href="#motion-feedback">Current motion</a><a href="#fiddler-anatomy">Earlier alignment</a><a href="#walks">Walk weight</a><a href="#expressions">Expression blends</a><a href="#cast">Cast</a><a href="#map">Map / UI</a><a href="#facts">Book fidelity</a><a href="#sources">Files / prompts</a></nav></header>
<section id="film"><h2>Current prologue — dialogue and staging</h2><p>Dialogue only: no narrator or internal-thought boxes. All 48 speaking paragraphs remain in order, with punctuation corrected where prose dialogue tags were removed. The tower is attached to the wall; the extra stair fragment is gone. Commander and Fiddler watch upright, avoiding the lean-height jump. Ganoes keeps his approved tiptoe geometry.</p><video controls preload="metadata" poster="art/previews/prologue/staging_revision_v2.png" src="art/previews/prologue_preview_v9.mp4"></video><p class="small">Current 1080p/60fps capture with Chamber B, facial-mask expression blends, blacker Ganoes hair and 70-character/second typewriter reveal. Manual advance remains available in Godot. Walking defects listed below are still being refined; this film does not imply every animation is approved.</p><p><a href="content/dialogue_punctuation_audit.json">Punctuation audit</a> · <a href="SPRITE_CONTRACT.md">Body and animation contract</a></p><h3>Exit timing close-up</h3><video controls loop preload="metadata" src="art/previews/departure_v2.mp4"></video><p>Surly leads. Arrival times at the door are staggered by 0.65s; each character disappears independently at the threshold.</p><p>Run <code>./run.sh</code>, or press F5 in Godot. F2 opens the cast gallery; A switches autoplay.</p></section>
<section id="ganoes-hair"><h2>Ganoes — black hair</h2><p>Chapter 1 explicitly describes his hair as black. The colour correction preserves his face, haircut, silhouette and pixel positions. Compare the previous warm highlights, new near-black hair and unchanged commander.</p><div class="assets">'''+figure('art/portraits/ganoes/neutral_v1.png','Previous warm hair','portrait')+figure('art/portraits/ganoes/neutral_black_v2.png','Ganoes: blacker hair','portrait')+figure('art/portraits/commander/neutral_v1.png','Commander: unchanged','portrait')+'''</div></section>
<section id="motion-feedback"><h2>Current motion repairs and proportion pilot</h2><video controls loop preload="metadata" src="art/previews/motion_feedback_v1.mp4"></video><p>Current Surly support correction, an unselected smaller-head Surly pilot, and Fiddler previous/revised run and walk. The pilot is deliberately separate: its head is smaller than our chosen chapter-one body reference, so a full cast migration is not yet selected.</p><p><a href="MOTION_FEEDBACK.md">What changed and what remains</a> · <a href="SPRITE_CONTRACT.md">Proportion and animation rules</a></p></section><section id="fiddler-anatomy"><h2>Earlier study: Fiddler anatomy</h2><p>The revised walk and idle preserve a complete head, neck and torso from the newer drawing. The previous export pasted an older head/back rectangle over the moving body to stabilise the fiddle. That patch is removed. The eight timed weight phases remain; running and leaning already preserve their complete anatomy and are shown unchanged for comparison.</p><video controls loop preload="metadata" poster="art/previews/fiddler_anatomy_v7.png" src="art/previews/fiddler_anatomy_v7.mp4"></video><p class="small">Previous above, revised below; south, west, east and north. Historical v7 comparison; the current build uses the additional contact and side-walk revisions shown above. The fiddle is still painted into this candidate. Future removable equipment will use an intact base body plus an accessory layer, with the same body proportions and root. <a href="MOTION_REVISION.md">Revision notes</a>.</p></section>
<section id="walks"><h2>Earlier study: walking weight</h2><p>The previous walk appears above and the revised version below, at matched cycle times and travel speed. Head, shoulders, body and equipment move together by a small integer-pixel offset. Ganoes and Fiddler have a 2-pixel low-to-high range; the commander and Surly use a restrained 1-pixel dip. Each key drawing’s planted sole keeps its position through a tiny boot-edge adjustment.</p><video controls loop preload="metadata" poster="art/previews/walk_weight_south_v6.png" src="art/previews/walk_weight_v6.mp4"></video><p class="small">Eight timed weight phases reuse the existing four limb drawings; these are not eight newly drawn leg poses. The 11-second Godot clip shows south/west/east/north and scripted travel. This clip predates Fiddler’s v7 anatomy correction shown above; the current game and gallery use the later contact and side-walk revisions. Frame timing follows actual travel speed. <a href="WALK_STUDY.md">Study notes and reproduction</a>.</p><p class="small">References: <a href="https://schwarnhild.itch.io/free-top-down-npc-01-walking-animation-in-4-directions">schwarnhild’s free four-direction NPC</a>, <a href="https://www.slynyrd.com/blog/2024/5/24/pixelblog-50-human-walk-cycle">SLYNYRD’s walk-cycle breakdown</a>, <a href="https://www.slynyrd.com/blog/2025/3/24/pixelblog-55-top-down-character-animation">SLYNYRD’s top-down animation study</a>.</p></section>
<section id="expressions"><h2>Smooth expression changes</h2><p>Selected expressions now preserve every pixel outside explicit facial masks. Hair, clothes and alpha silhouette are identical to neutral. Expressions blend in place over 0.20 seconds. The same feature is used by dialogue and the cast gallery. Each speaker remembers their last expression; character changes switch cleanly. Portraits keep their alignment and pixel sampling, and dialogue continues during the blend.</p><video controls loop preload="metadata" poster="art/previews/prologue/portrait_blend_gallery_midpoint.png" src="art/previews/portrait_transitions_v2.mp4"></video><p class="small">11-second Godot preview: all four cast members, followed by the actual dialogue component with expression-preview captions. Try F2, choose a character with 1–4, and press Tab to cycle emotions. Quickly cycling expressions keeps the latest choice queued until the current short blend finishes.</p></section>
<section id="cast"><h2>Cast at actual game scale</h2>'''+figure('art/previews/prologue/cast_gallery.png','Actual Godot render: 160×192 portraits and 48×64 sprite cells','wide')+'''<p>All five sprite models share the same frame contract. The twelve-year-old, adolescent and adults have different body heights inside those cells. Generated poses are sliced and aligned around the head. Earlier walk exports use fixed face/core patches; Fiddler’s current walk/idle preserve their complete source anatomy; running preserves complete anatomy; the commander and Fiddler now watch the wall upright. Four speakers now use eight timed phases in 384×256 walk sheets, based on four key drawings; the acolyte retains its earlier three-column sheet. These remain art candidates.</p></section>'''+''.join(sections)+'''
<section id="map"><h2>Environment and dialogue</h2><div class="assets">'''+''.join(figure('art/previews/prologue/'+name+'.png',label,'wide') for name,label in [
('staging_revision_v2','Current tower and upright soldiers'),('surly_arrival_map','Scripted staging with all six actors'),('map_coordinates','Zero-based tile coordinates — report edits as (column, row)'),
('dialogue_ganoes_proud','Default 160×192 bust and integrated speaker name'),('ui_compact','Compact portrait alternative: press L'),
('floor_quiet','Selected quieter floor candidate'),('floor_dense','Denser floor alternative: press M'),
('opening_establishing','Opening panorama as a brief establishing shot'),('ending_smoke','Ending smoke across the battlement')])+'''</div><p>The tile map uses 32×32 cells. The world is 640×360 logical pixels; at 1080p it scales exactly 3×. Characters follow authored pixel waypoints rather than a collision grid. The distant city is a separate art layer.</p><div class="assets">'''+figure('art/tiles/mocks_hold_tiles_v1.png','Masonry tile atlas')+figure('art/tiles/mocks_hold_floor_quiet_v2.png','Quieter floor atlas')+figure('art/props/citadel_portal_v1.png','Square tower portal candidate')+figure('art/props/mocks_vane_v2.png','Vane: demon squatting on pike tip')+'''</div></section>
<section id="facts"><h2>Book fidelity and remaining problems</h2><div class="callout">The book takes priority. Later chapters may guide appearance continuity; the prologue determines age, clothing, equipment and action for this scene.</div><ul>
<li>The first backgrounds’ flags and invented heraldry are absent. Mock’s Vane is a rusty winged demon on an old pike, not a banner.</li>
<li>The revised panorama concentrates smoke at the distant inland edge, leaving intervening city mostly intact. Exact buildings, city scale and bridges are visual interpretations.</li>
<li>All 58 quoted speech fragments from the supplied prologue are preserved, in order, across 48 speaking paragraphs. Attributions remain in the original-paragraph reference view (B); the game displays only spoken dialogue. Eleven tag-ending commas become full stops and one tag comma is removed from an interrupted sentence; original fragments remain unchanged in source metadata.</li>
<li>The commander and fiddle soldier use the prologue’s unnamed speaker labels. Surly’s label changes to Laseen after her correction.</li>
<li>Current motion pass: darker north heels, alternating Surly support, revised Fiddler side-walk contacts/passing poses and stronger north/south running. These remain review candidates. The broad six-column replacement trials were rejected; the more natural Surly pilot is shown separately and is not mixed into the cast.</li>
<li>The raised tower is now selected and routes use its doorway. Dedicated tower edge tiles could refine the joins further.</li>
<li>Stone texture remains busier than the portraits. There is a shared-palette experiment, but palette cleanup has not been silently applied to the current cast.</li>
<li>Dialogue uses a plain readable system font. Decorative pixel typography, sound, music and final cinematic timing remain future work.</li></ul><p>First review decisions: cast identities and ages, small-sprite proportions/motion, large versus compact bust layout, and the battlement composition. You can refer to a character, expression, animation frame or map coordinate.</p></section>
<section><h2>Interactive controls</h2><p><kbd>Space / Enter / click</kbd> reveal or advance · <kbd>A</kbd> autoplay · <kbd>B</kbd> source paragraph · <kbd>F2</kbd> gallery · <kbd>G</kbd> grid · <kbd>L</kbd> bust size · <kbd>M</kbd> floor · <kbd>H</kbd> hide review bar · <kbd>F11</kbd> fullscreen · <kbd>R</kbd> restart.</p><p>Gallery: <kbd>1–4</kbd> choose a speaker, <kbd>Tab</kbd> cycle expression, arrow keys change facing, <kbd>V</kbd> toggles the fiddle soldier’s walk/run, <kbd>5</kbd> guards. Gallery and book views pause the cutscene. There are no player movement controls.</p></section>
<section id="sources"><h2>Selected game files and generation prompts</h2><p>Selection means “used in this review build”; no candidate is approved. Earlier sources and rejected outputs are retained for history. <a href="art/CANDIDATE_MANIFEST.json">Full manifest with source hashes</a>.</p><table><thead><tr><th>Asset</th><th>Runtime</th><th>Master</th><th>Generation</th></tr></thead><tbody>'''+source_rows+'''</tbody></table></section><footer><p class="small">Local review board · No publishing or external service required · New artwork generated with built-in ImageGen; Aseprite used for preparation/export.</p></footer></html>'''
page=page.replace('<a href="#film">Watch</a>','<a href="#audio">Music / sound</a><a href="#film">Watch</a>')
page=page.replace('<section id="film">',audio_section()+'<section id="film">')
page=page.replace('Decorative pixel typography, sound, music and final cinematic timing remain future work.','Decorative pixel typography and final cinematic timing remain future work. First music and sound candidates are available in the audio section and current game.')
page=page.replace('<nav>', '<nav><a href="chapter1-review.html">Chapter 1 candidates</a>', 1)
page=page.replace('B is the current provisional default.', 'B — Chamber / On the Battlement is your selected musical direction; recording and sound effects remain drafts.')
(ROOT/'review.html').write_text(page)
print(f'Indexed {len(selected)} selected candidates and {len(sources)} generated masters; wrote review.html.')
