From ecedfe1667fdee8e8da18bb5f36b57ce67a82b71 Mon Sep 17 00:00:00 2001 From: g Date: Sat, 29 Aug 2026 17:02:55 -0700 Subject: [PATCH] docs: the 29 Aug release cut, and eight things the owner asked for A manual test cut of caf10bd on macOS, and the notes it produced. The cut itself was green: Release build clean, 631 tests / 1 skipped / 0 failures across 35 files. Navigation direction is correct, the done card is whole, the journal's numbers match the store exactly, and a source grep finds no network, analytics, StoreKit, notification or location API anywhere in the app. Five findings (F1-F5). The one that matters for play is F1: the first-run hint is specified as "never returns" but restart() clears the flag it reads, so it comes back on every field. F3 records that no installed simulator runtime can reach the 18.5 deployment floor, so the minimum supported OS is currently untested. F5 records that the version and build numbers are still v1.2's. Eight requests (R1-R8) raised by the owner during the pass. Two are major and both have a measurable cause rather than a taste argument: R7 - the pops do not read as distinct. Of the 100 catalog entries, zero override voice, haptic pattern or burst motion; all three fall through to the family, so 100 pops carry 10 sounds, 10 burst motions and at most 5 haptics, constant within a family. The channels are live and read - this is an authoring gap, not an engine one. R8 - the weather under-reads for two separable reasons: clear air is 44% of fields, and flowCarry is 0 for clear, snow AND fog, so half the airs move nothing at all. Also recorded: three non-findings, so nobody re-chases them. The "inert sky" and the "unpoppable orbs" were a stale window origin in the test harness, not the app. No code changed. Every item names its file, line and constant so the change can be made deliberately, one knob at a time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MiBreiwVPx8LF5pDdBeVff --- docs/release_cut_2026-08-29_feedback.md | 814 ++++++++++++++++++++++++ 1 file changed, 814 insertions(+) create mode 100644 docs/release_cut_2026-08-29_feedback.md diff --git a/docs/release_cut_2026-08-29_feedback.md b/docs/release_cut_2026-08-29_feedback.md new file mode 100644 index 0000000..0344190 --- /dev/null +++ b/docs/release_cut_2026-08-29_feedback.md @@ -0,0 +1,814 @@ +# Release cut feedback — 2026-08-29 + +**Cut:** `main` @ `caf10bd` ("A QA swarm: eleven new test suites, six production +defects, and the docs made true (#12)") +**Built:** Release, `-configuration Release`, `CODE_SIGNING_ALLOWED=NO` — **succeeded** +**Tests:** `xcodebuild test` — **631 executed, 1 skipped, 0 failures**, 35 files, 23.5 s +**Device:** iOS Simulator, iPhone 16 Pro on **iOS 26.5** (see E1 — the named device +was not available on any runtime the app can install to) +**Method:** fresh install (brand-new simulator, nothing to delete), driven with +synthetic touches + screenshots. Everything a finger, a haptic engine, a 120 Hz +panel or a screen reader is needed for is listed under "Not tested". + +Two kinds of item, kept apart on purpose. **F** items are things that are wrong +against the spec, the build or the release. **R** items are things the owner +asked for during the pass — requests and tuning, not defects. Each carries the +one thing that makes it actionable: where it lives. + +Nothing in this document has been changed in the code. Every item names its +file, line and constant so the change can be made deliberately, and +`CLAUDE.md`'s rule stands: **gameplay constants move one at a time.** + +## What's in here + +| | Item | Severity | Lives in | +|---|---|---|---| +| **F1** | First-run hint returns on every new field | Major — spec violation | `WorldView.swift:825`, `GameViewModel.swift:228` | +| **F2** | Checklist §XV stale on two counts | Docs | the walkthrough artifact | +| **F3** | Nothing local can test the iOS 18.5 floor | Major — coverage gap | simulator runtimes | +| **F4** | Unused-result warning in `ribbonTraits` | Minor — hygiene | `JournalView.swift:560` | +| **F5** | Version/build numbers still v1.2's | Blocker for submission | `project.pbxproj` | +| **R1** | Rise to the sky after a clear | Verify — likely already built | `GameConfig.onwardToSkyDelay` | +| **R2** | A pointer to the next suggested stone | New work | `SkyView.swift:503` | +| **R3** | Piercing fireworks | New work — largest | `GameSimulation.swift:929`, `:334` | +| **R4** | Chain too intensive | Tuning — one knob | `GameConfig.ringDisarmFraction` | +| **R5** | Visible dark edge at the sky/field join | Polish — one knob | `HorizonRenderer.swift:132` | +| **R6** | More fireworks | Tuning — one knob | `FieldPlan.swift:222` | +| **R7** | **Pops are not unique enough** | **Major** | `PopCatalog.swift` — an authoring gap | +| **R8** | **Weather too soft / not interesting** | **Major** | `Weather.swift:225`, `:147` | + +**If only three things are done:** R7 (the 100-pop collection does not currently +read as 100 things), R8's odds line (a headline feature is invisible half the +time), and F5 (the build cannot ship on v1.2's numbers). + +--- + +## F1 — The first-run hint returns on every new field + +**Severity:** Major (spec violation) · **Area:** §I, the core loop + +The committed spec says the hint is shown once, ever: + +> `docs/e2e_walkthrough.md:30` — *"Foot of the screen: 'tap an orb. let it go.' +> — disappears after the first pop, never returns."* + +It returns at the start of **every** field: after every clear, after every +"again?", and after every star tap. + +**Where:** +- `Vesper/World/WorldView.swift:825` — the hint renders on `if !game.started` +- `Vesper/Game/GameViewModel.swift:228` — `restart()` sets `started = false` + +`started` is per-field session state, but the copy it gates is written as a +lifetime, once-only instruction. + +**Evidence:** observed mid-session on a freshly seeded field after 7 cleared +fields and 121 lifetime pops, counter back at 0 and the hint present. + +**Action — one of two, and it is a product call, not a code call:** +- **(a) Make the hint lifetime-once.** Gate on a persisted flag in + `ProgressionStore` (`lifetimePops > 0`) rather than on `game.started`. Matches + the spec as written. Costs one `UserDefaults` read. +- **(b) Amend the spec.** If a per-field re-offer is intended — it is a calm, + affirming line, and re-showing it breaks no guardrail — change + `docs/e2e_walkthrough.md:30` and the §I checklist item to say "disappears + after the first pop of each field." + +**Owner decision:** which of (a)/(b). Recommend (a): "never returns" reads as a +deliberate promise, and a returning instruction quietly implies she forgot how +to play. + +--- + +## F2 — The walkthrough checklist's §XV is stale on two counts + +**Severity:** Docs · **Area:** §XV, accepted rough edges + +The checklist still lists as an accepted rough edge: + +> *"The sky's deep backdrop redraws more than it needs to while scrolling — +> caching deferred deliberately."* + +`caf10bd` fixed exactly this — it is the fourth bullet of this cut's own "what +is new" list. `SkyView.swift` now wraps the deep field in +`DeepFieldLayer: View, Equatable`, equatable on `size` alone, which is sound: +`SkyRenderer.drawDeepField` reads nothing but `size` and a constant seed, so +eliding an update cannot elide a change. + +Same section also points readers at *"docs/e2e_walkthrough.md on PR #11"*; the +current cut is #12. + +**Action:** delete the deep-backdrop bullet from §XV and retarget the PR +reference to #12. Both live in the checklist artifact +(`493bd3b9-df20-4e36-9751-e4c8e1972f37`), not in the repo. + +--- + +## F3 — Nothing local can exercise the 18.5 deployment floor + +**Severity:** Major (test coverage gap) · **Area:** release readiness + +`IPHONEOS_DEPLOYMENT_TARGET` is **18.5**. Installed simulator runtimes are +**iOS 18.3** and **iOS 26.5** — nothing in between. + +- 18.3 is *below* the floor: the app will not install there. +- 26.5 is eight minor versions above it. + +So every check in this cut ran on an OS far newer than the minimum the App Store +listing promises to support. Any 18.x-only SwiftUI behaviour — hit-testing, +`TimelineView(paused:)`, `Canvas`, safe-area insets — is currently unverified, +and this is a submission-adjacent risk rather than a cosmetic one. + +**Action:** install an iOS 18.5 (or 18.6) simulator runtime and re-run at +minimum §I, §III and §XI on it. Xcode → Settings → Components. + +**Owner decision:** or consciously raise `IPHONEOS_DEPLOYMENT_TARGET` to a +version you can actually test against, and say so in `RELEASE_v1.3.md`. + +--- + +## F4 — Build warning: unused result in `ribbonTraits` + +**Severity:** Minor (hygiene) · **Area:** §XI, the journal + +The Release build emits exactly one source warning: + +``` +Vesper/World/JournalView.swift:560:31: warning: result of call to 'insert' is unused +``` + +```swift +private func ribbonTraits(isCurrent: Bool) -> AccessibilityTraits { + var traits: AccessibilityTraits = .isButton + if isCurrent { traits.insert(.isSelected) } // ← line 560 + return traits +} +``` + +Behaviourally harmless — `SetAlgebra.insert` mutates regardless, and VoiceOver +does receive `.isSelected` on the current page. It is noise in a build that is +otherwise clean, and noise is where real warnings go to hide. + +**Action:** `_ = traits.insert(.isSelected)`, or +`traits.formUnion(.isSelected)`. One line. + +--- + +## F5 — Version and build numbers are still v1.2's + +**Severity:** Blocker for submission · **Area:** `RELEASE_v1.3.md` §2 + +Unchanged in `Vesper.xcodeproj/project.pbxproj` at this cut: + +| Setting | Lines | Current | +|---|---|---| +| `MARKETING_VERSION` (Vesper, Debug + Release) | 342, 371 | `1.2` | +| `CURRENT_PROJECT_VERSION` (Vesper) | 328, 357 | `1` | +| `MARKETING_VERSION` (VesperTests) | 389, 408 | `1.0` | + +`RELEASE_v1.3.md` marks this **🖐 owner's change — do not let an agent edit +`project.pbxproj`**, so this is recorded, not actioned. + +**Owner decision (already open in `RELEASE_v1.3.md` §7):** 1.3 + build 2, or +2.0. Build must go to at least `2` either way — 1.2 shipped at build 1. + +--- + +## R1 — After a clear, the world should rise to the sky on its own + +**Severity:** Request — verify first, it may already be built · **Area:** §V, auto-onward + +**Owner's note:** *"after completion, the screen should automatically scroll up to +view the sky."* + +**This is already specified and already implemented.** It is §V of the +walkthrough, and the machinery is there: + +| Step | Where | Timing | +|---|---|---| +| Done card is hers alone | `GameConfig.onwardToSkyDelay` | **3.4 s** | +| …then the world rises to the sky | `GameViewModel.swift:464-471` (`onwardWork = rise`) | | +| …then, with exactly one road on, it steps onto the next stone and carries her down | `GameConfig.onwardInSkyPause` → `GameViewModel.swift:495-496` | **+2.2 s** | + +A fork deliberately **halts** in the sky with the roads lit, rather than +choosing for her. + +**So the action here is to confirm, not to build.** I could not confirm it +behaviourally: every attempt was cancelled by my own harness, because §V says +*"any touch during the sequence cancels it for that field"* and a tap-driven +clear keeps tapping after the last orb pops. That cancel is correct behaviour — +but it means the rise itself is **unverified on this cut**. + +**Action:** clear a field on device, then take your hands off the glass +completely. The world should lift to the sky at ~3.4 s. +- If it does rise — the request is already satisfied, and the open question is + whether **3.4 s feels right**, which is a tuning call, not a code one. +- If it does not rise — that is a defect against §V, and the first place to look + is `cancelOnward()`: `restart()` now calls it (new in `caf10bd`), so anything + that triggers a restart on the way into the done card would kill the sequence + before it starts. + +--- + +## R2 — A pointer to the next suggested stone + +**Severity:** Request — new work · **Area:** §IV, the sky + +**Owner's note:** *"it should have some sort of cursor that points to the next +suggested level (to click)."* + +**What exists today** is signage, but nothing on the stone itself: + +- The road ahead is drawn in its own brighter tier — `SkyView.swift:67` + (`case onward`), assigned at `:253`, painted at `:503` as + `ink = accent; alpha = 0.30`. +- The HUD posts a line after a clear: *"the path continues"* / *"the path forks + — N roads ahead"*. + +So she is told *that* the path continues, and the road ahead is lit — but the +destination **star** carries no mark of its own. That is the gap the note is +pointing at. + +**Two constraints any answer has to satisfy**, both from `CLAUDE.md`: + +1. **Guardrail 1, affirming-only, no pressure.** A blinking arrow, a pulsing + "GO HERE", or anything that reads as a prompt to hurry is out. The phrasing + to test against is *more engaging, never more difficult* — and never more + demanding. +2. **The One World pillar: no icons, no chrome.** A literal cursor or arrow + glyph would be the first piece of UI furniture in a world that has + deliberately none. It should be made of what the sky is already made of. + +**Three options that stay inside the existing vocabulary**, cheapest first: + +- **(a) Carry the onward tier onto the star.** The road is already brighter; + give its destination star the same accent at a matching alpha. One value, in + `SkyRenderer`'s star pass, next to the existing anchor-rim treatment. Least + new vocabulary, least risk. +- **(b) Let the suggested star breathe.** Stars already breathe (and already + stop under Reduce Motion). Give the onward star a slightly deeper, slower + breath than its neighbours. Reads as invitation rather than instruction. +- **(c) A mote that walks the road.** A single drifting light travelling the + onward road from the anchor to the next stone, once, slowly. The most + beautiful and the most work; also the most likely to need a Reduce Motion + answer. + +**Recommendation:** (a) first, and look at it before building (b) or (c) — the +road is already lit, and the star inheriting that light may be the whole fix. + +**Must not do:** pick a winner at a fork. §V halts in the sky with **both** +roads lit precisely so the choice stays hers; a "next suggested" marker that +resolves a fork would quietly undo that. At a fork, either mark both or mark +neither. + +**Owner decision:** (a), (b) or (c) — and whether a fork gets two markers or +none. + +--- + +## R3 — A launched firework should be piercing + +**Severity:** Request — new work, and the largest of the three · **Area:** §X, fireworks + +**Owner's note:** *"fireworks should pop bubbles as they move (after being shot +off); that is the firework should be 'piercing', counting as a 'user click' on +the items it touches; (including activating other fireworks)."* + +**Today the climb touches nothing.** A shell lights, climbs for ~1 s, and only +the *break* affects the field — and it affects it with a **shove**, not a pop +(`GameSimulation.swift:1153`, `GameConfig.fireworkShove`). The climb is pure +spectacle. + +### Where it goes + +The clean insertion is the rising step, and the clean implementation is to reuse +the tap path rather than write a second one: + +| Piece | Where | +|---|---| +| Shell enters the climb | `GameSimulation.swift:880` — `phase = .rising(progress: 0)` | +| Climb advances each frame | `GameSimulation.swift:929` — `phase = .rising(progress: next)` ← **sweep here** | +| What a touch already does to orbs *and* to fireworks | `GameSimulation.swift:334-356` — `tap(at:)` pops orbs, lights a waiting fuse, hurries a burning one | + +Because `tap(at:)` already handles **both** orbs and fireworks, routing the +shell's swept path through the same code is what makes the request literally +true: it *is* a user click. Pops, chain rings, points, the counter, the +"chain of N" whisper and **lighting other shells** all follow for free, and the +sim stays the single source of truth — sound and haptics come along because they +already ride `GameEvent`. + +### Four things that will bite + +1. **Sweep the segment, not the point.** The climb crosses most of the field in + ~1 s, so per-frame travel is comfortably larger than an orb. A point test at + each frame will **tunnel straight through orbs**. Test the capsule from last + position to this one against `orb.pos` + radius. +2. **Reduce Motion changes the outcome.** §X's own fixed item says that under + Reduce Motion *"the shell climbs straight"*. If the path decides what gets + pierced, then a **straight path pierces different orbs than a wobbling one** — + an accessibility setting would silently change how many orbs pop and how many + points accrue. Nothing is lost or compared, so no guardrail breaks, but the + "same stone, same field every time" promise picks up an asterisk. **Decide + deliberately:** either accept it, or make the collision path the straight one + in both modes and let only the *drawn* path wobble. +3. **The audio limiter stops being optional.** §XV already accepts that *"huge + chains can in principle sum loud (no limiter)"*. A shell piercing a dense + field is precisely the huge-chain generator that assumption was written + before. This likely becomes a **prerequisite**, not a pending tuning pass. +4. **The particle cap will be hit harder.** `burst()` already trims to + `GameConfig.particleCap` with `removeFirst` (`GameSimulation.swift:1078`). A + pierce cascade *plus* a break in the same frame will trim more aggressively, + and `removeFirst` eats the oldest particles — visible as effects vanishing + mid-life rather than fading. + +### Guardrail check + +**It passes, and comfortably.** It makes the field shorter and more spectacular, +never harder — *more engaging, never more difficult*. Nothing is lost, spent or +failed, and an unlit shell still gates nothing. + +**Cascades terminate.** A field carries 2–6 shells and each lights once, so +shell-lights-shell is bounded by the shell count. No loop guard needed, though +a lit shell must not be re-lightable by a later pierce. + +**One arithmetic knock-on:** the break currently raises **3 orbs from the +reserve** so that *"a display never lengthens the field"* (§X). Piercing now +*shortens* it on the way up. That is the right direction, but the reserve rule +was tuned against a climb that touched nothing — revisit whether 3 is still the +number. + +**Owner decision:** the Reduce Motion question in (2) — same path for both, or +accept a different outcome per setting. + +--- + +## R4 — The chain is too intensive; she doesn't get to pop enough + +**Severity:** Request — tuning · **Area:** §I, the core loop + +**Owner's note:** *"the chain of popping is too intensive (i don't get to pop as +much as i like)."* + +### What actually happens + +**Chains are already only one generation deep.** A direct pop's *first* ring +arms; every other ring is decoration — `GameSimulation.swift:448-449`: + +> *"Only the first ring of a direct pop arms further chains; echo rings — +> extras, and any ring from a chained pop — are visual only."* + +So the complaint is **not** a runaway cascade. It is **reach**: one ring is +sweeping too much of the field. The knobs are all in `GameConfig.swift`: + +| Constant | Value | What it does | +|---|---|---| +| `ringBaseMaxRadius` | `110` | Base reach of the shockwave | +| `ringRadiusPerOrbRadius` | `2.6` | Bigger orb → bigger ring | +| `ringArmRadius` | `18` | Ring must exceed this before it can catch anything | +| `ringShellThickness` | `24` | Only orbs inside this moving band are caught | +| `ringDisarmFraction` | `0.5` | The ring stops catching after **half** its growth | + +A large orb therefore reaches roughly `0.5 × (110 + 2.6 × r)` ≈ **100 pt** — a +quarter of the screen's width, on a field that at stage 5–6 is dense. + +### The one knob to turn first + +`CLAUDE.md` says tuning is sacred and changes go **one at a time**. The best +first move is **`ringDisarmFraction`, 0.5 → ~0.38**, because it is the only knob +that **shrinks what the ring catches without changing what the ring looks +like**. The shockwave still blooms to exactly the same size and the pop still +feels as big; it simply stops recruiting sooner. Everything else on that list +makes the visual smaller too, which costs the feel she is not complaining about. + +If that is not enough, `ringBaseMaxRadius` (110 → ~95) is second. Leave +`ringRadiusPerOrbRadius` alone longest: `GameConfig.swift:311` notes that a +bigger ring is an *unlock reward* — *"both only ever help"* — so flattening the +big-orb advantage quietly weakens a progression promise. + +### A caution about my own numbers + +This session recorded a **best chain of 58**, and it would be easy to read that +as proof. **It is not.** `chainStreak` counts *pops inside a 0.9 s window* +(`GameViewModel.swift:533-540`, `GameConfig.chainWindow`), not cascade depth — so +my tap harness firing many taps per second inflated it. Treat 58 as an artefact +of the robot, not evidence. **Your hands are the evidence here.** + +**Owner decision:** confirm the direction on device after the `ringDisarmFraction` +change, and whether the chain-of-N whisper should still fire as readily once +chains are shorter (`chainNoteThreshold = 3`). + +--- + +## R5 — The sky/field join has a visible dark edge + +**Severity:** Request — polish · **Area:** §III, one world + +**Owner's note:** *"the border between the sky and the game place has noticeable +darkness (can we make it one continuous)."* + +**Confirmed — I saw it in every field screenshot this session:** a crisp +horizontal line about **216 pt** down the screen, lighter above, darker below. +That is exactly `0.25 × 874`, i.e. the join where the peeking sky place's +rectangle ends and the field's begins. Neighbours peeking is by design (§III, +0.75 screens apart); the hard *edge* between them is not. + +### Why it shows + +`HorizonRenderer.swift` exists precisely to hide this join — its own header +calls it *"the seamless answer"*. The glow's strength varies along the axis, and +here is the problem (`HorizonRenderer.swift:132-137`): + +``` +static let fieldPresence: Double = 0.5 +static let skyPresence: Double = 1.0 +``` + +The light **rises to full toward the sky and sits at half at the field**. So the +join is least concealed exactly where she stands for the whole game, and fully +concealed only while travelling to a place she visits between fields. The +covering cue is weakest where it is needed most. + +### Knobs, cheapest first + +| Constant | Value | Effect | +|---|---|---| +| `fieldPresence` | `0.5` | **Raise toward `skyPresence`.** The single most direct fix — no new drawing, just more of the glow that already exists, at rest. | +| `lightPeak` | `0.055` | Overall glow strength; lifts the whole cue, sky included. | +| `luminanceCap` | `0.12` | Hard clamp — raising `fieldPresence` may run into this. | +| `depthFloor` / `depthCeiling` | `0.12` / `0.40` | How deep the blend band is. A deeper band spreads the join over more pixels. | + +**Try `fieldPresence` first**, alone, and check `luminanceCap` is not clipping +the result before reaching for anything else. + +**Watch out for:** the ground colours themselves differ per place (charcoal / +blue-black / warm brown-black, §III). If the two grounds are far enough apart in +value, no amount of glow will fully hide the join and the real fix is bringing +the sky's and field's ground stops closer at the seam. Check that before +spending effort on the glow. + +**Do not** simply darken the sky's ground to match — the ground crossfade is a +travel cue in its own right (§III), and flattening it would cost the sense of +moving between places. + +--- + +## R6 — More fireworks + +**Severity:** Request — tuning · **Area:** §X, fireworks + +**Owner's note:** *"more fireworks please."* + +There are **two independent levers**, and they answer different complaints. + +### Lever 1 — how often a field has any (frequency) + +`FieldPlan.swift:214-215`: + +```swift +guard stage >= 2 else { return false } +return (generation + stage) % 2 == 1 +``` + +Displays land on **alternating stones**, and the *other* alternating stones carry +the balloon animal. So today it is display, creature, display, creature. + +**This lever is the expensive one to pull.** `FieldPlan.swift:228-236` is +explicit that the alternation is doing three jobs: it keeps neither spectacle +ordinary, it is a function of the stone (so a stone is the same field every time +she returns), and it guarantees a display and an animal never share a field — +because *"a shy animal keeping to the edges while shells go up in the middle is +two things asking for the same attention, and a firework's shove would be pushing +the one orb that is trying to stay put."* + +Raising frequency therefore costs animal frequency one-for-one, unless you accept +the two sharing a field — which that comment argues against, and which R3's +piercing shells would make worse (a shell that pops what it touches would hunt +the animal). + +### Lever 2 — how many shells per display (density) + +`FieldPlan.swift:220-223`: + +```swift +return min(GameConfig.maxFireworksPerField, 2 + generation / 3) +``` + +So **2 shells**, gaining one every 3 generations, capped by +`GameConfig.maxFireworksPerField`. The cap's comment says it exists *"so a field +never becomes a firing range."* + +**Pull this one first.** `2 + generation / 3` → `2 + generation / 2` gets more +shells on screen sooner without touching the alternation, the animal's share, or +the same-stone-same-field promise. Then raise `maxFireworksPerField` if the cap +binds before it feels right. + +**Owner decision:** is the wish *"more shells when I get a display"* (Lever 2 — +cheap, no side effects) or *"displays more often"* (Lever 1 — comes straight out +of the balloon animals' budget)? **My read is Lever 2**, and it is worth trying +alone before touching the alternation. + +--- + +## R7 — The pops are not unique enough (MAJOR) + +**Severity:** Major — it undercuts the 100-pop collection · **Area:** §XI, progression + +**Owner's note:** *"the pops are not unique enough; i literally cannot tell the +difference between the pops (unless its an animal)."* + +**This one has a precise, measurable root cause, and it is not a taste +question.** + +### The schema is rich. The catalog does not use it. + +`PopStandard.swift` gives a pop five perceptual channels. I counted what the +100 entries in `PopCatalog.swift` actually set: + +| Channel | Values available | Entries (of 100) that set their own | Result | +|---|---|---|---| +| **Sound voice** | 10 (`pop`, `tone`, `bell`, `pluck`, `breath`, `glass`, `wood`, `crackle`, `shimmer`, `drop`) | **0** | inherited from family | +| **Haptic pattern** | 5 (`single`, `double`, `ripple`, `swell`, `thud`) | **0** | inherited from family | +| **Burst motion** | 10 (`radial`, `bloom`, `implode`, `spiral`, `drip`, `ascend`, `scatter`, `shiver`, `ring`, `veil`) | **0** | inherited from family | +| Particle shape | 5 | 54 | 21 spark · 13 shard · 10 ring · 10 petal · 46 dot | +| Shimmer on/off | 2 | 27 | | + +The zeroes are exact, not approximate. `voice:`, `pattern:` and `burst:` each +appear exactly **twice** in `PopCatalog.swift` — once in the `def()` signature +as `= nil`, once in the body as `voice ?? family.voice`. **No individual pop +overrides any of them.** + +### What that means in the hand + +There are **10 families** (`vesper, ember, tide, bloom, frost, chime, lantern, +current, prism, aurora`). So across 100 pops there are: + +- **10 distinct sounds** — one per family, and all ten pops inside a family are + sonically **identical**; +- **10 distinct burst motions** — same; +- **at most 5 distinct haptics** — same. + +Within a family, one pop differs from another only by **paint, particle shape +(5 options), a shimmer flag, and small numeric tweaks**. On a dark screen, at +orb size, in motion, that is not enough signal — which is exactly what the note +reports. + +**And the animal is the control case that proves it.** An animal reads as +different because it varies on channels nothing else does: a silhouette, a +multi-tap life, a dart, its own sound. It is the one thing in the game that uses +more than paint. + +**Corroborating, already accepted:** §XV of the walkthrough concedes that *"pop +detune variants render nearly identical"* and that *"split/emit/startle sounds +clamp to the top pitch bucket."* So even the small numeric sound differences +that **are** authored are being flattened by the engine before they reach her. + +### What to do + +The good news: **nothing needs new engine work.** Unlike `FireworkBurst.gravity` +(F-list, authored but never read), all three channels are live — +`SceneRenderer.swift:473` reads `particleShape`, `GameSimulation.swift:590` +switches on `behavior.burst`, `HapticsEngine.swift:41` switches on +`profile.pattern`. **The renderer is ready and the data is empty.** This is an +authoring pass, not a build. + +1. **Break the family lock on burst motion first.** It is the most visible + channel at arm's length, it costs no audio work, and there are 10 motions for + 10 pops per family — a family could use all ten and no two of its pops would + ever burst alike. +2. **Then voice.** 10 voices × the existing per-pop `freq` / `sweep` / `decay` + numbers is a very large distinct space. Fix the flattening in §XV first, or + the new voices land in the same mush. +3. **Haptics last** — 5 patterns is the thinnest channel and it is silent on + iPad anyway (§XIII). + +**Owner decision:** whether a family should stay sonically coherent *by design* +(a family reads as a family — which is a real argument, and may be why it was +authored this way) or whether family identity should live in **paint and name +only**, leaving sound and motion free to individuate. That choice governs the +whole pass and should be made before anyone edits 100 entries. + +--- + +## R8 — The weather is too soft and not interesting enough (MAJOR) + +**Severity:** Major — a headline v1.3 feature is under-reading · **Area:** §VII, weather + +**Owner's note:** *"the weather is 'too soft' and is not 'interesting' enough."* + +Two separable problems, and they have different fixes. + +### Problem 1 — she rarely sees any weather at all + +`Weather.choose` (`Weather.swift:225-233`): + +| Air | Odds | +|---|---| +| **clear** | **44%** | +| rain | 16% | +| summer/warm | 12% | +| snow | 10% | +| fog | 10% | +| storm | **8%** | + +**Nearly half of all fields have no weather whatsoever**, and the most dramatic +air is the rarest at 1-in-12. The comment above it defends this — *"a sky that +is remarkable every evening is remarkable never"* — and that argument is sound +for a game played a few fields per evening. But it means the feature reads as +absent, and **storm, the one air that would answer "not interesting enough," is +the one she almost never gets.** + +Cheapest possible experiment, and it changes no rendering: **drop `clear` to +~25–30% and put the difference into `storm` and `rain`.** One function, one +edit, immediately testable. + +### Problem 2 — when it does arrive, it barely touches the field + +`flowCarry` is how much the air actually moves orbs (`Weather.swift:147`): + +| Air | `flowCarry` | +|---|---| +| rain | 1.35 | +| storm | 1.20 | +| summer | 0.50 | +| **clear, snow, fog** | **0** | + +**Three of the six airs move nothing at all.** Snow and fog are pure overlay — +72 flakes and 7 banks drawn *in front of* a field that behaves exactly as it +does in still air. So half the weather in the game is wallpaper, and "too soft" +is a fair description of wallpaper. + +Note also that **storm carries *less* than rain** (1.20 vs 1.35), which is +probably backwards from what the word "storm" promises. + +### Where the ceiling is, and the one rule not to break + +Other knobs: `speedScale`, `glide`, `swellAmount`, `swellRate`, `wander`, +`shine`, plus the counts (`crestCount` 2, `eddyCount` 4, `flakeCount` 72, +`shaftCount` 3, `bankCount` 7). + +**The hard constraint is §VII's own line: weather "never changes +hittability."** Air may move an orb; it must never make one harder to hit. So +the room to grow is in **amplitude and legibility of motion**, not in speed that +outruns a thumb. Snow already has the right idea — *"orbs grip and move in +little jerks"* — it is just set to zero carry. + +### Suggested order + +1. **Odds** — `clear` 44% → ~28%, into storm and rain. No render changes, biggest + perceived gain per line changed. +2. **Give snow a real `flowCarry`** (~0.3–0.5). It is the air most obviously + doing nothing, and the settling/melting behaviour is already built. +3. **Make storm out-carry rain** — swap the ordering so 1.20 > 1.35 becomes + storm-dominant, and consider raising `eddyCount` or gust depth. +4. **Leave fog at 0 carry** — fog's whole idea is the hole she pushes with a + finger, which is interaction, not motion. Its problem is that it is *behind* + nothing; it already reads. + +**Must re-check after any of these:** Reduce Motion, where §VII requires *"the +air is nearly still and never carries orbs."* Strengthening carry must not leak +past that guard. + +**Owner decision:** step 1 alone may be the whole fix — she may simply need to +*meet* the weather more often before concluding it is dull. I would ship the +odds change first and re-judge before touching carry, per `CLAUDE.md`'s +one-knob-at-a-time rule. + +--- + +## E1 — Test-environment notes (not product defects) + +Recorded so the next cut does not rediscover them. + +**E1a — `iPhone 16 Pro` does not exist on a usable runtime.** It is present only +on iOS 18.3, below the deployment floor. I created one on 26.5: + +```sh +xcrun simctl create "iPhone 16 Pro (26.5)" \ + com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro \ + com.apple.CoreSimulator.SimRuntime.iOS-26-5 +``` + +Two devices then share the name, so `-destination 'name=iPhone 16 Pro'` is +ambiguous — drive it by UDID. Delete when done: +`xcrun simctl delete E692193E-283F-4645-AF1D-75F30F3C4AC6`. + +**E1b — a fresh simulator burns ~800% CPU on its own.** `mediaanalysisd` inside +a newly created device pegged roughly eight cores indefinitely, analysing the +stock photo library. It is unrelated to Vesper but it will wreck any judgement +about pop latency, frame pacing, warmth or battery — precisely what §II and +§XIII ask you to feel. Kill it before testing feel: + +```sh +xcrun simctl spawn launchctl bootout system/com.apple.mediaanalysisd +``` + +Worth a line in `docs/PLAYTEST.md`. + +**E1c — Vesper's own resource behaviour was clean.** Across 268 samples during +active play: peak 104% CPU (one core), peak 447 MB RSS, **zero swap**, host free +memory never below 95%. No leak signature — RSS sat flat at ~300 MB through +hundreds of pops and returned there after each field. + +--- + +## Non-findings — do not re-chase + +Recorded because they looked like serious defects mid-run and were not. + +- **"The sky is inert to taps."** Stars and the "the field" whisper appeared + completely dead across ~6 attempts. **Cause: my harness**, not the app. The + Simulator window moved mid-session and the tap driver held a cached window + origin, so every touch landed 147 pt off target. With the origin re-read per + tap, stars, whispers and journal rows all respond normally. +- **"Two orbs will not pop after ~100 taps."** Same root cause, plus centroid + bias: the blob detector's centre is pulled off the orb body by its asymmetric + glow. With the origin fixed, a full field cleared in 4 scan-and-tap passes. +- **"A field froze."** The peeking field's orbs are static and CPU drops to ~5% + while she is at the sky. That is the sim correctly not stepping a place she is + not standing in, not the §II freeze. + +--- + +## Verified this cut + +**The four new behaviours (§4)** — all four present; each diff read against +`1afe11f`: + +| Behaviour | Where | Verdict | +|---|---|---| +| A tap stops a coasting sky | `SkyScroll.swift` `catchGlide()`, `WorldInputView.swift` `onTouchDown` | Present, observational only, called ahead of arbitration | +| Choosing during the sky pause sticks | `GameViewModel.swift` `restart()` → `fieldEpoch` + `cancelOnward()`; onward step gained `guard self.sim.completed` | Present, belt and brace | +| A field can no longer freeze | `GameViewModel.swift` `frame()` epoch stamp on the `main.async` hop | Present, discards stale blocks whole | +| Cheaper sky scroll | `SkyView.swift` `DeepFieldLayer: View, Equatable` | Present and sound | + +The two sky-side behaviours could not be exercised *behaviourally* — the sky +scroll needs ≳6 generations and the map is still 4 stars. + +**Behavioural checks that passed** + +- **§I** — opens straight into the field, no menu, status bar hidden; hint + present and gone after the first pop; done card complete: *"the field is quiet + now."*, a verse, "75 set free", "+3,230 pop points", "303 set free, all time.", + "again?", and **no scrim** — the world is live behind it. +- **§III** — **navigation direction is correct**: a finger moving up travels to + the sky, the journal is below. Whispers name the ways and travel on tap; from + the sky and journal only "the field" is offered. +- **§IV** — family-shaped gems, closed ring on cleared stars, anchor's brighter + rim, solid walked road vs dashed unwalked, rendered as a fork. +- **§X** — waiting shells with rope fuses; an unlit shell just sits there and the + field clears around it. +- **§XI** — all three pages via the ribbon; hush; sound/haptics/point-whisper + toggles; "begin this field again"; "Vesper · made by Kate Wu · collects + nothing"; the "✦ new pop · Pearl" capsule. The journal's numbers matched the + persisted plist exactly (5,219 pts · 121 set free · 7 fields · 7 fortunes · + 13 best chain). +- **§XIV** — source grep finds **no** `URLSession`, `URLRequest`, `import + Network`, `CFNetwork`, `StoreKit`, `requestReview`, `UNUserNotification`, + `CLLocation`, `ATTracking` or any analytics SDK anywhere in `Vesper/`. +- **Progression / stage gates** — accrued correctly across the session (303 + lifetime pops, best chain 29). Fireworks appeared exactly at stage 2, matching + `FieldPlan.stage = cleared / 3` at 7 fields cleared. + +**The two known issues in the directive are accurately described** + +- `Vesper/Game/Firework.swift:169` documents in-source that `gravity`, `splits` + and `twinkles` are authored but not read — so all fourteen shells falling at + one rate is expected, and a willow does drop like a crackle. +- The animal dart is implemented as a briefly raised **speed ceiling** + (`AnimalPop.swift:284-289`), not an impulse, which is the mechanism by which + weather-driven velocity eats it. + +--- + +## Not tested — needs a person and hardware + +Nothing below is a pass or a fail; it is untouched. + +| Area | Why it needs you | +|---|---| +| §I, §VIII haptics | No haptics in the simulator | +| §II battery / warmth | Needs a real phone over real time | +| §XIII ProMotion parity | Needs a 120 Hz panel and a 60 Hz one, side by side | +| §XIII VoiceOver, Dynamic Type | Needs the real screen reader and text sizes | +| §XIII iPad, Split View | Not run | +| §XII 3-day settling | Needs a clock advance across days | +| §VI sky scroll, glide-catch | Needs ≳6 generations of map | +| §V onward-pause timing | A tap-driven harness cancels the sequence — which is §V's "any touch cancels it" working correctly, but it means the ~2 s window needs a human finger | +| §VII weather (all six airs) | Only clear air came up; the odds are deliberately lopsided | +| §IX splitters, drifters, generators, depth | Needs stages 2–6, i.e. 18 cleared fields | + +--- + +## Housekeeping + +- **Your uncommitted dev scaffolding is stashed, not lost.** `DevStage.swift`, + `DevWeather.swift` and the `JournalView`/`GameViewModel` hooks are in + `stash@{0}` ("dev affordances: DevStage/DevWeather + journal hooks + (pre-caf10bd)"). They were written against `e6300d4` and will likely conflict + with `caf10bd`'s `GameViewModel.swift`, so they were left for you rather than + forced back. `git stash pop` when you want them. +- Build output: `build/` is already covered by `.gitignore:12`. **`build-test/` + (mine) and `build-release/` (pre-existing, left alone) are NOT ignored** and + currently show as untracked — worth adding to `.gitignore` or deleting. +- Those dev affordances are `#if DEBUG` throughout and cannot reach a Release + binary, so they never needed removing for this cut — only for a clean diff.