Compare commits

...
4 Commits
Author SHA1 Message Date
quoije d9c44d2f26 rough, this is a waste of tokens... 2026-09-17 22:35:37 -04:00
quoije 8fa8dc4ba9 Fix Scenes freeze and Languages up/down navigation
- VsmxInterpreter.interpretFunction: save/restore pc/exit/stack/callStates so
  re-entrant event firing (FireEventAnim, timers, movie callbacks) no longer
  truncates the outer script. This is what left ss_btn_mm without focus
  (Scenes appeared frozen: no selection, input ignored while audio continued).
- Add default geometric focus traversal for buttons with no direction handler:
  Controller.moveFocus picks the nearest visible focusable object. The PSP does
  this natively; English/Francais buttons have no onUp/onDown events in the RCO
  and relied on it.
- Track page visibility (BaseObject.displayed, set by PageObject.open/close) so
  traversal skips closed pages and collapsed (alpha 0) columns.
- Make BaseObject.trigger null-safe.
2026-09-17 18:48:24 -04:00
quoije 298de29e5f Fix menu reveal: MoveToAnim is absolute; register unlabeled anim children
- MoveToAnim x/y/z are absolute target positions, not offsets: objects are
  authored in their hidden state and tweened to the visible position
  (e.g. the LANGUAGES title -500 -> 20). JPCSP's offset treatment is wrong.
- Register unlabeled child entries (animation records) under the 'children'
  property with a synthetic zero-padded name, so BaseAnim.play can reach
  them. Previously anim containers had zero children and played nothing.
2026-09-17 00:48:00 -04:00
quoije 6912439ddf Wire RCO texture property to the renderer; fix group parent positions
- Expose the RCO image table as resource.texturetable[label]
- Route native-object property assignment through set<Name>(BaseNativeObject)
  reflection so 'plane.texture = image' reaches PlaneObject.setTexture
- Plane/UButton/Button/Icon draw their runtime texture in preference to the
  static image, and seed the texture property from the static image
- Store the RCO entry's VSMX object so group children inherit parent position
2026-09-17 00:37:10 -04:00
20 changed files with 157244 additions and 60 deletions
+156833
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,3 +15,4 @@ Thumbs.db
# Disc images # Disc images
*.iso *.iso
reference/
@@ -58,6 +58,7 @@ public final class RcoFile {
public final List<Entry> subEntries = new ArrayList<>(); public final List<Entry> subEntries = new ArrayList<>();
public byte[] data; public byte[] data;
public String[] texts; public String[] texts;
public ImageObject image;
public BaseObject obj; public BaseObject obj;
public VsmxBaseObject vsmxObject; public VsmxBaseObject vsmxObject;
@@ -269,7 +270,9 @@ public final class RcoFile {
if (imgData != null && comp != COMPRESSION_NONE) { if (imgData != null && comp != COMPRESSION_NONE) {
imgData = decompressImage(imgData, comp, sizeUnpacked); imgData = decompressImage(imgData, comp, sizeUnpacked);
} }
images.put(entryOffset, new ImageObject(imgData)); ImageObject imageObject = new ImageObject(imgData);
images.put(entryOffset, imageObject);
entry.image = imageObject;
} }
} }
case TABLE_MODEL -> { case TABLE_MODEL -> {
@@ -20,24 +20,26 @@ public class MoveToAnim extends BaseAnim {
@Override @Override
protected void doPlay(VsmxBaseObject object) { protected void doPlay(VsmxBaseObject object) {
applyTo(ref, target -> { applyTo(ref, target -> {
// x/y/z are absolute target positions: objects are authored in their
// hidden state (e.g. off-screen) and tweened to the visible position.
float tx = x.getFloatValue(); float tx = x.getFloatValue();
float ty = y.getFloatValue(); float ty = y.getFloatValue();
float tz = z.getFloatValue(); float tz = z.getFloatValue();
long millis = (long) duration.getFloatValue(); long millis = (long) duration.getFloatValue();
if (millis <= 0) { if (millis <= 0) {
target.animX = tx; target.posX.setFloatValue(tx);
target.animY = ty; target.posY.setFloatValue(ty);
target.animZ = tz; target.posZ.setFloatValue(tz);
target.onDisplayUpdated(); target.onDisplayUpdated();
return; return;
} }
float sx = target.animX; float sx = target.posX.getFloatValue();
float sy = target.animY; float sy = target.posY.getFloatValue();
float sz = target.animZ; float sz = target.posZ.getFloatValue();
Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> { Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> {
target.animX = sx + (tx - sx) * p; target.posX.setFloatValue(sx + (tx - sx) * p);
target.animY = sy + (ty - sy) * p; target.posY.setFloatValue(sy + (ty - sy) * p);
target.animZ = sz + (tz - sz) * p; target.posZ.setFloatValue(sz + (tz - sz) * p);
target.onDisplayUpdated(); target.onDisplayUpdated();
})); }));
}); });
@@ -30,6 +30,7 @@ public abstract class BaseObject extends BaseNativeObject implements IDisplay {
protected Controller controller; protected Controller controller;
private String name; private String name;
private BaseNativeObject parent; private BaseNativeObject parent;
private boolean displayed;
private Field[] getSortedFields() { private Field[] getSortedFields() {
Field[] fields = getClass().getFields(); Field[] fields = getClass().getFields();
@@ -97,6 +98,18 @@ public abstract class BaseObject extends BaseNativeObject implements IDisplay {
public VsmxBaseObject createVsmxObject(VsmxInterpreter interpreter, VsmxBaseObject parent, RcoFile.Entry entry) { public VsmxBaseObject createVsmxObject(VsmxInterpreter interpreter, VsmxBaseObject parent, RcoFile.Entry entry) {
VsmxNativeObject object = new VsmxNativeObject(interpreter, this); VsmxNativeObject object = new VsmxNativeObject(interpreter, this);
setObject(object); setObject(object);
entry.vsmxObject = object;
// Seed the "texture" property with this object's static image so the
// script can read or replace it. Assignment is routed through the
// native setTexture setter, when one exists.
Object staticImage = getImage();
if (staticImage instanceof ImageObject imageObject) {
VsmxNativeObject textureObject = new VsmxNativeObject(interpreter, imageObject);
imageObject.setObject(textureObject);
object.setPropertyValue("texture", textureObject);
}
if (entry.label != null) { if (entry.label != null) {
name = entry.label; name = entry.label;
object.setPropertyValue("name", new VsmxString(interpreter, entry.label)); object.setPropertyValue("name", new VsmxString(interpreter, entry.label));
@@ -128,8 +141,17 @@ public abstract class BaseObject extends BaseNativeObject implements IDisplay {
return name; return name;
} }
/** True while this object is part of an opened page's display list. */
public boolean isDisplayed() {
return displayed;
}
public void setDisplayed(boolean displayed) {
this.displayed = displayed;
}
protected void trigger(EventType event) { protected void trigger(EventType event) {
if (event.getEvent() != null && controller != null) { if (event != null && event.getEvent() != null && controller != null) {
controller.getInterpreter().interpretScript(getObject(), event.getEvent()); controller.getInterpreter().interpretScript(getObject(), event.getEvent());
} }
} }
@@ -64,26 +64,14 @@ public class BasePositionObject extends BaseObject {
@Override @Override
public int getWidth() { public int getWidth() {
float w = width.getFloatValue(); // A zero authored size means "use the image's natural size"; the
if (w == 0f) { // renderer falls back to the decoded image dimensions when this is 0.
Object image = getImage(); return Math.round(width.getFloatValue() * scaleWidth.getFloatValue());
if (image == null) {
w = 0f;
}
}
return Math.round(w * scaleWidth.getFloatValue());
} }
@Override @Override
public int getHeight() { public int getHeight() {
float h = height.getFloatValue(); return Math.round(height.getFloatValue() * scaleHeight.getFloatValue());
if (h == 0f) {
Object image = getImage();
if (image == null) {
h = 0f;
}
}
return Math.round(h * scaleHeight.getFloatValue());
} }
@Override @Override
@@ -6,6 +6,8 @@ import dev.quoije.umd.rco.type.EventType;
import dev.quoije.umd.rco.type.ImageType; import dev.quoije.umd.rco.type.ImageType;
import dev.quoije.umd.rco.type.IntType; import dev.quoije.umd.rco.type.IntType;
import dev.quoije.umd.rco.type.TextType; import dev.quoije.umd.rco.type.TextType;
import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject;
import dev.quoije.umd.rco.vsmx.objects.Controller;
/** A button object. */ /** A button object. */
public class ButtonObject extends BasePositionObject { public class ButtonObject extends BasePositionObject {
@@ -24,16 +26,38 @@ public class ButtonObject extends BasePositionObject {
@ObjectField(order = 212) public EventType onContextMenu; @ObjectField(order = 212) public EventType onContextMenu;
@ObjectField(order = 213) public IntType unknownInt40; @ObjectField(order = 213) public IntType unknownInt40;
/** Runtime texture assigned by the script; overrides the static image. */
private ImageObject texture;
@Override @Override
public Object getImage() { public Object getImage() {
if (texture != null) {
return texture;
}
return image.getImage(); return image.getImage();
} }
/** Called via the VSMX {@code texture} property setter. */
public void setTexture(BaseNativeObject texture) {
if (texture instanceof ImageObject imageObject) {
this.texture = imageObject;
}
onDisplayUpdated();
}
@Override @Override
public Object getFocusImage() { public Object getFocusImage() {
return focus.getImage(); return focus.getImage();
} }
@Override
public void setController(Controller controller) {
super.setController(controller);
if (controller != null) {
controller.registerFocusable(this);
}
}
@Override @Override
public void setFocus() { public void setFocus() {
trigger(onFocusIn); trigger(onFocusIn);
@@ -53,21 +77,37 @@ public class ButtonObject extends BasePositionObject {
@Override @Override
public void onUp() { public void onUp() {
trigger(onFocusUp); if (onFocusUp != null && onFocusUp.getEvent() != null) {
trigger(onFocusUp);
} else if (controller != null) {
controller.moveFocus(this, 0, 1);
}
} }
@Override @Override
public void onDown() { public void onDown() {
trigger(onFocusDown); if (onFocusDown != null && onFocusDown.getEvent() != null) {
trigger(onFocusDown);
} else if (controller != null) {
controller.moveFocus(this, 0, -1);
}
} }
@Override @Override
public void onLeft() { public void onLeft() {
trigger(onFocusLeft); if (onFocusLeft != null && onFocusLeft.getEvent() != null) {
trigger(onFocusLeft);
} else if (controller != null) {
controller.moveFocus(this, -1, 0);
}
} }
@Override @Override
public void onRight() { public void onRight() {
trigger(onFocusRight); if (onFocusRight != null && onFocusRight.getEvent() != null) {
trigger(onFocusRight);
} else if (controller != null) {
controller.moveFocus(this, 1, 0);
}
} }
} }
@@ -3,6 +3,7 @@ package dev.quoije.umd.rco.object;
import dev.quoije.umd.rco.ObjectField; import dev.quoije.umd.rco.ObjectField;
import dev.quoije.umd.rco.type.ImageType; import dev.quoije.umd.rco.type.ImageType;
import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject;
/** An icon object. */ /** An icon object. */
public class IconObject extends BasePositionObject { public class IconObject extends BasePositionObject {
@@ -11,8 +12,22 @@ public class IconObject extends BasePositionObject {
@ObjectField(order = 202) public ImageType unknownImage18; @ObjectField(order = 202) public ImageType unknownImage18;
@ObjectField(order = 203) public ImageType unknownImage20; @ObjectField(order = 203) public ImageType unknownImage20;
/** Runtime texture assigned by the script; overrides the static image. */
private ImageObject texture;
@Override @Override
public Object getImage() { public Object getImage() {
if (texture != null) {
return texture;
}
return unknownImage16.getImage(); return unknownImage16.getImage();
} }
/** Called via the VSMX {@code texture} property setter. */
public void setTexture(BaseNativeObject texture) {
if (texture instanceof ImageObject imageObject) {
this.texture = imageObject;
}
onDisplayUpdated();
}
} }
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
package dev.quoije.umd.rco.object; package dev.quoije.umd.rco.object;
import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject;
/** An image entry from the RCO IMG table (raw bytes until decoded by the UI). */ /** An image entry from the RCO IMG table (raw bytes until decoded by the UI). */
public final class ImageObject { public class ImageObject extends BaseNativeObject {
private final byte[] data; private final byte[] data;
@@ -35,6 +35,7 @@ public class PageObject extends BaseObject {
VsmxBaseObject children = object.getPropertyValue(Resource.childrenName); VsmxBaseObject children = object.getPropertyValue(Resource.childrenName);
for (String childName : children.getPropertyNames()) { for (String childName : children.getPropertyNames()) {
VsmxBaseObject child = children.getPropertyValue(childName); VsmxBaseObject child = children.getPropertyValue(childName);
setDisplayed(child, true);
display.add(child); display.add(child);
display(child); display(child);
} }
@@ -47,11 +48,18 @@ public class PageObject extends BaseObject {
VsmxBaseObject children = object.getPropertyValue(Resource.childrenName); VsmxBaseObject children = object.getPropertyValue(Resource.childrenName);
for (String childName : children.getPropertyNames()) { for (String childName : children.getPropertyNames()) {
VsmxBaseObject child = children.getPropertyValue(childName); VsmxBaseObject child = children.getPropertyValue(childName);
setDisplayed(child, false);
display.remove(child); display.remove(child);
hide(child); hide(child);
} }
} }
private static void setDisplayed(VsmxBaseObject object, boolean displayed) {
if (object instanceof VsmxNativeObject nativeObject && nativeObject.getObject() instanceof BaseObject baseObject) {
baseObject.setDisplayed(displayed);
}
}
private BasePositionObject getFirstButton(VsmxBaseObject object) { private BasePositionObject getFirstButton(VsmxBaseObject object) {
if (!object.hasPropertyValue(Resource.childrenName)) { if (!object.hasPropertyValue(Resource.childrenName)) {
return null; return null;
@@ -4,6 +4,7 @@ package dev.quoije.umd.rco.object;
import dev.quoije.umd.rco.ObjectField; import dev.quoije.umd.rco.ObjectField;
import dev.quoije.umd.rco.type.ImageType; import dev.quoije.umd.rco.type.ImageType;
import dev.quoije.umd.rco.type.IntType; import dev.quoije.umd.rco.type.IntType;
import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject;
/** A plane (textured rectangle). */ /** A plane (textured rectangle). */
public class PlaneObject extends BasePositionObject { public class PlaneObject extends BasePositionObject {
@@ -11,8 +12,22 @@ public class PlaneObject extends BasePositionObject {
@ObjectField(order = 201) public ImageType image; @ObjectField(order = 201) public ImageType image;
@ObjectField(order = 202) public IntType unknownInt18; @ObjectField(order = 202) public IntType unknownInt18;
/** Runtime texture assigned by the script; overrides the static image. */
private ImageObject texture;
@Override @Override
public Object getImage() { public Object getImage() {
if (texture != null) {
return texture;
}
return image.getImage(); return image.getImage();
} }
/** Called via the VSMX {@code texture} property setter. */
public void setTexture(BaseNativeObject texture) {
if (texture instanceof ImageObject imageObject) {
this.texture = imageObject;
}
onDisplayUpdated();
}
} }
@@ -5,6 +5,8 @@ import dev.quoije.umd.rco.ObjectField;
import dev.quoije.umd.rco.type.EventType; import dev.quoije.umd.rco.type.EventType;
import dev.quoije.umd.rco.type.ImageType; import dev.quoije.umd.rco.type.ImageType;
import dev.quoije.umd.rco.type.IntType; import dev.quoije.umd.rco.type.IntType;
import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject;
import dev.quoije.umd.rco.vsmx.objects.Controller;
/** A user button object (navigation with onLeft/onRight/onUp/onDown events). */ /** A user button object (navigation with onLeft/onRight/onUp/onDown events). */
public class UButtonObject extends BasePositionObject { public class UButtonObject extends BasePositionObject {
@@ -19,11 +21,33 @@ public class UButtonObject extends BasePositionObject {
@ObjectField(order = 208) public EventType onDown; @ObjectField(order = 208) public EventType onDown;
@ObjectField(order = 209) public IntType unknownInt32; @ObjectField(order = 209) public IntType unknownInt32;
/** Runtime texture assigned by the script; overrides the static image. */
private ImageObject texture;
@Override @Override
public Object getImage() { public Object getImage() {
if (texture != null) {
return texture;
}
return image.getImage(); return image.getImage();
} }
/** Called via the VSMX {@code texture} property setter. */
public void setTexture(BaseNativeObject texture) {
if (texture instanceof ImageObject imageObject) {
this.texture = imageObject;
}
onDisplayUpdated();
}
@Override
public void setController(Controller controller) {
super.setController(controller);
if (controller != null) {
controller.registerFocusable(this);
}
}
@Override @Override
public void setFocus() { public void setFocus() {
trigger(onFocusIn); trigger(onFocusIn);
@@ -43,21 +67,37 @@ public class UButtonObject extends BasePositionObject {
@Override @Override
public void onUp() { public void onUp() {
trigger(onUp); if (onUp != null && onUp.getEvent() != null) {
trigger(onUp);
} else if (controller != null) {
controller.moveFocus(this, 0, 1);
}
} }
@Override @Override
public void onDown() { public void onDown() {
trigger(onDown); if (onDown != null && onDown.getEvent() != null) {
trigger(onDown);
} else if (controller != null) {
controller.moveFocus(this, 0, -1);
}
} }
@Override @Override
public void onLeft() { public void onLeft() {
trigger(onLeft); if (onLeft != null && onLeft.getEvent() != null) {
trigger(onLeft);
} else if (controller != null) {
controller.moveFocus(this, -1, 0);
}
} }
@Override @Override
public void onRight() { public void onRight() {
trigger(onRight); if (onRight != null && onRight.getEvent() != null) {
trigger(onRight);
} else if (controller != null) {
controller.moveFocus(this, 1, 0);
}
} }
} }
@@ -458,9 +458,23 @@ public final class VsmxInterpreter {
/** Runs the script's main body, populating the given global variables object. */ /** Runs the script's main body, populating the given global variables object. */
public synchronized void run(VsmxObject globalVariables) { public synchronized void run(VsmxObject globalVariables) {
// This can be invoked re-entrantly (Controller.changeResource loads and
// runs a new resource while the previous resource's script is still on
// the stack), so the outer execution state must be preserved and
// restored exactly like interpretFunction does. Without this, the
// nested run clobbers pc/exit/stack/callStates and the outer script
// crashes with a null operand stack after the nested run returns.
int savedPc = pc;
boolean savedExit = exit;
Deque<VsmxBaseObject> savedStack = stack;
Deque<VsmxCallState> savedCallStates = callStates;
VsmxCallState savedCallState = callState;
pc = 0; pc = 0;
exit = false; exit = false;
callStates = new ArrayDeque<>(); callStates = new ArrayDeque<>();
stack = null;
callState = null;
pushCallState(VsmxNull.singleton, 0, false, true); pushCallState(VsmxNull.singleton, 0, false, true);
this.globalVariables = globalVariables; this.globalVariables = globalVariables;
@@ -470,8 +484,16 @@ public final class VsmxInterpreter {
interpret(); interpret();
callStates.clear(); pc = savedPc;
callState = null; exit = savedExit;
stack = savedStack;
callStates = savedCallStates;
callState = savedCallState;
}
/** Requests the currently-running script to stop after the current instruction. */
public void stop() {
exit = true;
} }
private void callFunction(VsmxFunction function, VsmxBaseObject thisObject, VsmxBaseObject[] arguments, private void callFunction(VsmxFunction function, VsmxBaseObject thisObject, VsmxBaseObject[] arguments,
@@ -498,6 +520,19 @@ public final class VsmxInterpreter {
/** Interprets a function body in a fresh call frame. */ /** Interprets a function body in a fresh call frame. */
public synchronized void interpretFunction(VsmxFunction function, VsmxBaseObject object, VsmxBaseObject[] arguments) { public synchronized void interpretFunction(VsmxFunction function, VsmxBaseObject object, VsmxBaseObject[] arguments) {
// This entry point can be invoked re-entrantly (FireEventAnim, timers,
// movie callbacks all run from inside an already-running script), so the
// outer execution state must be preserved and restored. Without this,
// the inner RETURN would set {@code exit} and truncate the outer script.
int savedPc = pc;
boolean savedExit = exit;
Deque<VsmxBaseObject> savedStack = stack;
Deque<VsmxCallState> savedCallStates = callStates;
VsmxCallState savedCallState = callState;
callStates = new ArrayDeque<>();
stack = null;
callState = null;
pushCallState(object, function.getLocalVars(), false, true); pushCallState(object, function.getLocalVars(), false, true);
for (int i = 1; i <= function.getArgs(); i++) { for (int i = 1; i <= function.getArgs(); i++) {
if (arguments == null || i > arguments.length) { if (arguments == null || i > arguments.length) {
@@ -508,6 +543,12 @@ public final class VsmxInterpreter {
} }
pc = function.getStartLine(); pc = function.getStartLine();
interpret(); interpret();
pc = savedPc;
exit = savedExit;
stack = savedStack;
callStates = savedCallStates;
callState = savedCallState;
} }
/** Interprets a script event handler by name (e.g. "script:/main/onPush"). */ /** Interprets a script event handler by name (e.g. "script:/main/onPush"). */
@@ -3,6 +3,8 @@ package dev.quoije.umd.rco.vsmx.interpreter;
import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject; import dev.quoije.umd.rco.vsmx.objects.BaseNativeObject;
import java.lang.reflect.Method;
/** A VSMX object wrapping a native Java object. */ /** A VSMX object wrapping a native Java object. */
public class VsmxNativeObject extends VsmxObject { public class VsmxNativeObject extends VsmxObject {
@@ -17,6 +19,27 @@ public class VsmxNativeObject extends VsmxObject {
return object; return object;
} }
/**
* Assigning a native object to a property is routed through a matching
* {@code set<Name>(BaseNativeObject)} method when the wrapped object
* declares one. This is how {@code plane.texture = image} reaches
* {@code PlaneObject.setTexture}.
*/
@Override
public void setPropertyValue(String name, VsmxBaseObject value) {
if (object != null && name != null && !name.isEmpty() && value instanceof VsmxNativeObject nativeValue) {
String setterName = "set" + Character.toUpperCase(name.charAt(0)) + name.substring(1);
try {
Method setter = object.getClass().getMethod(setterName, BaseNativeObject.class);
setter.invoke(object, nativeValue.getObject());
return;
} catch (ReflectiveOperationException e) {
// No matching setter; fall back to storing the property.
}
}
super.setPropertyValue(name, value);
}
@Override @Override
public String toString() { public String toString() {
return object == null ? super.toString() : object.toString() + super.toString(); return object == null ? super.toString() : object.toString() + super.toString();
@@ -10,6 +10,9 @@ import dev.quoije.umd.rco.vsmx.interpreter.VsmxNativeObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxNull; import dev.quoije.umd.rco.vsmx.interpreter.VsmxNull;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxString; import dev.quoije.umd.rco.vsmx.interpreter.VsmxString;
import java.util.ArrayList;
import java.util.List;
/** The controller native object: input focus and resource management. */ /** The controller native object: input focus and resource management. */
public class Controller extends BaseNativeObject { public class Controller extends BaseNativeObject {
@@ -20,6 +23,7 @@ public class Controller extends BaseNativeObject {
private VsmxBaseObject userData; private VsmxBaseObject userData;
private String resource; private String resource;
private BasePositionObject focus; private BasePositionObject focus;
private final List<BasePositionObject> focusables = new ArrayList<>();
public static VsmxNativeObject create(VsmxInterpreter interpreter, MenuHost menuHost, String resource) { public static VsmxNativeObject create(VsmxInterpreter interpreter, MenuHost menuHost, String resource) {
Controller controller = new Controller(interpreter, menuHost); Controller controller = new Controller(interpreter, menuHost);
@@ -64,6 +68,10 @@ public class Controller extends BaseNativeObject {
if (!resource.equals(resourceName)) { if (!resource.equals(resourceName)) {
resource = resourceName; resource = resourceName;
menuHost.changeResource(resourceName); menuHost.changeResource(resourceName);
// The resource switch abandons this interpreter; stop the script
// that requested it so it does not keep running against a stale
// menu (the PSP drops the old resource's script at this point).
interpreter.stop();
} }
return VsmxBoolean.getValue(true); return VsmxBoolean.getValue(true);
} }
@@ -79,6 +87,63 @@ public class Controller extends BaseNativeObject {
return focus; return focus;
} }
/** Registers a focusable object so default (geometric) focus traversal can reach it. */
public void registerFocusable(BasePositionObject object) {
synchronized (focusables) {
if (!focusables.contains(object)) {
focusables.add(object);
}
}
}
/**
* Moves focus to the nearest visible, focusable object in the given direction
* when a button has no script handler for that direction. {@code dx}/{@code dy}
* use the framebuffer coordinate system (+X right, +Y up).
*/
public void moveFocus(BasePositionObject from, int dx, int dy) {
int fromX = from.getX() + from.getWidth() / 2;
int fromY = from.getY() + from.getHeight() / 2;
BasePositionObject best = null;
double bestScore = Double.MAX_VALUE;
synchronized (focusables) {
for (BasePositionObject candidate : focusables) {
if (candidate == from || !candidate.isDisplayed() || candidate.getAlpha() <= 0f
|| candidate.getImage() == null) {
continue;
}
int candidateX = candidate.getX() + candidate.getWidth() / 2;
int candidateY = candidate.getY() + candidate.getHeight() / 2;
int offsetX = candidateX - fromX;
int offsetY = candidateY - fromY;
if (dy < 0 && offsetY >= 0) {
continue;
}
if (dy > 0 && offsetY <= 0) {
continue;
}
if (dx < 0 && offsetX >= 0) {
continue;
}
if (dx > 0 && offsetX <= 0) {
continue;
}
double primary = dx != 0 ? java.lang.Math.abs(offsetX) : java.lang.Math.abs(offsetY);
double secondary = dx != 0 ? java.lang.Math.abs(offsetY) : java.lang.Math.abs(offsetX);
double score = primary * 10 + secondary;
if (score < bestScore) {
bestScore = score;
best = candidate;
}
}
}
if (best != null) {
best.setFocus();
}
}
public void onUp() { public void onUp() {
if (focus != null) { if (focus != null) {
focus.onUp(); focus.onUp();
@@ -50,7 +50,7 @@ public class GlobalVariables extends BaseNativeObject {
} }
public void writeln(VsmxBaseObject object, VsmxBaseObject s1, VsmxBaseObject s2, VsmxBaseObject s3) { public void writeln(VsmxBaseObject object, VsmxBaseObject s1, VsmxBaseObject s2, VsmxBaseObject s3) {
log.append(s1.getStringValue()).append(s3.getStringValue()).append('\n'); log.append(s1.getStringValue()).append(s2.getStringValue()).append(s3.getStringValue()).append('\n');
} }
public VsmxBaseObject parseFloat(VsmxBaseObject object, VsmxBaseObject value) { public VsmxBaseObject parseFloat(VsmxBaseObject object, VsmxBaseObject value) {
@@ -7,6 +7,7 @@ import dev.quoije.umd.rco.vsmx.interpreter.VsmxBaseObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxInterpreter; import dev.quoije.umd.rco.vsmx.interpreter.VsmxInterpreter;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxNativeObject; import dev.quoije.umd.rco.vsmx.interpreter.VsmxNativeObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxObject; import dev.quoije.umd.rco.vsmx.interpreter.VsmxObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxString;
/** /**
* The resource native object, exposing the RCO object tables to the script. * The resource native object, exposing the RCO object tables to the script.
@@ -50,6 +51,15 @@ public class Resource extends BaseNativeObject {
private static void createObjectFromEntry(VsmxInterpreter interpreter, Display display, Controller controller, private static void createObjectFromEntry(VsmxInterpreter interpreter, Display display, Controller controller,
VsmxBaseObject parent, RcoFile.Entry entry) { VsmxBaseObject parent, RcoFile.Entry entry) {
if (entry.image != null) {
VsmxNativeObject object = new VsmxNativeObject(interpreter, entry.image);
entry.image.setObject(object);
if (entry.label != null) {
object.setPropertyValue("name", new VsmxString(interpreter, entry.label));
parent.setPropertyValue(entry.label, object);
}
return;
}
if (entry.obj == null) { if (entry.obj == null) {
return; return;
} }
@@ -60,7 +70,14 @@ public class Resource extends BaseNativeObject {
if (!entry.subEntries.isEmpty()) { if (!entry.subEntries.isEmpty()) {
VsmxObject children = new VsmxObject(interpreter, null); VsmxObject children = new VsmxObject(interpreter, null);
object.setPropertyValue(childrenName, children); object.setPropertyValue(childrenName, children);
for (RcoFile.Entry child : entry.subEntries) { for (int i = 0; i < entry.subEntries.size(); i++) {
RcoFile.Entry child = entry.subEntries.get(i);
// Unlabeled children (e.g. animation records) still need to be
// registered under "children" so scripts/anim playback can reach
// them. Assign a synthetic zero-padded name, as JPCSP does.
if (child.label == null) {
child.label = String.format("%04d", i);
}
createObjectFromEntry(interpreter, display, controller, children, child); createObjectFromEntry(interpreter, display, controller, children, child);
} }
} }
@@ -76,7 +76,10 @@ public class Timer extends BaseNativeObject {
if (due != null) { if (due != null) {
if (due.function instanceof VsmxFunction f) { if (due.function instanceof VsmxFunction f) {
synchronized (callbackLock) { synchronized (callbackLock) {
interpreter.interpretFunction(f, due.object, due.params); // Fire on the interpreter that compiled the callback so
// timers survive a resource switch (each resource owns
// its own interpreter).
f.getInterpreter().interpretFunction(f, due.object, due.params);
} }
} }
continue; continue;
@@ -72,6 +72,17 @@ public final class PlayerApp extends Application {
private volatile VsmxNativeObject controllerObject; private volatile VsmxNativeObject controllerObject;
private long lastMenuKeyNanos = 0; private long lastMenuKeyNanos = 0;
/** The open disc image, kept for the lifetime of the app so menu resources
* (RCO files) can be switched at runtime. */
private volatile UmdIsoReader iso;
private volatile String isoPath;
private volatile Playlist playlist;
/** The script global scope, shared across resource switches so globals
* (e.g. {@code g_nResChangeInfo}) survive. */
private volatile VsmxNativeObject globalVariables;
/** False until the first resource has run; later loads fire onChangeResource. */
private boolean resourceInitialized;
/** The active decode engine, closed when a new stream starts. */ /** The active decode engine, closed when a new stream starts. */
private volatile PlaybackEngine currentEngine; private volatile PlaybackEngine currentEngine;
/** Bumped each time playback starts so stale streams stop themselves. */ /** Bumped each time playback starts so stale streams stop themselves. */
@@ -173,25 +184,55 @@ public final class PlayerApp extends Application {
} }
private void loadMenu(String isoPath) { private void loadMenu(String isoPath) {
try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath))) { try {
Playlist playlist = Playlist.parse(iso); this.iso = new UmdIsoReader(Path.of(isoPath));
byte[] rcoData = readFile(iso, "UMD_VIDEO/RESOURCE/EN100000.RCO"); this.isoPath = isoPath;
this.playlist = Playlist.parse(iso);
this.resourceInitialized = false;
loadResource("EN100000");
} catch (Exception e) {
e.printStackTrace();
}
}
/** Loads (or switches to) a menu resource and starts its script. */
private void loadResource(String resourceName) {
UmdIsoReader disc = iso;
if (disc == null) {
return;
}
try {
byte[] rcoData = readFile(disc, "UMD_VIDEO/RESOURCE/" + resourceName + ".RCO");
if (rcoData == null) { if (rcoData == null) {
System.err.println("EN100000.RCO not found"); System.err.println("Menu resource not found: " + resourceName + ".RCO");
return; return;
} }
RcoFile rco = new RcoFile(rcoData); RcoFile rco = new RcoFile(rcoData);
if (!rco.isValid()) { if (!rco.isValid()) {
System.err.println("RCO invalid"); System.err.println("RCO invalid: " + resourceName);
return; return;
} }
Vsmx vsmx = new Vsmx(rco.vsmxData(), "main"); // Each resource gets a fresh interpreter so that switching
// resources never swaps the bytecode out from under an already
// running script (the old script keeps its own code and state).
VsmxInterpreter interpreter = new VsmxInterpreter(); VsmxInterpreter interpreter = new VsmxInterpreter();
Vsmx vsmx = new Vsmx(rco.vsmxData(), "main");
interpreter.setVsmx(vsmx); interpreter.setVsmx(vsmx);
this.menuInterpreter = interpreter;
if (this.globalVariables == null) {
this.globalVariables = GlobalVariables.create(interpreter, SCREEN_WIDTH, SCREEN_HEIGHT);
}
renderer = new RcoRenderer(menuCanvas); if (renderer == null) {
renderer = new RcoRenderer(menuCanvas);
} else {
// The previous resource's object tree belongs to a different
// script; drop it before the new page tree is registered.
renderer.changeResource();
}
MenuHost menuHost = new MenuHost() { MenuHost menuHost = new MenuHost() {
@Override @Override
@@ -199,7 +240,7 @@ public final class PlayerApp extends Application {
int audioFlag, int subtitleNumber, int subtitleFlag) { int audioFlag, int subtitleNumber, int subtitleFlag) {
Playlist.Stream stream = playlist.streamForPlayListNumber(playListNumber); Playlist.Stream stream = playlist.streamForPlayListNumber(playListNumber);
if (stream != null) { if (stream != null) {
startPlayback(isoPath, stream, playListNumber); startPlayback(PlayerApp.this.isoPath, stream, playListNumber);
} }
} }
@@ -214,26 +255,32 @@ public final class PlayerApp extends Application {
} }
@Override @Override
public void changeResource(String resourceName) { public void changeResource(String newResource) {
loadResource(newResource);
} }
}; };
VsmxNativeObject globals = GlobalVariables.create(interpreter, SCREEN_WIDTH, SCREEN_HEIGHT); VsmxNativeObject controllerObject = Controller.create(interpreter, menuHost, resourceName);
VsmxNativeObject controllerObject = Controller.create(interpreter, menuHost, "EN100000");
VsmxNativeObject movieplayer = MoviePlayer.create(interpreter, menuHost, controllerObject); VsmxNativeObject movieplayer = MoviePlayer.create(interpreter, menuHost, controllerObject);
VsmxNativeObject resource = Resource.create(interpreter, renderer, controllerObject, rco); VsmxNativeObject resource = Resource.create(interpreter, renderer, controllerObject, rco);
VsmxNativeObject math = Math.create(interpreter); VsmxNativeObject math = Math.create(interpreter);
globals.setPropertyValue(Controller.objectName, controllerObject); globalVariables.setPropertyValue(Controller.objectName, controllerObject);
globals.setPropertyValue(MoviePlayer.objectName, movieplayer); globalVariables.setPropertyValue(MoviePlayer.objectName, movieplayer);
globals.setPropertyValue(Resource.objectName, resource); globalVariables.setPropertyValue(Resource.objectName, resource);
globals.setPropertyValue(Math.objectName, math); globalVariables.setPropertyValue(Math.objectName, math);
interpreter.run(globalVariables);
interpreter.run(globals);
controller = (Controller) controllerObject.getObject(); controller = (Controller) controllerObject.getObject();
moviePlayer = (MoviePlayer) movieplayer.getObject(); moviePlayer = (MoviePlayer) movieplayer.getObject();
menuInterpreter = interpreter;
this.controllerObject = controllerObject; this.controllerObject = controllerObject;
controller.callCallback(interpreter, "onAutoPlay", null);
if (!resourceInitialized) {
resourceInitialized = true;
controller.callCallback(interpreter, "onAutoPlay", null);
} else {
controller.callCallback(interpreter, "onChangeResource", null);
}
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} }
@@ -11,6 +11,7 @@ import javafx.application.Platform;
import javafx.scene.canvas.Canvas; import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
@@ -158,10 +159,28 @@ public final class RcoRenderer implements Display {
if (data == null || data.length == 0) { if (data == null || data.length == 0) {
return null; return null;
} }
// Decode synchronously through ImageIO. JavaFX's `new Image(InputStream)`
// loads in the background and `GraphicsContext.drawImage` silently skips
// images whose pixels have not been uploaded yet, which leaves the whole
// menu invisible even though the image headers are readable.
try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { try (ByteArrayInputStream in = new ByteArrayInputStream(data)) {
return new Image(in); java.awt.image.BufferedImage buffered = javax.imageio.ImageIO.read(in);
if (buffered == null) {
return null;
}
return toFxImage(buffered);
} catch (IOException e) { } catch (IOException e) {
return null; return null;
} }
} }
private static Image toFxImage(java.awt.image.BufferedImage buffered) {
int w = buffered.getWidth();
int h = buffered.getHeight();
int[] argb = new int[w * h];
buffered.getRGB(0, 0, w, h, argb, 0, w);
WritableImage image = new WritableImage(w, h);
image.getPixelWriter().setPixels(0, 0, w, h, javafx.scene.image.PixelFormat.getIntArgbInstance(), argb, 0, w);
return image;
}
} }