@bitefoundry/retrieviewer 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,6 +95,13 @@ puppy sheet, not new photographs. COMFORT-ART.md records the prompt, source hash
95
95
  and fixed-scale preparation. The ninth archive is covered explicitly by
96
96
  ASSETS-LICENSE.md and ATTRIBUTION.md. Original sheet PNGs remain outside npm.
97
97
 
98
+ Local candidate2026-10-24 changes only offline watch-frame selection: the same
99
+ 15 approved whole-body drawings are held, not crossfaded into double contours.
100
+ Source sheet SHA256 and registration stay unchanged; `watchRegistration` now
101
+ records the exact36 source-key indices and `pixelBlending:false`. No new generated
102
+ or photographic input, asset family or attribution is added. Existing CC BY-SA4.0
103
+ terms and all non-watch pixels remain unchanged. See WATCH-REDRAW.md for the A/B.
104
+
98
105
  Copyrightability of AI-generated expression, the precise derivative-work analysis,
99
106
  and worldwide name clearance are not conclusively determined here. Existing
100
107
  domestic trademark research records possible similar marks; owning an npm scope
package/EMBEDDING.md CHANGED
@@ -30,6 +30,12 @@ pet.dispose();
30
30
  - Frame contains text,columns,rows,artProfile,pixels,state,report,placement,
31
31
  projection,mouth,contacts. pixels is a **borrowed** RGBA buffer: valid only until
32
32
  next frame/dispose; copy if retaining. The wrapper also retains no frame.
33
+ - needsFrame(frameOptions): optional pre-render check for a host that only needs
34
+ changed pictures. False means a known held pose and all visible objects match
35
+ the last frame. Always keep calling step(dt); needs/cooldowns/autonomy continue.
36
+ Moving poses, gestures, visitors and sleep cues conservatively return true.
37
+ frame() itself never skips or returns stale metadata. Force a frame when the
38
+ host loses its display contents (for example after hiding or reconnecting).
33
39
  - camera:{viewportWidth,viewportHeight,rect:{left,top,width,height}} places a pane
34
40
  within a larger viewport. Keep it consistent across time for metric perspective.
35
41
  - interact('ball'|'treat'): false while busy/cooling down; unknown kind throws.
package/PUPPY-MEMORY.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Runtime weight and performance
2
2
 
3
+ ## Held-pose invalidation (local candidate, 2026-10-24)
4
+
5
+ The desktop pump now asks `pet.needsFrame(viewport)` before rasterization. Only
6
+ known held seated/down poses qualify; the key includes their source selection,
7
+ world position, camera/grid and visible objects. Moving/turning poses, gestures,
8
+ butterflies and sleep cues take the normal rendering path. Simulation, needs,
9
+ cooldowns and autonomous decisions still advance. Accepted input, viewport
10
+ revision and pause/resume invalidate immediately. `frame()` itself still paints
11
+ and returns current metadata and the same borrowed-buffer contract. One small
12
+ key is retained, not another raster; no dependency, timer or process is added.
13
+
14
+ Same seed731, 480x135 cells, 450 ticks at30Hz, first throw at tick24, brake return:
15
+ without the hint450 renderer calls, with it300; both publish259 identical native
16
+ frames (transport SHA256 `4069444df1bc5c1a43375ff8f3f9a4cee9bc9965c047ae5f6bfecdcc01721985`).
17
+ During one settled1s offer, full renderer calls fall from30 to0. One local pair
18
+ measured CPU2469ms ->1499ms; timing is a sample, not a universal CPU saving.
19
+ Reproduce with `node tools/benchmark-held-render.mjs`.
20
+
21
+ Offscreen fresh-process diagnostic-GC RSS is effectively unchanged: loaded
22
+ 102,940,672 ->102,952,960 bytes; rendered179,982,336 ->180,019,200 bytes.
23
+ The retained ArrayBuffer difference is -19,124 bytes, mainly fewer unique watch
24
+ samples. This is a CPU/allocation-work improvement, NOT a large desktop RAM cut.
25
+ No live native-helper RSS or Linux/macOS performance was measured in this pass.
26
+
27
+ Long-run treat/tug attention now replans a local brake from the current speed:
28
+ `d = v*v/(2*a)`, retaining heading, distance-driven gait phase and existing
29
+ run/walk deceleration. It cancels obsolete waypoints rather than waiting for a
30
+ 15m trip to finish. A hand-fed treat appears immediately; recovery at the tested
31
+ rear-running speeds finishes in1.08-1.94s, including turning and sitting.
32
+ Existing roll/tug/walk-entry recovery still reaches connected source endpoints.
33
+ This does not change ball-return variants or multiply physical simulation time.
34
+
3
35
  ## Spatial autonomy and allocation pass (0.0.10)
4
36
 
5
37
  Autonomous strolls, dashes and butterfly visits no longer end with an unconditional
package/RELEASING.md CHANGED
@@ -1,4 +1,21 @@
1
- # Retrieviewer 0.0.10 release process
1
+ # Retrieviewer 0.0.11 release process
2
+
3
+ Current target: `@bitefoundry/retrieviewer@0.0.11`, public `latest`.
4
+ On2026-10-24 the owner accepted the reviewed candidate and authorized immediate
5
+ release. This scope removes blended watch contours while retaining all15 approved
6
+ whole-body keys, interrupts treat/tug journeys with velocity-continuous braking,
7
+ and checks held-pose dirtiness before rasterization. Existing fetch patterns and
8
+ all three return choices remain. No new runtime dependency, timer or process.
9
+ RAM is effectively unchanged; this pass reduces repeated CPU/allocation work.
10
+
11
+ Build a fresh exact tarball, install outside the source tree, compare every
12
+ installed byte, check its owned Windows native lifecycle and bilingual browser
13
+ runtime, then publish that exact tarball. Verify registry integrity and GitHub
14
+ Pages runtime bytes afterwards. Do not restart the user's existing companion.
15
+ Earlier npm artifacts remain immutable. MIT code / CC BY-SA4.0 artwork and
16
+ experimental macOS / X11-only Linux boundaries remain unchanged.
17
+
18
+ ## Previous 0.0.10 scope (historical)
2
19
 
3
20
  The owner explicitly requested publication and website deployment after reviewing
4
21
  the repaired watch/fetch motion and restoring the original braking return as an
package/WATCH-REDRAW.md CHANGED
@@ -159,6 +159,17 @@ after accepting candidates; the pending package tarball is obsolete.
159
159
 
160
160
  ## Local review
161
161
 
162
+ Local follow-up (2026-10-24): source-key selection replaces the pixel crossfade
163
+ in `prepare-watch-redraw.py`. All15 registered whole-body keys, common scale,
164
+ ground registration,36 logical samples and canonical seat endpoints remain.
165
+ Each sample is now one exact authored key; outward/return paths reuse the same
166
+ bytes instead of drawing two semi-transparent muzzles/shoulders. No new image,
167
+ geometry warp or runtime blend is introduced. Non-watch decoded pixels remain
168
+ byte-exact. The behavior archive drops from1,286,465 to1,270,759 gzip bytes.
169
+ `dist/watch-no-ghost-v1/` preserves the candidate and source-key receipt;
170
+ `git show HEAD:puppy-paper-behaviors-q5.bin.gz` remains the immutable release A/B.
171
+ Automated checks do not replace final owner approval of the new held-key cadence.
172
+
162
173
  - `node tools/prepare-watch-redraw.mjs` writes a candidate, not shipped assets.
163
174
  - `node tools/check-watch-redraw.mjs` makes a same-state old/new text replay.
164
175
  - `node tools/prepare-scramble-art.mjs` writes a candidate, not shipped assets.
@@ -30,13 +30,16 @@ export function createFramePump({pet,send,now=()=>performance.now()}){
30
30
  alive();if(inFlight||!viewport||paused)return false;
31
31
  const t=now(),dt=last===null?0:Math.max(0,(t-last)/1000);last=t;
32
32
  pet.step(dt);if(t<nextPaint)return false;
33
+ // Do this BEFORE rasterization/Braille conversion. Older embedders without
34
+ // the optional hint retain the original byte-comparison fallback below.
35
+ if(lastFrame&&pet.needsFrame?.(viewport)===false)return false;
33
36
  const source=pet.frame(viewport),frame=cropFrame(source,revision);
34
37
  nextPaint=source.state?.state==='sleep'?t+200:0;
35
38
  // A held seat/down pose can be byte-identical for seconds. Advance the life
36
39
  // clock normally but avoid JSON, IPC, bitmap allocation and native repaint.
37
40
  if(lastFrame&&frame.x===lastFrame.x&&frame.y===lastFrame.y&&frame.columns===lastFrame.columns&&frame.rows===lastFrame.rows&&frame.text===lastFrame.text)return false;
38
41
  inFlight=true;
39
- try{send(frame);lastFrame=frame;}catch(error){inFlight=false;throw error;}return true;
42
+ try{send(frame);lastFrame=frame;}catch(error){inFlight=false;lastFrame=null;nextPaint=0;throw error;}return true;
40
43
  },
41
44
  interact(kind){alive();if(!['ball','treat','tug'].includes(kind))throw Error('Invalid overlay interaction');const accepted=pet.interact(kind);if(accepted){lastFrame=null;nextPaint=0;}return accepted;},
42
45
  get hidden(){return !viewport||paused;},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitefoundry/retrieviewer",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "A fluffy Braille desktop pet for Windows and Linux X11, with experimental macOS support and an embeddable TUI engine.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://bitefoundry.github.io/retrieviewer/",
@@ -77,14 +77,14 @@
77
77
  "width": 320,
78
78
  "height": 320,
79
79
  "frames": 162,
80
- "uniqueFrames": 128,
80
+ "uniqueFrames": 126,
81
81
  "sourceSha256": "7918261fb64c57a91739729841fd84c6dd53891196c7fb76ee9c27712ab6ef5d",
82
82
  "sourcePackedSha256": "7785ef65b8a2a5bf6f7ce600fe9957a60d9af82066ff49275bba061eac5392d1",
83
- "encodedBytes": 5918897,
84
- "gzipBytes": 1286465,
85
- "residentPixelBytes": 3680463,
86
- "encodedSha256": "06b2ab8e0d4ff42600818c8eb96bba4089ba77560c6e9b98a662af3967271c2d",
87
- "quantizedSha256": "07d919258ed32175fed81cfe848f5336edaf88706a3b04bfb51506f82c1b2083",
83
+ "encodedBytes": 5797825,
84
+ "gzipBytes": 1270759,
85
+ "residentPixelBytes": 3605128,
86
+ "encodedSha256": "cb045558ca490436c5d6703f6468daba26f20ed76bc886e829787889253ccbb9",
87
+ "quantizedSha256": "eb5007588c9f1843a9279fa21ee701e0f1ac3238d2dda70cc8e8c0c900e3c34b",
88
88
  "watchRegistration": {
89
89
  "sourceSize": [
90
90
  1619,
@@ -99,6 +99,45 @@
99
99
  "nonlinearWarp": false,
100
100
  "perKeyScale": false,
101
101
  "runtimeCompositing": false,
102
+ "pixelBlending": false,
103
+ "sourceKeys": [
104
+ 7,
105
+ 8,
106
+ 9,
107
+ 10,
108
+ 11,
109
+ 11,
110
+ 12,
111
+ 13,
112
+ 14,
113
+ 14,
114
+ 13,
115
+ 12,
116
+ 11,
117
+ 11,
118
+ 10,
119
+ 9,
120
+ 8,
121
+ 7,
122
+ 7,
123
+ 6,
124
+ 5,
125
+ 4,
126
+ 3,
127
+ 3,
128
+ 2,
129
+ 1,
130
+ 0,
131
+ 0,
132
+ 1,
133
+ 2,
134
+ 3,
135
+ 3,
136
+ 4,
137
+ 5,
138
+ 6,
139
+ 7
140
+ ],
102
141
  "keys": 15,
103
142
  "frames": 36,
104
143
  "sourceSheetSha256": "34b9bbca507f82c739c39e4b42e2390202863189d6d1b5e6e8ca7293555e19d3",
@@ -1,8 +1,8 @@
1
1
  // Autonomous companion "life" controller layered over a paper puppy actor.
2
2
  // The parent calls actor.update(dt) THEN life.update(dt). Commands are only issued
3
3
  // while the actor rests at a shared safe endpoint (idle/down); everything else waits.
4
- // Exception: an accepted ball throw reacts at once via actor.chase (the actor owns safe
5
- // interruption); actors without chase fall back to go at the next safe endpoint.
4
+ // Accepted interactions ask the actor for safe interruption: chase for a ball,
5
+ // attend for a hand-fed treat/tug. Substitute actors can use safe-endpoint fallback.
6
6
  // Parameters are authored for feel, not canine science.
7
7
  import {paperCameraPoint,paperGroundTarget,PAPER_SCREEN,PAPER_CAMERA} from './puppy-paper-world.mjs';
8
8
  import {createBallPath,createFetchPatterns} from './puppy-companion-ball.mjs';
@@ -260,12 +260,15 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
260
260
  quiet=0;
261
261
  if(butterfly||lastEpisode==='butterfly'){
262
262
  butterfly=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
263
- if(kind!=='ball')actor.sit();
264
263
  }
265
264
  if(kind==='treat'||kind==='tug'){
266
265
  pending=kind;
267
- // The pet wakes through its authored recovery, not after a long sleep timer.
268
- if(['sleep','belly'].includes(actor.sample(clock).state))actor.sit();
266
+ queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
267
+ // Show the hand now, not several seconds later after the old route ends.
268
+ if(kind==='treat')treat=true;
269
+ if(typeof actor.attend==='function')actor.attend();
270
+ // Backward-compatible substitute actors may only support sleep recovery.
271
+ else if(['sleep','belly'].includes(actor.sample(clock).state))actor.sit();
269
272
  return true;
270
273
  }
271
274
  // Ball: launch now and drop obsolete controller plans; the actor's own state is left to chase.
Binary file
package/puppy-paper.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {createCachedTween,retimeRun,cleanGroundOffset} from './puppy-clean.mjs';
2
2
  import {createPaperFootLock,footSourcePoint,createRearSampler,createPaperSupportEpisodes} from './puppy-paper-contact.mjs';
3
3
  import {createPaperSeatSampler} from './puppy-paper-seat.mjs';
4
- import {planPaperRun,planPaperWalk,planPaperCarry,planPaperStumble,paperWalkTarget,planPaperApproach,planPaperApproachTravel,PAPER_GAITS,PAPER_GROUND,paperGroundTarget} from './puppy-paper-world.mjs';
4
+ import {planPaperRun,planPaperWalk,planPaperCarry,planPaperStumble,planPaperTravel,paperWalkTarget,planPaperApproach,planPaperApproachTravel,PAPER_DYNAMICS,PAPER_GAITS,PAPER_GROUND,paperGroundTarget} from './puppy-paper-world.mjs';
5
5
  import {createPaperDirector} from './puppy-paper-director.mjs';
6
6
  import {createPaperBehaviorSampler,paperBehaviorPose} from './puppy-paper-behaviors.mjs';
7
7
  import {createPaperAreaSampler} from './puppy-paper-area.mjs';
@@ -97,6 +97,9 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
97
97
  const playAction=(name,done,duration=PAPER_ACTION_DURATIONS[name])=>{const timing=trickTimings?.[name];duration=timing?.duration??duration;if(!Number.isFinite(duration)||duration<=0)throw Error('Invalid paper action duration');activeAction={name,duration,timing};actionDone=done;stop();enter('action');};
98
98
  const finishTrick=name=>{if(name!=='spin'){view='approach';flip=false;seatProgress=1;seatVariant='default';enter('idle');}else{view='front';flip=false;seatProgress=0;enter('settle');}drain();};
99
99
  const issue=(skipEntry=false)=>{
100
+ // The walk-enter drawing finishes at a shared standing endpoint. A direct
101
+ // interaction queued during it must not start the obsolete walk afterwards.
102
+ if(skipEntry&&deferred){drain();return;}
100
103
  if(movementMode==='walk'&&!skipEntry){playAction('walk-enter',()=>issue(true));return;}
101
104
  arrived=false;completed=false;travelAge=0;travelDistance=0;const x=animal.position.x,z=animal.position.z;if(straight&&view==='approach')target[0]=x;
102
105
  const dx=target[0]-x,dz=target[1]-z,distance=Math.hypot(dx,dz),run=movementMode==='walk'?planPaperWalk(distance):planPaperRun(distance);phase=Math.ceil(phase);
@@ -182,6 +185,22 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
182
185
  }
183
186
  }
184
187
  return{
188
+ // Attention interrupts a journey, not its momentum. Continue the current
189
+ // heading/gait while braking from the actual velocity, then recover to sit.
190
+ // Rolls/tugs still finish their connected authored recovery via dispatch.
191
+ attend(){
192
+ if(state!=='run'||!travel||!target||motionSpeed<1e-8)return dispatch(()=>requestAction('sit',{urgent:true}),{urgent:true});
193
+ const n=Math.hypot(travel.dx,travel.dz),remaining=Math.max(0,travel.plan.distance-(travelDistance-(travel.distanceBase??0)));
194
+ const a=movementMode==='walk'?PAPER_DYNAMICS.walkDeceleration:PAPER_DYNAMICS.deceleration;
195
+ const distance=Math.min(remaining,motionSpeed**2/(2*a));
196
+ if(!n||distance<1e-10)return dispatch(()=>requestAction('sit',{urgent:true}),{urgent:true});
197
+ const x=animal.position.x,z=animal.position.z,dx=travel.dx/n*distance,dz=travel.dz/n*distance;
198
+ const plan=planPaperTravel(distance,{speed:motionSpeed,startSpeed:motionSpeed,deceleration:motionSpeed**2/(2*distance)});
199
+ travel={...travel,x,z,dx,dz,plan,distanceBase:travelDistance,attending:true};
200
+ travelAge=0;target=[x+dx,z+dz];waypoints=[];route=false;deferred=null;
201
+ pendingAction='sit';preparationRate=3;scrambleArmed=false;completed=false;
202
+ return true;
203
+ },
185
204
  go(x,z){if(![x,z].every(Number.isFinite))throw Error('Invalid paper target');return dispatch(()=>{route=false;go(x,z);});},
186
205
  // Eager attention changes preparation/recovery cadence only. Existing dispatch
187
206
  // keeps actions/walks at safe endpoints; root physics and gait are unchanged.
@@ -210,7 +229,7 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
210
229
  let enteredScramble=false;
211
230
  if(state==='run'&&travel&&target){
212
231
  travelAge+=dt;const p=travel.plan.at(travelAge),ratio=travel.plan.distance?p.distance/travel.plan.distance:1;
213
- travelDistance=p.distance;motionSpeed=p.speed;motionStage=p.stage;
232
+ travelDistance=(travel.distanceBase??0)+p.distance;motionSpeed=p.speed;motionStage=p.stage;
214
233
  const gain=travel.plan.distance?p.speed/travel.plan.distance:0;
215
234
  animal.follow({x:travel.x+travel.dx*ratio,y:0,z:travel.z+travel.dz*ratio},{x:travel.dx*gain,y:0,z:travel.dz*gain},{heading:travel.heading});
216
235
  arrived=p.done;
@@ -240,7 +259,7 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
240
259
  director.update(dt,state==='run',distance);
241
260
  if(state==='run'){
242
261
  phase+=distance/(travel.carry&&travelDistance<travel.carry.runDistance?PAPER_GAITS.approach.stride:travel.stride);
243
- if(target&&arrived){completed=true;stop();target=null;if(movementMode==='walk')playAction('walk-exit',()=>{movementMode='run';enter(walkTransitions?'idle':'settle');drain();});else enter('finish');}
262
+ if(target&&arrived){completed=true;stop();target=null;if(movementMode==='walk')playAction('walk-exit',()=>{movementMode='run';enter(walkTransitions?'idle':'settle');if(pendingAction)prepareDirection();else drain();});else enter('finish');}
244
263
  }else if(state==='action'&&age>=activeAction.duration){
245
264
  const done=actionDone;activeAction=null;actionDone=null;done();
246
265
  }else if(state==='launch'&&age>=launchSeconds()){
@@ -281,7 +300,7 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
281
300
  // This clip is a calibrated standing half-turn, not walk-specific motion.
282
301
  // The old turn's intro/outro added an unrelated squat to every reversal.
283
302
  if(walkTransitions&&state==='turn'){actionName='walk-turn';actionProgress=Math.min(1,age/turnSeconds());}
284
- if(view==='approach'&&approachPlan&&(state==='run'||(state==='finish'&&completed))){const p=approachPlan.at(travelDistance);if(state==='finish'){behavior='brake';behaviorProgress=1;}else if(p.clip!=='run'){behavior=p.clip;behaviorProgress=p.progress;}}
303
+ if(view==='approach'&&approachPlan&&(state==='run'||(state==='finish'&&completed))){const p=approachPlan.at(travelDistance);if(state==='finish'&&!travel?.attending){behavior='brake';behaviorProgress=1;}else if(p.clip!=='run'){behavior=p.clip;behaviorProgress=p.progress;}}
285
304
  if(view==='approach'&&travel?.carry&&(state==='run'||state==='finish')){
286
305
  const p=state==='finish'?{clip:'carry-settle',progress:1}:travel.carry.poseAt(travelDistance);
287
306
  if(p.clip!=='run'){comfortPose=p;behavior=null;actionName=null;}
@@ -89,6 +89,8 @@ export interface Pet {
89
89
  /** Seconds. Fixed 60 Hz simulation, at most .1 s catch-up per call. */
90
90
  step(dt: number): number;
91
91
  frame(options: FrameOptions): PetFrame;
92
+ /** Conservative pre-render check for held poses. Simulation still needs step(). */
93
+ needsFrame(options: FrameOptions): boolean;
92
94
  /** False when busy or cooling down. */
93
95
  interact(kind: 'ball' | 'treat' | 'tug'): boolean;
94
96
  /** Sets the next throw's return style. Does not interrupt a fetch in progress. */
package/retrieviewer.mjs CHANGED
@@ -59,6 +59,20 @@ async function gunzip(bytes){
59
59
  // Only immutable art/metadata is shared. Each pet still owns its animal, actor,
60
60
  // life, renderer and output. Last dispose drops the shared lease, not other pets.
61
61
  const sharedAssets=new Map();
62
+ // Conservative pre-render invalidation: only known held source poses qualify.
63
+ // Moving poses, gestures, sleep cues and visitors always take the normal path.
64
+ // One small key, no second pixel buffer or animation cache. Needs/cooldowns are
65
+ // intentionally absent: they still advance, but do not draw anything themselves.
66
+ function heldFrameKey(state,report,{columns,rows,cellAspect=.5,camera,poseFps=24,contactEnabled=true}={}){
67
+ const seated=state.state==='idle'&&state.seatProgress===1&&!state.behavior&&!state.actionName&&!state.comfortPose;
68
+ const down=state.state==='down'&&(state.actionName==='lie-down'&&state.actionProgress===1||state.behavior==='rest');
69
+ if((!seated&&!down)||report.gesture||report.butterfly||report.snore!==null&&report.snore!==undefined)return null;
70
+ return JSON.stringify([columns,rows,cellAspect,poseFps,!!contactEnabled,camera?.viewportWidth,camera?.viewportHeight,
71
+ camera?.rect?.left,camera?.rect?.top,camera?.rect?.width,camera?.rect?.height,
72
+ state.state,state.x,state.z,state.bodyLift??0,state.view,state.flip,state.seatProgress,state.seatVariant,
73
+ state.actionName,down?state.actionProgress:0,state.behavior,state.behaviorProgress,
74
+ report.ball,report.treat]);
75
+ }
62
76
  async function loadPetAssets(loadAsset,artProfile){
63
77
  // Node keeps filtered crops compressed; only the existing two-frame LRU is
64
78
  // expanded. Browser bundles retain the portable bit-packed implementation.
@@ -114,7 +128,7 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
114
128
  actor=createPaperActor(animal,info,{manualTricks:true,trickTimings:createSnappyTrickTimings(assets.actions.info),comfortTimings:createPaperComfortTimings(assets.actions.info),walkTransitions:true,scrambleSeconds:assets.comfort.info.scramble?.duration??0,fetchMotions:assets.comfort.info.version>=4?assets.comfort.info:null});
115
129
  life=createCompanionLife(actor,{seed,returnStyle,...energy===undefined?{}:{energy}});
116
130
  }catch(e){animal?.dispose?.();release();throw e;}
117
- let paint=null,paintKey='',time=0,acc=0,disposed=false;
131
+ let paint=null,paintKey='',time=0,acc=0,disposed=false,lastHeldFrameKey=null;
118
132
  const alive=()=>{if(disposed)throw Error('Pet is disposed');};
119
133
  return {
120
134
  // Fixed 1/60 s simulation; host deltas are clamped to 0.1 s so a stalled host
@@ -125,6 +139,9 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
125
139
  while(acc>=STEP_SECONDS-1e-9){actor.update(STEP_SECONDS);life.update(STEP_SECONDS);time+=STEP_SECONDS;acc=Math.max(0,acc-STEP_SECONDS);n++;}
126
140
  return n;
127
141
  },
142
+ // Optional host optimization. False only when a known held pose and all
143
+ // visible objects/settings match the last frame; frame() itself never skips.
144
+ needsFrame(options){alive();const key=heldFrameKey(actor.sample(time,{poseFps:options?.poseFps??24}),life.report,options);return key===null||key!==lastHeldFrameKey;},
128
145
  // Plain Braille text sized exactly columns x rows (2x4 dots per cell). `pixels`
129
146
  // is the renderer's buffer, valid only until the next frame()/dispose().
130
147
  frame({columns,rows,cellAspect=.5,camera,poseFps=24,contactEnabled=true}={}){
@@ -141,6 +158,7 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
141
158
  const placement=projection.placement(state.x,state.z,state.bodyLift??0),pixels=paint(state,{...placement,contactEnabled:!!contactEnabled});
142
159
  paintCompanionObjects(pixels,width,height,projection,report,state,paint.lastMouth);
143
160
  const {text}=rgbaToBraille(pixels,width,height,{method:'stable',brightness:1.18,contrast:1.1});
161
+ lastHeldFrameKey=heldFrameKey(state,report,{columns,rows,cellAspect,camera,poseFps,contactEnabled});
144
162
  return {text,columns,rows,pixels,state,report,placement,projection,artProfile,mouth:paint.lastMouth,contacts:paint.report()};
145
163
  },
146
164
  // 'ball' | 'treat' | 'tug'. False when busy or cooling down; unknown kinds throw.
@@ -151,7 +169,7 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
151
169
  dispose(){
152
170
  if(disposed)return;disposed=true;
153
171
  try{animal.dispose?.();}finally{
154
- animal=actor=life=paint=cache=info=meta=null;paintKey='';
172
+ animal=actor=life=paint=cache=info=meta=null;paintKey='';lastHeldFrameKey=null;
155
173
  for(const k of BUNDLES)assets[k]=null;
156
174
  release();
157
175
  }