strats: custom object types can be edited and deleted with undo (code rebuilt, objects follow their type), low-confidence opcodes re-checked (353 high, 2 low left), script view with do-while, while, break, continue, switch and else-if (gotos 11019 -> 400), first script compiler (structured script -> assembly, script stored with the type)

This commit is contained in:
MineTec committed 2026-11-07 12:17:32 +01:00
1 parent b35792a4ed
commit 8b78958306
21 files changed
+2332 -154

No files matched your search

@@ -74,6 +74,20 @@ public final class StratCatalog {
names.putIfAbsent(code, name);
}
/** Removes a type and the names of its variants' codes (custom types being rebuilt). */
public void remove(String name) {
Type t = types.remove(name);
if (t != null) {
t.variants.forEach(v -> names.remove(v.code, name));
}
codeOnly.remove(name);
}
/** Forgets the name of a code offset. */
public void removeName(int code) {
names.remove(code);
}
/** Placeable types, sorted by name. */
public List<Type> types() {
return List.copyOf(types.values());
@@ -97,6 +97,23 @@ final class ScriptPanel extends JPanel {
"duplicate",
"Copy this type into a custom type and edit its script",
() -> TypeDialog.showClone(ctx, primaryType())));
row2.add(Widgets.button("Edit type…", null, "Edit the script of this custom type (undoable)", () -> {
int k = customIndex();
if (k < 0) {
status.setText("Only custom types can be edited – use Clone as new type… for original types");
} else {
TypeDialog.showEdit(ctx, k);
}
}));
row2.add(Widgets.button(
"Delete type…", "delete", "Delete this custom type (warns about objects using it)", () -> {
int k = customIndex();
if (k < 0) {
status.setText("Only custom types can be deleted");
} else {
TypeDialog.delete(ctx, k);
}
}));
JPanel top = new JPanel(new java.awt.GridLayout(2, 1));
top.add(row1);
top.add(row2);
@@ -148,6 +165,11 @@ final class ScriptPanel extends JPanel {
reload();
}
private int customIndex() {
Scene s = ctx.scene();
return s == null || listing == null ? -1 : StratEdits.customIndex(s, listing.unit);
}
private int selectedUnit() {
UnitItem u = (UnitItem) units.getSelectedItem();
return u == null ? -1 : u.unit;
@@ -172,7 +194,7 @@ final class ScriptPanel extends JPanel {
RawChunk c = s == null ? null : StratEdits.chunk(s);
if (s != shownScene || c == null || c.data() != shownData) {
int keep = list.getSelectedIndex();
int u = selectedUnit();
int u = listing != null ? listing.unit : selectedUnit();
reloadUnits();
if (u >= 0) {
selectUnit(u);
@@ -501,7 +523,7 @@ final class ScriptPanel extends JPanel {
return;
}
if (StratEdits.isCustomUnit(ctx.scene(), listing.unit)) {
status.setText("Custom types are edited through their source: clone it again with the changed script");
status.setText("Custom types are edited through their source: Edit type…");
return;
}
Map<StratListing.Line, Integer> changes = ConstantDialog.show(ctx, r.consts);
@@ -519,9 +519,14 @@ public final class StratEdits {
/** Assembles a type's source as a new unit appended to the scene's code and verifies it. */
public static Built build(Scene s, CustomType t) {
Analysis a = analysis(s);
byte[] old = a.img.data;
return build(a.img.data, s.settings().wfpc(), a.entries, t);
}
/** Assembles a type's source as a new unit appended to the STPC data old and verifies it. */
static Built build(byte[] old, int wfpc, Set<Integer> entries, CustomType t) {
StpcImage img0 = new StpcImage(old, wfpc);
int base = old.length + 4;
StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(a.img, base));
StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(img0, base));
Integer e = r.labels().get(t.entryLabel);
if (e == null) {
throw new IllegalArgumentException("entry label " + t.entryLabel + " not found in the source");
@@ -530,20 +535,20 @@ public final class StratEdits {
byte[] d = new byte[old.length + 4 + words.length];
System.arraycopy(old, 0, d, 0, old.length);
int n = words.length / 4;
d[old.length] = (byte) n;
d[old.length + 1] = (byte) (n >> 8);
d[old.length + 2] = (byte) (n >> 16);
d[old.length + 3] = (byte) (n >> 24);
putInt(d, old.length, n);
System.arraycopy(words, 0, d, old.length + 4, words.length);
int nu = Bytes.i32(d, a.img.codeStart) + 1;
d[a.img.codeStart] = (byte) nu;
d[a.img.codeStart + 1] = (byte) (nu >> 8);
d[a.img.codeStart + 2] = (byte) (nu >> 16);
d[a.img.codeStart + 3] = (byte) (nu >> 24);
List<String> problems = verify(d, s.settings().wfpc(), a.entries, base, base + e, r);
putInt(d, img0.codeStart, Bytes.i32(d, img0.codeStart) + 1);
List<String> problems = verify(d, wfpc, entries, base, base + e, r);
return new Built(d, base, base + e, r, problems);
}
private static void putInt(byte[] d, int o, int v) {
d[o] = (byte) v;
d[o + 1] = (byte) (v >> 8);
d[o + 2] = (byte) (v >> 16);
d[o + 3] = (byte) (v >> 24);
}
/**
* Re-disassembly of the merged code: no decode errors, the new unit's listing assembles back
* to the same words, and every address operand of the new unit hits a model, an animation set
@@ -637,6 +642,154 @@ public final class StratEdits {
return code;
}
/** Why the custom types of a scene cannot be rebuilt in place (null = they can). */
public static String rebuildBlocker(Scene s) {
State st = state(s);
if (st.firstCustom >= 0 && st.firstCustom + st.customEntries.size() != units(s)) {
return "object types were imported after the custom types – save and reopen the project first";
}
return null;
}
/** STPC data of a scene without its custom units. */
private static byte[] withoutCustom(Scene s) {
State st = state(s);
Analysis a = analysis(s);
if (st.firstCustom < 0) {
return a.img.data;
}
int cut = a.img.units.get(st.firstCustom).start() - 4;
byte[] d = java.util.Arrays.copyOf(a.img.data, cut);
putInt(d, a.img.codeStart, Bytes.i32(d, a.img.codeStart) - st.customEntries.size());
return d;
}
/**
* Problems of a new list of custom types for a scene (assembled one after another behind the
* base code, as on load); null when all of them assemble and verify. index = the type to report
* words for (out[0], −1 = none).
*/
public static List<String> checkTypes(Scene s, List<CustomType> types, int index, int[] words) {
String blocker = rebuildBlocker(s);
if (blocker != null) {
return List.of(blocker);
}
byte[] d = withoutCustom(s);
Set<Integer> entries = new TreeSet<>(analysis(s).entries);
entries.removeIf(o -> o >= d.length);
byte[] cur = d;
for (int i = 0; i < types.size(); i++) {
CustomType t = types.get(i);
Built x = build(cur, s.settings().wfpc(), entries, t);
if (!x.problems.isEmpty()) {
return x.problems.stream().map(m -> t.name + ": " + m).toList();
}
if (i == index && words != null) {
words[0] = x.asm.words().length;
}
entries.add(x.entry);
for (int v : x.asm.strats().values()) {
entries.add(x.base + v);
}
cur = x.stpc;
}
return null;
}
/**
* Replaces the custom types of reference and scene by types (the custom units are cut off the
* code and the new ones appended); objects of old type k move to the entry of type remap[k]
* (objects of a type mapped to −1 must have been removed). Records the list in the project.
*/
static void setTypes(Project p, List<CustomType> types, int[] remap) {
for (Scene s : List.of(p.reference(), p.scene())) {
List<String> problems = checkTypes(s, types, -1, null);
if (problems != null) {
throw new IllegalStateException(String.join("; ", problems));
}
}
for (Scene s : List.of(p.reference(), p.scene())) {
Runnable r = () -> rebuild(s, types, remap);
if (s == p.scene()) {
s.bulk(r);
} else {
r.run();
}
}
Records rec = Records.of(p.extra());
new Records(rec.constants, List.copyOf(types)).store(p.extra(), session(p.scene()));
}
private static void rebuild(Scene s, List<CustomType> types, int[] remap) {
State st = state(s);
List<Integer> oldEntries = new ArrayList<>(st.customEntries);
Analysis a = analysis(s);
int from = st.firstCustom < 0
? a.img.data.length
: a.img.units.get(st.firstCustom).start() - 4;
int to = a.img.data.length;
List<Integer> oldUnitOf = new ArrayList<>(), oldCode = new ArrayList<>();
for (Strat x : s.strats()) {
boolean custom = x.code() >= from && x.code() < to;
oldUnitOf.add(custom ? a.img.unitOf(x.code()) - st.firstCustom : -1);
oldCode.add(x.code());
if (custom) {
s.set(x, "code", 0); // keep stale codes out of the analysis roots while appending
}
}
byte[] cut = withoutCustom(s);
StratCatalog cat = s.catalog();
for (var e : List.copyOf(cat.names().entrySet())) {
if (e.getKey() >= from && e.getKey() < to) {
StratCatalog.Type t = cat.type(e.getValue());
if (t != null && t.variants().stream().allMatch(v -> v.code() >= from && v.code() < to)) {
cat.remove(e.getValue());
}
cat.removeName(e.getKey());
}
}
chunk(s).setRaw("data", cut);
st.firstCustom = -1;
st.customEntries.clear();
for (CustomType t : types) {
appendType(s, t, false);
}
List<Strat> strats = List.copyOf(s.strats());
for (int i = 0; i < strats.size(); i++) {
int k = oldUnitOf.get(i);
if (k < 0) {
continue;
}
Strat x = strats.get(i);
int nk = k < remap.length ? remap[k] : -1;
if (nk < 0 || nk >= st.customEntries.size()) {
throw new IllegalStateException(x.id() + " still uses a removed custom type");
}
int oldEntry = oldEntries.get(k);
// objects placed at the entry follow the entry; other codes keep their offset in the unit
int c0 = oldCode.get(i);
int code = c0 == oldEntry ? st.customEntries.get(nk) : c0 - oldEntry + st.customEntries.get(nk);
s.set(x, "code", code);
}
}
/** Objects of the scene whose code lies in custom type index k's unit. */
public static List<Strat> users(Scene s, int k) {
State st = state(s);
Analysis a = analysis(s);
List<Strat> out = new ArrayList<>();
if (st.firstCustom < 0 || k < 0 || k >= st.customEntries.size()) {
return out;
}
int unit = st.firstCustom + k;
for (Strat x : s.strats()) {
if (a.img.unitOf(x.code()) == unit) {
out.add(x);
}
}
return out;
}
/** Current position of the custom units (for remapping codes on load), or null. */
static Map<String, Object> session(Scene s) {
State st = state(s);
@@ -123,10 +123,17 @@ public final class StratsEditorPlugin implements EditorPlugin {
JPanel p = new JPanel(new BorderLayout(6, 0));
p.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
p.add(new JLabel(info, Icons.get("script", 16), JLabel.LEADING), BorderLayout.CENTER);
p.add(
Widgets.button(
"Open script", null, "Show and edit this type's strat script", () -> ctx.showPanel(PANEL)),
BorderLayout.EAST);
JPanel buttons = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.TRAILING, 4, 0));
int k = a == null || a.img().unitOf(s.code()) < 0
? -1
: StratEdits.customIndex(s.scene(), a.img().unitOf(s.code()));
if (k >= 0) {
buttons.add(Widgets.button(
"Edit type…", null, "Edit this custom type's script", () -> TypeDialog.showEdit(ctx, k)));
}
buttons.add(Widgets.button(
"Open script", null, "Show and edit this type's strat script", () -> ctx.showPanel(PANEL)));
p.add(buttons, BorderLayout.EAST);
return p;
}
}
@@ -5,6 +5,7 @@ import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.format.strat.StratAssembler;
import com.eliasmueller.llamamod.format.strat.StratCompiler;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
@@ -42,6 +43,18 @@ final class TypeDialog {
end
""";
static final String SCRIPT_TEMPLATE =
"""
// A custom strat in the script language (manual chapter "Strat scripts").
strat MyStrat {
setModel(model:0) // first model of the level (pick one from another type's script)
loop {
turnLeft(4deg) // turn per frame
yield // wait one frame
}
}
""";
static void showClone(EditorContext ctx, String type) {
if (ctx.document() == null) {
return;
@@ -57,7 +70,7 @@ final class TypeDialog {
Scene s = ctx.scene();
String name = freeName(s, type + "Custom");
StratEdits.CustomType t = StratEdits.cloneOf(s, type, name);
open(ctx, "Clone " + type + " as a new type", t.name(), t.source(), type, t.entryLabel(), true);
open(ctx, "Clone " + type + " as a new type", t.name(), t.source(), type, t.entryLabel(), true, -1);
}
static void showNew(EditorContext ctx) {
@@ -67,7 +80,86 @@ final class TypeDialog {
Scene s = ctx.scene();
String base =
s.catalog().has("Coin") ? "Coin" : s.catalog().types().get(0).name();
open(ctx, "New object type from script", freeName(s, "MyStrat"), TEMPLATE, base, "MyStrat", false);
open(ctx, "New object type from script", freeName(s, "MyStrat"), TEMPLATE, base, "MyStrat", false, -1);
}
/** Edits custom type k in place: its name, entry label and source (type fields are kept). */
static void showEdit(EditorContext ctx, int k) {
if (ctx.document() == null) {
return;
}
String blocker = StratEdits.rebuildBlocker(ctx.scene());
List<StratEdits.CustomType> types =
StratEdits.records(ctx.document().project()).types();
if (blocker != null || k < 0 || k >= types.size()) {
JOptionPane.showMessageDialog(
ctx.frame(),
blocker != null ? "Cannot edit the type: " + blocker + "." : "Not a custom type.",
"Edit object type",
JOptionPane.INFORMATION_MESSAGE);
return;
}
StratEdits.CustomType t = types.get(k);
open(
ctx,
"Edit object type " + t.name(),
t.name(),
t.source(),
t.basedOn(),
t.entryLabel(),
t.basedOn() != null && !t.basedOn().isEmpty(),
k);
}
/** Deletes custom type k after a confirmation that names the objects using it. */
static void delete(EditorContext ctx, int k) {
if (ctx.document() == null) {
return;
}
Project p = ctx.document().project();
Scene s = ctx.scene();
List<StratEdits.CustomType> types = StratEdits.records(p).types();
if (k < 0 || k >= types.size()) {
return;
}
String name = types.get(k).name();
String blocker = StratEdits.rebuildBlocker(s);
List<StratEdits.CustomType> rest = new java.util.ArrayList<>(types);
rest.remove(k);
List<String> problems = blocker != null ? List.of(blocker) : StratEdits.checkTypes(s, rest, -1, null);
if (problems != null) {
JOptionPane.showMessageDialog(
ctx.frame(),
"Cannot delete " + name + ": " + problems.get(0) + ".",
"Delete object type",
JOptionPane.WARNING_MESSAGE);
return;
}
int n = StratEdits.users(s, k).size();
String msg = n == 0
? "Delete the custom type " + name + "? No object in the level uses it."
: n + " object" + (n == 1 ? "" : "s") + " in the level use" + (n == 1 ? "s " : " ") + name
+ ".\nDelete the type and " + (n == 1 ? "this object" : "these objects") + "?";
Object[] opts = {n == 0 ? "Delete type" : "Delete type and objects", "Cancel"};
int r = JOptionPane.showOptionDialog(
ctx.frame(),
msg,
"Delete object type",
JOptionPane.DEFAULT_OPTION,
JOptionPane.WARNING_MESSAGE,
null,
opts,
opts[1]);
if (r != 0) {
return;
}
try {
ctx.execute(TypeEdits.delete(p, k));
} catch (RuntimeException ex) {
ctx.error("Delete object type", ex);
return;
}
ctx.status("Object type " + name + " deleted" + (n == 0 ? "" : " with " + n + " object" + (n == 1 ? "" : "s")));
}
static String freeName(Scene s, String base) {
@@ -85,14 +177,25 @@ final class TypeDialog {
String source,
String fieldsFrom,
String entry,
boolean clone) {
boolean clone,
int editIndex) {
JDialog d = new JDialog(ctx.frame(), title, true);
JTextField nameField = new JTextField(name, 18);
JTextField entryField = new JTextField(entry, 14);
JComboBox<String> from = new JComboBox<>();
ctx.scene().catalog().types().forEach(t -> from.addItem(t.name()));
from.setSelectedItem(fieldsFrom);
JTextArea text = new JTextArea(source, 30, 90);
boolean editing = editIndex >= 0;
String oldName = editing ? name : null;
if (editing) {
from.setEnabled(false);
from.setToolTipText("An edited type keeps its type fields and parameters");
}
String script = StratCompiler.embeddedSource(source);
JTextArea text = new JTextArea(script != null ? script : source, 30, 90);
JComboBox<String> lang = new JComboBox<>(new String[] {"Assembly", "Script"});
lang.setSelectedIndex(script != null ? 1 : 0);
lang.setToolTipText("Assembly = the exact instruction list; Script = structured language compiled to assembly");
text.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
text.setTabSize(8);
JLabel result = new JLabel(" ");
@@ -101,10 +204,12 @@ final class TypeDialog {
top.add(nameField);
top.add(new JLabel("Entry label"));
top.add(entryField);
top.add(new JLabel("Language"));
top.add(lang);
top.add(new JLabel("Type fields / parameters from"));
top.add(from);
JButton check = new JButton("Check");
JButton create = new JButton("Create type");
JButton create = new JButton(editing ? "Apply" : "Create type");
JButton cancel = new JButton("Cancel");
JPanel bottom = new JPanel(new BorderLayout());
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.TRAILING));
@@ -128,6 +233,39 @@ final class TypeDialog {
d.getContentPane().add(north, BorderLayout.NORTH);
d.getContentPane().add(new JScrollPane(text), BorderLayout.CENTER);
d.getContentPane().add(bottom, BorderLayout.SOUTH);
String[] shown = {text.getText()};
int[] shownLang = {lang.getSelectedIndex()};
lang.addActionListener(e -> {
int to = lang.getSelectedIndex();
if (to == shownLang[0]) {
return;
}
String cur = text.getText();
if (to == 0) {
try {
text.setText(StratCompiler.compile(cur));
} catch (StratCompiler.CompileException ex) {
result.setText("⚠ " + ex.getMessage() + " – fix the script before switching to assembly");
highlight(text, ex.line);
lang.setSelectedIndex(1);
return;
}
} else {
String embedded = StratCompiler.embeddedSource(cur);
if (embedded != null) {
text.setText(embedded);
} else if (cur.equals(TEMPLATE) || cur.equals(shown[0]) && !clone && editIndex < 0) {
text.setText(SCRIPT_TEMPLATE);
} else {
result.setText(
"⚠ Assembly cannot be turned into script; start from the script template (New object type from script…)");
lang.setSelectedIndex(0);
return;
}
}
shownLang[0] = to;
text.setCaretPosition(0);
});
Runnable[] verify = new Runnable[1];
StratEdits.CustomType[] ok = new StratEdits.CustomType[1];
verify[0] = () -> {
@@ -138,12 +276,55 @@ final class TypeDialog {
result.setText("⚠ The name must be letters, digits and _ (max 40).");
return;
}
if (s.catalog().has(n) || s.catalog().names().containsValue(n)) {
if (!n.equals(oldName) && (s.catalog().has(n) || s.catalog().names().containsValue(n))) {
result.setText("⚠ A type named " + n + " already exists.");
return;
}
String src = text.getText();
if (lang.getSelectedIndex() == 1) {
try {
src = StratCompiler.compile(src);
} catch (StratCompiler.CompileException e) {
result.setText("⚠ " + e.getMessage());
highlight(text, e.line);
return;
}
String first = StratCompiler.firstStrat(text.getText());
if (first != null && !src.contains("\n" + entryField.getText().trim() + ":\n")) {
entryField.setText(first);
}
}
StratCatalog.Type ft = s.catalog().type((String) from.getSelectedItem());
if (editing) {
StratEdits.CustomType old =
StratEdits.records(ctx.document().project()).types().get(editIndex);
StratEdits.CustomType t = new StratEdits.CustomType(
n, old.basedOn(), entryField.getText().trim(), src, old.proto());
List<StratEdits.CustomType> all = new java.util.ArrayList<>(
StratEdits.records(ctx.document().project()).types());
all.set(editIndex, t);
int[] words = new int[1];
try {
List<String> ps = StratEdits.checkTypes(s, all, editIndex, words);
if (ps != null) {
result.setText("⚠ " + String.join("; ", ps));
return;
}
} catch (StratAssembler.AsmException e) {
List<StratAssembler.Problem> ps = e.problems;
result.setText("⚠ " + ps.size() + " error" + (ps.size() == 1 ? "" : "s") + ": " + ps.get(0));
highlight(text, ps.get(0).line());
return;
} catch (RuntimeException e) {
result.setText("⚠ " + e.getMessage());
return;
}
int users = StratEdits.users(s, editIndex).size();
result.setText("✓ " + words[0] + " words, verified. " + users + " object" + (users == 1 ? "" : "s")
+ " in the level use" + (users == 1 ? "s" : "") + " this type.");
ok[0] = t;
return;
}
if (ft == null) {
result.setText("⚠ Choose the type whose type fields and parameters the new type uses.");
return;
@@ -181,12 +362,21 @@ final class TypeDialog {
}
Project p = ctx.document().project();
try {
StratEdits.addType(p, ok[0]);
if (editing) {
ctx.execute(TypeEdits.edit(p, editIndex, ok[0]));
} else if (StratEdits.rebuildBlocker(ctx.scene()) == null) {
ctx.execute(TypeEdits.create(p, ok[0]));
} else {
StratEdits.addType(p, ok[0]); // types imported after the custom ones: append, no undo
}
} catch (RuntimeException ex) {
ctx.error("Create object type", ex);
ctx.error(editing ? "Edit object type" : "Create object type", ex);
return;
}
ctx.status("Object type " + ok[0].name() + " created – place it from Assets → Objects");
ctx.status(
editing
? "Object type " + ok[0].name() + " updated – rebuild/play to see the effect"
: "Object type " + ok[0].name() + " created – place it from Assets → Objects");
d.dispose();
});
cancel.addActionListener(e -> d.dispose());
@@ -0,0 +1,81 @@
package com.eliasmueller.llamamod.editor.tools.strats;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import java.util.ArrayList;
import java.util.List;
/** Undoable create / edit / delete of custom object types (the custom code block is rebuilt). */
final class TypeEdits {
private TypeEdits() {}
/** Replaces the custom type list; fwd/back map type indexes of the old list to the new one and back. */
record SetTypes(
Project p,
String label,
List<StratEdits.CustomType> before,
List<StratEdits.CustomType> after,
int[] fwd,
int[] back)
implements Command {
@Override
public void apply(Scene s) {
StratEdits.setTypes(p, after, fwd);
}
@Override
public void revert(Scene s) {
StratEdits.setTypes(p, before, back);
}
}
static int[] identity(int n) {
int[] a = new int[n];
for (int i = 0; i < n; i++) {
a[i] = i;
}
return a;
}
static Command create(Project p, StratEdits.CustomType t) {
List<StratEdits.CustomType> before = StratEdits.records(p).types();
List<StratEdits.CustomType> after = new ArrayList<>(before);
after.add(t);
return new SetTypes(
p, "Create object type " + t.name(), before, after, identity(before.size()), identity(before.size()));
}
/** Replaces custom type k by t (objects of the type stay and use the new code). */
static Command edit(Project p, int k, StratEdits.CustomType t) {
List<StratEdits.CustomType> before = StratEdits.records(p).types();
List<StratEdits.CustomType> after = new ArrayList<>(before);
after.set(k, t);
return new SetTypes(
p, "Edit object type " + t.name(), before, after, identity(before.size()), identity(before.size()));
}
/** Deletes custom type k together with the objects that use it. */
static Command delete(Project p, int k) {
List<StratEdits.CustomType> before = StratEdits.records(p).types();
List<StratEdits.CustomType> after = new ArrayList<>(before);
StratEdits.CustomType gone = after.remove(k);
int[] fwd = new int[before.size()], back = new int[after.size()];
for (int i = 0; i < fwd.length; i++) {
fwd[i] = i < k ? i : i == k ? -1 : i - 1;
}
for (int j = 0; j < back.length; j++) {
back[j] = j < k ? j : j + 1;
}
List<Command> cmds = new ArrayList<>();
List<Strat> users = StratEdits.users(p.scene(), k);
if (!users.isEmpty()) {
cmds.add(Edits.delete(p.scene(), users));
}
cmds.add(new SetTypes(p, "Delete object type " + gone.name(), before, after, fwd, back));
return new Batch("Delete object type " + gone.name(), cmds);
}
}
@@ -0,0 +1,652 @@
package com.eliasmueller.llamamod.format.strat;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Compiler for strat scripts, a small structured language for NEW behaviour (notes/strat-language.md,
* "Script language"): statements and expressions in the style of the script view, compiled to the
* assembly of {@link StratAssembler}. Literals mean the same as in the assembly (10.0 = ten, 10 =
* ten raw 1/4096 units, 90deg).
*/
public final class StratCompiler {
/** A compile error at a 1-based source line. */
public static final class CompileException extends RuntimeException {
public final int line;
CompileException(int line, String message) {
super("line " + line + ": " + message);
this.line = line;
}
}
/** Prefix of the comment lines that carry the script source inside the generated assembly. */
public static final String SOURCE_PREFIX = ";| ";
private record Tok(String kind, String text, int line) {}
private static final Map<String, Integer> PREC = Map.ofEntries(
Map.entry("||", 1),
Map.entry("&&", 2),
Map.entry("|", 3),
Map.entry("&", 4),
Map.entry("==", 5),
Map.entry("!=", 5),
Map.entry("<", 6),
Map.entry(">", 6),
Map.entry("<=", 6),
Map.entry(">=", 6),
Map.entry("<<", 7),
Map.entry(">>", 7),
Map.entry("+", 8),
Map.entry("-", 8),
Map.entry("*", 9),
Map.entry("/", 9));
private static final Map<String, String> BINOP = Map.ofEntries(
Map.entry("||", "or"),
Map.entry("|", "or"),
Map.entry("&&", "and"),
Map.entry("&", "bitwiseAnd"),
Map.entry("==", "eq"),
Map.entry("!=", "notEqual"),
Map.entry("<", "lt"),
Map.entry(">", "gt"),
Map.entry("<=", "lessEqual"),
Map.entry(">=", "greaterEqual"),
Map.entry("<<", "shl"),
Map.entry(">>", "shr"),
Map.entry("+", "add"),
Map.entry("-", "sub"),
Map.entry("*", "mul"),
Map.entry("/", "div"));
/** Target prefix → push, pop, address mnemonics (variables of the parent use the *Var ops). */
private static final Map<String, String[]> TARGETS = Map.of(
"self", new String[] {"pushProp", "popProp", "pushPropAddr"},
"player", new String[] {"pushPlayerProp", "popPlayerProp", null},
"parent", new String[] {"pushParentProp", "popParentProp", "pushParentPropAddr"},
"camera", new String[] {"pushCameraProp", "popCameraProp", null},
"target", new String[] {"pushTargetProp", "popTargetProp", null},
"target2", new String[] {"pushTarget2Prop", "popTarget2Prop", null},
"boss", new String[] {"pushBossProp", "popBossProp", null},
"dialog", new String[] {"pushDialogProp", "popDialogProp", null});
private static final List<String> REFS = List.of("model", "anim", "unit", "sound", "tex", "text");
private final OpTable ops = OpTable.get();
private final List<Tok> toks;
private int p;
private final StringBuilder out = new StringBuilder();
private final Map<String, String> aliases = new HashMap<>();
private int labels;
/** Enclosing loops: {break label, continue label}. */
private final Deque<String[]> loops = new ArrayDeque<>();
private StratCompiler(String src) {
toks = lex(src);
}
/** Compiles a script to assembly text; the script is kept as {@link #SOURCE_PREFIX} comments. */
public static String compile(String src) {
StratCompiler c = new StratCompiler(src);
for (String l : src.split("\n", -1)) {
c.out.append(SOURCE_PREFIX).append(l.stripTrailing()).append('\n');
}
c.program();
return c.out.toString();
}
/** The script embedded in compiled assembly, or null if the text is not compiler output. */
public static String embeddedSource(String asm) {
if (!asm.startsWith(SOURCE_PREFIX)) {
return null;
}
StringBuilder b = new StringBuilder();
for (String l : asm.split("\n", -1)) {
if (!l.startsWith(SOURCE_PREFIX.trim())) {
break;
}
b.append(l.length() >= SOURCE_PREFIX.length() ? l.substring(SOURCE_PREFIX.length()) : "")
.append('\n');
}
return b.toString().stripTrailing() + "\n";
}
/** Entry label of the first strat of a script (null if none). */
public static String firstStrat(String src) {
List<Tok> t = lex(src);
for (int i = 0; i + 1 < t.size(); i++) {
if (t.get(i).text.equals("strat") && t.get(i + 1).kind.equals("id")) {
return t.get(i + 1).text;
}
}
return null;
}
// ------------------------------------------------------------------ lexer
private static List<Tok> lex(String s) {
List<Tok> out = new ArrayList<>();
int line = 1;
int i = 0;
while (i < s.length()) {
char c = s.charAt(i);
if (c == '\n') {
line++;
i++;
} else if (Character.isWhitespace(c) || c == ';') {
i++;
} else if (s.startsWith("//", i)) {
while (i < s.length() && s.charAt(i) != '\n') {
i++;
}
} else if (s.startsWith("/*", i)) {
int e = s.indexOf("*/", i + 2);
e = e < 0 ? s.length() : e + 2;
for (int k = i; k < e; k++) {
line += s.charAt(k) == '\n' ? 1 : 0;
}
i = e;
} else if (Character.isDigit(c)) {
int j = i;
if (s.startsWith("0x", i) || s.startsWith("0X", i)) {
j += 2;
while (j < s.length() && Character.digit(s.charAt(j), 16) >= 0) {
j++;
}
} else {
while (j < s.length() && (Character.isDigit(s.charAt(j)) || s.charAt(j) == '.')) {
j++;
}
if (s.startsWith("deg", j)) {
j += 3;
}
}
out.add(new Tok("num", s.substring(i, j), line));
i = j;
} else if (Character.isLetter(c) || c == '_') {
int j = i;
while (j < s.length() && (Character.isLetterOrDigit(s.charAt(j)) || s.charAt(j) == '_')) {
j++;
}
out.add(new Tok("id", s.substring(i, j), line));
i = j;
} else if (c == '"') {
int j = i + 1;
while (j < s.length() && s.charAt(j) != '"' && s.charAt(j) != '\n') {
j += s.charAt(j) == '\\' ? 2 : 1;
}
out.add(new Tok("str", s.substring(i, Math.min(j + 1, s.length())), line));
i = j + 1;
} else {
String two = i + 1 < s.length() ? s.substring(i, i + 2) : "";
String op = List.of("==", "!=", "<=", ">=", "&&", "||", "<<", ">>", "+=", "-=", "*=", "/=")
.contains(two)
? two
: String.valueOf(c);
out.add(new Tok("op", op, line));
i += op.length();
}
}
out.add(new Tok("eof", "", line));
return out;
}
// ------------------------------------------------------------------ parser helpers
private Tok peek() {
return toks.get(p);
}
private Tok peek(int k) {
return toks.get(Math.min(p + k, toks.size() - 1));
}
private Tok next() {
return toks.get(p < toks.size() - 1 ? p++ : p);
}
private boolean at(String text) {
return peek().text.equals(text) && !peek().kind.equals("str");
}
private boolean at1(String text) {
return peek(1).text.equals(text) && !peek(1).kind.equals("str");
}
private boolean accept(String text) {
if (at(text)) {
p++;
return true;
}
return false;
}
private Tok expect(String text) {
if (!at(text)) {
throw err("expected '" + text + "' but found " + describe(peek()));
}
return next();
}
private String ident() {
if (!peek().kind.equals("id")) {
throw err("expected a name but found " + describe(peek()));
}
return next().text;
}
private static String describe(Tok t) {
return t.kind.equals("eof") ? "the end of the script" : "'" + t.text + "'";
}
private CompileException err(String m) {
return new CompileException(peek().line, m);
}
private String label() {
return "c_" + (++labels);
}
private void emit(String instr) {
out.append(" ").append(instr).append('\n');
}
private void place(String label) {
out.append(label).append(":\n");
}
// ------------------------------------------------------------------ program
private void program() {
while (!peek().kind.equals("eof")) {
if (accept("strat")) {
String name = ident();
out.append(" .string \"").append(name).append("\"\n");
place(name);
emit("debugName \"" + name + "\"");
boolean ends = block();
if (!ends) {
emit("end");
}
} else if (accept("sub")) {
String name = ident();
place(name);
accept("(");
accept(")");
boolean ends = block();
if (!ends) {
emit("return");
}
} else if (at("alias")) {
statement();
} else {
throw err("expected 'strat', 'sub' or 'alias' but found " + describe(peek()));
}
}
}
/** Parses { statements }; returns whether control never falls out of its end. */
private boolean block() {
expect("{");
boolean ends = false;
while (!at("}")) {
if (peek().kind.equals("eof")) {
throw err("missing '}'");
}
ends = statement();
}
expect("}");
return ends;
}
/** One statement; returns whether control never continues after it (end, endless loop, return). */
private boolean statement() {
Tok t = peek();
switch (t.text) {
case "if" -> {
next();
ifRest();
return false;
}
case "while" -> {
next();
String top = label(), done = label();
place(top);
expect("(");
cond(done);
expect(")");
loops.push(new String[] {done, top});
loopBody(top);
loops.pop();
place(done);
return false;
}
case "loop" -> {
next();
String top = label(), done = label();
place(top);
loops.push(new String[] {done, top});
loopBody(top);
loops.pop();
place(done);
return false;
}
case "do" -> {
next();
String top = label(), check = label(), done = label();
place(top);
loops.push(new String[] {done, check});
block();
loops.pop();
expect("while");
expect("(");
place(check);
expr();
expect(")");
emit("jnz " + top);
place(done);
return false;
}
case "break", "continue" -> {
next();
if (loops.isEmpty()) {
throw new CompileException(t.line, t.text + " outside a loop");
}
emit("jmp " + loops.peek()[t.text.equals("break") ? 0 : 1]);
return true;
}
case "yield" -> {
next();
String l = label();
emit("jmpYield " + l);
place(l);
return false;
}
case "return" -> {
next();
emit("return");
return true;
}
case "end" -> {
next();
emit("end");
return true;
}
case "call" -> {
next();
String l = ident();
if (accept("(")) {
expect(")");
}
emit("call " + l);
return false;
}
case "alias" -> {
next();
String n = ident();
expect("=");
String v = ident();
if (!v.matches("v\\d+")) {
throw new CompileException(t.line, "an alias names a variable vN");
}
aliases.put(n, v);
out.append(" .alias ").append(n).append(" = ").append(v).append('\n');
return false;
}
default -> {
// fall through to assignments and calls
}
}
if (!t.kind.equals("id")) {
throw err("expected a statement but found " + describe(t));
}
if (isOp(t.text) && !isLvalueStart()) {
String mn = callExpr();
OpTable.Op op = ops.byName(mn);
if (op.pushes() == 1) {
emit("pop");
}
return mn.equalsIgnoreCase("end") || mn.equalsIgnoreCase("remove");
}
assignment();
return false;
}
private void ifRest() {
String other = label();
expect("(");
cond(other);
expect(")");
block();
if (accept("else")) {
String done = label();
emit("jmp " + done);
place(other);
if (accept("if")) {
ifRest();
} else {
block();
}
place(done);
} else {
place(other);
}
}
/** Condition: jumps to falseTarget when it is 0. */
private void cond(String falseTarget) {
expr();
emit("jz " + falseTarget);
}
/** Loop body; a trailing yield becomes the yielding back edge. */
private void loopBody(String top) {
expect("{");
int mark = -1;
while (!at("}")) {
if (peek().kind.equals("eof")) {
throw err("missing '}'");
}
mark = at("yield") && peek(1).text.equals("}") ? out.length() : -1;
if (mark >= 0) {
next();
break;
}
statement();
}
expect("}");
emit((mark >= 0 ? "jmpYield " : "jmp ") + top);
}
private boolean isOp(String name) {
return ops.byName(name) != null;
}
private boolean isLvalueStart() {
String a = peek().text;
return aliases.containsKey(a)
|| a.matches("[vg]\\d+")
|| TARGETS.containsKey(a) && peek(1).text.equals(".");
}
// ------------------------------------------------------------------ assignments and lvalues
/** A writable place: push/pop/address instructions with their operand. */
private record Place(String push, String pop, String addr) {}
private Place place() {
Tok t = peek();
String a = ident();
if (aliases.containsKey(a)) {
a = aliases.get(a);
}
if (a.matches("v\\d+")) {
return new Place("pushVar " + a, "popVar " + a, "pushVarAddr " + a);
}
if (a.matches("g\\d+")) {
return new Place("pushGlobal " + a, "popGlobal " + a, "pushGlobalAddr " + a);
}
if (TARGETS.containsKey(a) && accept(".")) {
String n = ident();
if (a.equals("parent") && n.matches("v\\d+")) {
return new Place("pushParentVar " + n, "popParentVar " + n, "pushParentVarAddr " + n);
}
if (ops.propByName(n) == null && !n.matches("prop\\d+")) {
throw new CompileException(t.line, "unknown property " + n);
}
String[] m = TARGETS.get(a);
return new Place(m[0] + " " + n, m[1] + " " + n, m[2] == null ? null : m[2] + " " + n);
}
throw new CompileException(
t.line,
"unknown name " + a + " (variables are vN or aliases, globals gN, "
+ "properties self.name, player.name, …)");
}
private void assignment() {
Tok t = peek();
Place pl = place();
String op = next().text;
switch (op) {
case "=" -> {
expr();
emit(pl.pop);
}
case "+=", "-=", "*=", "/=" -> {
boolean one = at("1.0") && (peek(1).line != peek().line || at1("}"));
if ((op.equals("+=") || op.equals("-=")) && pl.addr != null && one) {
next();
emit(pl.addr);
emit(op.equals("+=") ? "inc" : "dec");
} else {
emit(pl.push);
expr();
emit(BINOP.get(op.substring(0, 1)));
emit(pl.pop);
}
}
default -> throw new CompileException(t.line, "expected '=', '+=', '-=', '*=' or '/=' after a name");
}
}
// ------------------------------------------------------------------ expressions
private void expr() {
binary(1);
}
private void binary(int min) {
unary();
while (peek().kind.equals("op") && PREC.containsKey(peek().text) && PREC.get(peek().text) >= min) {
String op = next().text;
binary(PREC.get(op) + 1);
emit(BINOP.get(op));
}
}
private void unary() {
if (accept("-")) {
if (peek().kind.equals("num")) {
number("-" + next().text);
return;
}
unary();
emit("neg");
} else if (accept("!")) {
unary();
emit("not");
} else {
primary();
}
}
private void primary() {
Tok t = peek();
if (t.kind.equals("num")) {
number(next().text);
} else if (accept("(")) {
expr();
expect(")");
} else if (t.kind.equals("id") && REFS.contains(t.text) && peek(1).text.equals(":")) {
next();
next();
StringBuilder ref = new StringBuilder(t.text + ":" + next().text);
if (at("+") && peek(1).kind.equals("num") && !t.text.equals("sound") && !t.text.equals("tex")) {
next();
ref.append('+').append(next().text);
}
switch (t.text) {
case "sound" -> emit("soundAddress " + ref);
case "tex" -> emit("push " + ref);
case "text" -> throw new CompileException(t.line, "text:N is an inline operand: topSay<text:N>()");
default -> emit("address " + ref);
}
} else if (t.kind.equals("id") && isOp(t.text) && !isLvalueStart()) {
String mn = callExpr();
if (ops.byName(mn).pushes() != 1) {
throw new CompileException(t.line, mn + " gives no value");
}
} else if (t.kind.equals("id")) {
emit(place().push);
} else {
throw err("expected a value but found " + describe(t));
}
}
private void number(String text) {
Integer v = Num.parse(text);
if (v == null) {
throw err("bad number " + text);
}
emit((v >= Short.MIN_VALUE && v <= Short.MAX_VALUE ? "pushShort " : "push ") + text);
}
/** op<inline, …>(args): pushes the arguments and emits the opcode; returns its mnemonic. */
private String callExpr() {
Tok t = peek();
String mn = ident();
OpTable.Op op = ops.byName(mn);
mn = op.name();
List<String> inline = new ArrayList<>();
if (accept("<")) {
StringBuilder cur = new StringBuilder();
while (!at(">")) {
if (peek().kind.equals("eof")) {
throw err("missing '>'");
}
Tok x = next();
if (x.text.equals(",")) {
inline.add(cur.toString());
cur.setLength(0);
} else {
cur.append(x.text);
}
}
expect(">");
inline.add(cur.toString());
}
int args = 0;
if (accept("(")) {
while (!at(")")) {
expr();
args++;
if (!accept(",")) {
break;
}
}
expect(")");
}
if (op.pops() >= 0 && args != op.pops()) {
throw new CompileException(
t.line, mn + " takes " + op.pops() + " value" + (op.pops() == 1 ? "" : "s") + ", got " + args);
}
emit(mn + (inline.isEmpty() ? "" : " " + String.join(", ", inline)));
return mn;
}
}
@@ -119,7 +119,19 @@ public final class StratPseudo {
return b.toString();
}
private record Block(int open, int close, String kind) {}
/**
* A structured block. if: open = the conditional jump, close = its target label; loop/do: open
* = head label, close = the backward jump; switch: open = the switch, close = the end label.
*/
private record Block(int open, int close, String kind) {
boolean breakable() {
return !kind.equals("if");
}
boolean isLoop() {
return kind.startsWith("loop");
}
}
private String mn(StratListing.Line l) {
String t = l.text();
@@ -133,27 +145,64 @@ public final class StratPseudo {
return sp < 0 ? "" : t.substring(sp + 1).trim();
}
private static boolean condJump(int op) {
return op >= 0x12 && op <= 0x15;
}
private static boolean jump(int op) {
return condJump(op) || op == 0x16 || op == 0x2b;
}
// structure found by build(), used while emitting
private final Map<Integer, Block> ifAt = new HashMap<>(), loopEnd = new HashMap<>(), switchAt = new HashMap<>();
private final Map<Integer, List<Block>> opensAt = new HashMap<>();
private final Map<Integer, Integer> closeCount = new HashMap<>(), elseAt = new HashMap<>();
private final Map<Integer, Integer> switchDepth = new HashMap<>();
private final Map<Integer, List<String>> caseAt = new HashMap<>();
private final Set<Integer> elseJumps = new HashSet<>(), silentJumps = new HashSet<>();
private final Map<Integer, String> jumpAs = new HashMap<>();
private Map<String, Integer> labelIdx;
private Integer target(int i) {
return labelIdx.get(operand(ls.get(i)));
}
/** Index of the first non-label line at or after i (labels at the same address share it). */
private int anchor(int i) {
while (i < ls.size() && ls.get(i).kind() == StratListing.Kind.LABEL) {
i++;
}
return i;
}
private void build() {
Map<String, Integer> labelIdx = new HashMap<>();
labelIdx = new HashMap<>();
for (int i = 0; i < ls.size(); i++) {
if (ls.get(i).kind() == StratListing.Kind.LABEL) {
labelIdx.put(ls.get(i).label(), i);
}
}
Map<String, List<String[]>> tables = switchTables();
// candidate blocks
List<Block> cand = new ArrayList<>();
Map<Integer, Integer> elseOf = new HashMap<>();
List<Block> switches = new ArrayList<>();
Map<Integer, List<int[]>> switchCases = new HashMap<>();
for (int i = 0; i < ls.size(); i++) {
StratListing.Line l = ls.get(i);
if (l.kind() != StratListing.Kind.INSTR) {
continue;
}
int op = l.op();
if (op == StratOps.SWITCH) {
switchCandidate(i, tables, switches, switchCases);
continue;
}
Integer t = labelIdx.get(operand(l));
if (t == null) {
continue;
}
if ((op == 0x14 || op == 0x15 || op == 0x12 || op == 0x13) && t > i && clean(i, t)) {
if (condJump(op) && t > i && clean(i, t)) {
cand.add(new Block(i, t, "if"));
int pj = prevInstr(t);
if (pj > i && ls.get(pj).op() == 0x16) {
@@ -164,54 +213,85 @@ public final class StratPseudo {
}
} else if ((op == 0x16 || op == 0x2b) && t <= i && clean(t, i)) {
cand.add(new Block(t, i, op == 0x2b ? "loopYield" : "loop"));
} else if (condJump(op) && t <= i && clean(t, i)) {
cand.add(new Block(t, i, "do"));
}
}
List<Block> accepted = new ArrayList<>();
Set<Integer> elseAccepted = new HashSet<>();
cand.sort((a, b) -> a.open != b.open ? Integer.compare(a.open, b.open) : Integer.compare(b.close, a.close));
for (Block b : cand) {
int end = b.kind.equals("if") && elseOf.containsKey(b.open) ? elseOf.get(b.open) : b.close;
boolean ok = true;
for (Block a : accepted) {
int ae = a.kind.equals("if") && elseAccepted.contains(a.open) ? elseOf.get(a.open) : a.close;
ok &= nests(b.open, end, a.open, ae);
if (ok && b.kind.equals("if") && elseOf.containsKey(b.open)) {
ok &= nests(b.open, b.close, a.open, ae) && nests(b.close, end, a.open, ae);
}
if (ok && a.kind.equals("if") && elseAccepted.contains(a.open)) {
ok &= nests(b.open, end, a.open, a.close) && nests(b.open, end, a.close, ae);
}
boolean withElse = b.kind.equals("if") && elseOf.containsKey(b.open);
if (withElse && !fits(b, true, accepted, elseOf, elseAccepted)) {
withElse = false;
}
if (ok && accepted.stream().noneMatch(a -> a.open == b.open)) {
if (fits(b, withElse, accepted, elseOf, elseAccepted)
&& accepted.stream().noneMatch(a -> a.open == b.open && a.close == b.close)
&& !(b.isLoop() && accepted.stream().anyMatch(a -> a.open == b.open && a.isLoop()))
&& (b.kind.equals("if")
|| accepted.stream().noneMatch(a -> a.open == b.open && a.kind.equals("if")))) {
accepted.add(b);
if (elseOf.containsKey(b.open) && b.kind.equals("if")) {
if (withElse) {
elseAccepted.add(b.open);
}
}
}
Map<Integer, Block> ifAt = new HashMap<>(), loopAt = new HashMap<>(), loopEnd = new HashMap<>();
Map<Integer, Integer> closeCount = new HashMap<>(), elseAt = new HashMap<>();
Set<Integer> elseJumps = new HashSet<>();
Map<String, Integer> consumed = new HashMap<>();
for (Block b : accepted) {
if (b.kind.equals("if")) {
ifAt.put(b.open, b);
consumed.merge(ls.get(b.close).label(), 1, Integer::sum);
if (elseAccepted.contains(b.open)) {
int m = elseOf.get(b.open);
elseAt.put(b.close, b.open);
elseJumps.add(prevInstr(b.close));
consumed.merge(ls.get(m).label(), 1, Integer::sum);
closeCount.merge(m, 1, Integer::sum);
} else {
closeCount.merge(b.close, 1, Integer::sum);
for (Block sw : switches) {
if (!fits(sw, false, accepted, elseOf, elseAccepted)) {
continue;
}
boolean ok = true;
for (int[] c : switchCases.get(sw.open)) {
for (Block a : accepted) {
int ae = end(a, elseOf, elseAccepted);
if (a.open > sw.open && ae <= sw.close && a.open < c[0] && c[0] < ae) {
ok = false;
}
}
} else {
loopAt.computeIfAbsent(b.open, k -> b);
loopEnd.put(b.close, b);
consumed.merge(ls.get(b.open).label(), 1, Integer::sum);
}
if (ok) {
accepted.add(sw);
}
}
Map<String, Integer> consumed = new HashMap<>();
for (Block b : accepted) {
switch (b.kind) {
case "if" -> {
ifAt.put(b.open, b);
consumed.merge(ls.get(b.close).label(), 1, Integer::sum);
if (elseAccepted.contains(b.open)) {
int m = elseOf.get(b.open);
elseAt.put(b.close, b.open);
elseJumps.add(prevInstr(b.close));
consumed.merge(ls.get(m).label(), 1, Integer::sum);
closeCount.merge(m, 1, Integer::sum);
} else {
closeCount.merge(b.close, 1, Integer::sum);
}
}
case "switch" -> {
switchAt.put(b.open, b);
closeCount.merge(b.close, 1, Integer::sum);
silentJumps.add(b.open + 1);
consumed.merge(ls.get(b.close).label(), 1, Integer::sum);
for (int[] c : switchCases.get(b.open)) {
String text = c[1] < 0
? "default:"
: "case " + tables.get(operand(ls.get(b.open))).get(c[1])[0] + ":";
caseAt.computeIfAbsent(c[0], k -> new ArrayList<>()).add(text);
caseOwner.put(c[0], b.open);
consumed.merge(ls.get(c[0]).label(), 1, Integer::sum);
}
}
default -> {
opensAt.computeIfAbsent(b.open, k -> new ArrayList<>()).add(b);
loopEnd.put(b.close, b);
consumed.merge(ls.get(b.open).label(), 1, Integer::sum);
}
}
}
opensAt.values().forEach(l -> l.sort((a, b) -> Integer.compare(b.close, a.close)));
resolveJumps(accepted, consumed);
Map<String, Integer> refs = new HashMap<>();
for (StratListing.Line l : ls) {
if (l.kind() == StratListing.Kind.INSTR || l.kind() == StratListing.Kind.DATA) {
@@ -245,6 +325,13 @@ public final class StratPseudo {
out(-1, "} else {");
depth++;
}
if (caseAt.containsKey(i)) {
depth = switchDepth.get(caseOwner.get(i));
for (String t : caseAt.get(i)) {
out(-1, t);
}
depth++;
}
int r = refs.getOrDefault(l.label(), 0) - consumed.getOrDefault(l.label(), 0);
if (src.strats.containsKey(l.offset())) {
out(l.offset(), "");
@@ -254,8 +341,8 @@ public final class StratPseudo {
|| l.label().startsWith("ev_")) {
out(l.offset(), l.label() + ":");
}
if (loopAt.containsKey(i)) {
out(-1, "loop {");
for (Block b : opensAt.getOrDefault(i, List.of())) {
out(-1, b.kind.equals("do") ? "do {" : "loop {");
depth++;
}
}
@@ -263,10 +350,245 @@ public final class StratPseudo {
flush();
out(l.offset(), l.text());
}
case INSTR -> instr(i, l, ifAt, loopEnd, elseJumps);
case INSTR -> instr(i, l);
}
}
flush();
whileLoops();
elseIfChains();
}
private final Map<Integer, Integer> caseOwner = new HashMap<>();
private static int end(Block a, Map<Integer, Integer> elseOf, Set<Integer> elseAccepted) {
return a.kind.equals("if") && elseAccepted.contains(a.open) ? elseOf.get(a.open) : a.close;
}
private static boolean fits(
Block b, boolean withElse, List<Block> accepted, Map<Integer, Integer> elseOf, Set<Integer> elseAccepted) {
int end = withElse ? elseOf.get(b.open) : b.close;
for (Block a : accepted) {
int ae = end(a, elseOf, elseAccepted);
boolean ok = nests(b.open, end, a.open, ae);
if (ok && withElse) {
ok = nests(b.open, b.close, a.open, ae) && nests(b.close, end, a.open, ae);
}
if (ok && a.kind.equals("if") && elseAccepted.contains(a.open)) {
ok = nests(b.open, end, a.open, a.close) && nests(b.open, end, a.close, ae);
}
if (!ok) {
return false;
}
}
return true;
}
/** Switch tables of the unit: table label → rows {case value text, target label}. */
private Map<String, List<String[]>> switchTables() {
Map<String, List<String[]>> out = new HashMap<>();
for (int i = 0; i + 1 < ls.size(); i++) {
if (ls.get(i).kind() == StratListing.Kind.LABEL
&& ls.get(i + 1).kind() == StratListing.Kind.DATA
&& ls.get(i + 1).text().startsWith(".switch ")) {
Integer n = Num.parse(ls.get(i + 1).text().substring(8).trim());
List<String[]> rows = new ArrayList<>();
for (int k = 0; n != null && k < n && i + 2 + k < ls.size(); k++) {
String t = ls.get(i + 2 + k).text();
if (!t.startsWith(".case ")) {
break;
}
t = t.substring(6).trim();
if (t.startsWith("imm ")) {
t = t.substring(4);
}
int comma = t.lastIndexOf(',');
rows.add(new String[] {
t.substring(0, comma).trim(), t.substring(comma + 1).trim()
});
}
if (n != null && rows.size() == n) {
out.put(ls.get(i).label(), rows);
}
}
}
return out;
}
/** switch at s followed by "jmp E": cases (and default) inside (s, E] become a switch block. */
private void switchCandidate(
int s, Map<String, List<String[]>> tables, List<Block> out, Map<Integer, List<int[]>> cases) {
List<String[]> rows = tables.get(operand(ls.get(s)));
if (rows == null || s + 1 >= ls.size()) {
return;
}
StratListing.Line j = ls.get(s + 1);
if (j.kind() != StratListing.Kind.INSTR || j.op() != 0x16) {
return;
}
Integer d = target(s + 1);
if (d == null || d <= s + 1) {
return;
}
List<int[]> cs = new ArrayList<>();
int e = d;
for (int k = 0; k < rows.size(); k++) {
Integer t = labelIdx.get(rows.get(k)[1]);
if (t == null || t <= s + 1) {
return;
}
cs.add(new int[] {t, k});
e = Math.max(e, t);
}
// the end: the default target when it lies behind every case, else the first label after
// the last case's code that a "jmp" in a case body targets
int endIdx = d;
if (d < e) {
endIdx = -1;
for (int i = s + 2; i < ls.size() && endIdx < 0; i++) {
if (ls.get(i).kind() == StratListing.Kind.INSTR && ls.get(i).op() == 0x16) {
Integer t = target(i);
if (t != null && t > e) {
endIdx = t;
}
}
}
if (endIdx < 0) {
return;
}
cs.add(new int[] {d, -1});
}
if (!clean(s, endIdx)) {
return;
}
for (int[] c : cs) {
if (c[0] > endIdx) {
return;
}
}
int stop = endIdx;
cs.removeIf(c -> c[0] == stop);
if (cs.isEmpty()) {
return;
}
cs.sort((a, b) -> Integer.compare(a[0], b[0]));
if (anchor(cs.get(0)[0]) != anchor(s + 2)) {
return;
}
out.add(new Block(s, endIdx, "switch"));
cases.put(s, cs);
}
/** Remaining jumps to the end (or head) of the innermost enclosing loop/switch become break/continue. */
private void resolveJumps(List<Block> accepted, Map<String, Integer> consumed) {
for (int i = 0; i < ls.size(); i++) {
StratListing.Line l = ls.get(i);
if (l.kind() != StratListing.Kind.INSTR
|| !jump(l.op())
|| ifAt.containsKey(i)
|| loopEnd.containsKey(i)
|| elseJumps.contains(i)
|| silentJumps.contains(i)) {
continue;
}
Integer t = target(i);
if (t == null) {
continue;
}
Block in = null;
for (Block b : accepted) {
if (b.breakable()
&& b.open < i
&& i < b.close
&& (in == null || b.open >= in.open && b.close <= in.close)) {
in = b;
}
}
if (in == null) {
continue;
}
int endAt = in.kind.equals("switch") ? in.close : in.close + 1;
String as = null;
if (anchor(t) == anchor(endAt)) {
as = "break";
} else if (in.isLoop() && anchor(t) == anchor(in.open)) {
as = "continue";
}
if (as != null) {
jumpAs.put(i, as);
consumed.merge(ls.get(t).label(), 1, Integer::sum);
}
}
}
/** "loop {" directly followed by "if (c) break" becomes "while (!c) {". */
private void whileLoops() {
for (int i = 0; i + 1 < lines.size(); i++) {
Line a = lines.get(i), b = lines.get(i + 1);
if (a.text.equals("loop {")
&& b.depth == a.depth + 1
&& b.text.startsWith("if (")
&& b.text.endsWith(") break")) {
String c = b.text.substring(4, b.text.length() - 7);
String w = c.startsWith("!(") && c.endsWith(")") && balanced(c.substring(2, c.length() - 1))
? c.substring(2, c.length() - 1)
: c.startsWith("!") && !c.contains(" ") ? c.substring(1) : "!(" + c + ")";
lines.set(i, new Line(b.offset, a.depth, "while (" + w + ") {"));
lines.remove(i + 1);
}
}
}
/** "} else {" + a lone "if" up to the else's end becomes "} else if (…) {"; an empty then-block is negated. */
private void elseIfChains() {
for (int i = 0; i + 1 < lines.size(); i++) {
Line a = lines.get(i), b = lines.get(i + 1);
if (a.text.startsWith("if (")
&& a.text.endsWith(") {")
&& b.text.equals("} else {")
&& b.depth == a.depth) {
String c = a.text.substring(4, a.text.length() - 3);
String n = c.startsWith("!(") && c.endsWith(")") && balanced(c.substring(2, c.length() - 1))
? c.substring(2, c.length() - 1)
: "!(" + c + ")";
lines.set(i, new Line(a.offset, a.depth, "if (" + n + ") {"));
lines.remove(i + 1);
}
}
for (int i = 0; i + 1 < lines.size(); i++) {
Line a = lines.get(i), b = lines.get(i + 1);
if (!a.text.equals("} else {")
|| b.depth != a.depth + 1
|| !b.text.startsWith("if (")
|| !b.text.endsWith(" {")) {
continue;
}
int d = a.depth;
int close = -1;
boolean ok = true;
for (int k = i + 2; k < lines.size(); k++) {
Line x = lines.get(k);
if (x.depth <= d) {
close = x.depth == d && x.text.equals("}") ? k : -1;
break;
}
if (x.depth == d + 1 && !x.text.startsWith("}")) {
ok = false;
break;
}
}
if (!ok || close < 0 || !lines.get(close - 1).text.equals("}") || lines.get(close - 1).depth != d + 1) {
continue;
}
lines.set(i, new Line(b.offset, d, "} else " + b.text));
lines.remove(i + 1);
close--;
lines.remove(close - 1);
close--;
for (int k = i + 1; k < close; k++) {
Line x = lines.get(k);
lines.set(k, new Line(x.offset, x.depth - 1, x.text));
}
}
}
private static boolean nests(int a0, int a1, int b0, int b1) {
@@ -339,8 +661,7 @@ public final class StratPseudo {
return d == 0;
}
private void instr(
int i, StratListing.Line l, Map<Integer, Block> ifAt, Map<Integer, Block> loopEnd, Set<Integer> elseJumps) {
private void instr(int i, StratListing.Line l) {
int op = l.op();
String mn = mn(l);
String a = operand(l);
@@ -388,18 +709,29 @@ public final class StratPseudo {
push(off, "!" + pop());
return;
}
boolean cond = op >= 0x12 && op <= 0x15;
if (cond) {
if (condJump(op)) {
String e = strip(pop());
boolean neg = op == 0x13 || op == 0x15;
String yield = op <= 0x13 ? " /*yield*/" : "";
int b0 = begin(off);
flush();
Block lp = loopEnd.get(i);
String jumpIf = neg ? e : "!(" + e + ")";
if (ifAt.containsKey(i)) {
out(b0, "if (" + (neg ? "!(" + e + ")" : e) + ")" + yield + " {");
depth++;
} else if (lp != null) {
depth--;
Line last = lines.isEmpty() ? null : lines.get(lines.size() - 1);
if (last != null && last.text.equals("do {") && last.depth == depth) {
lines.set(lines.size() - 1, new Line(b0, depth, "do {} while (" + jumpIf + ")" + yield));
} else {
out(b0, "} while (" + jumpIf + ")" + yield);
}
} else if (jumpAs.containsKey(i)) {
out(b0, "if (" + jumpIf + ") " + jumpAs.get(i) + yield);
} else {
out(b0, "if (" + (neg ? e : "!(" + strip(e) + ")") + ") goto " + a + yield);
out(b0, "if (" + jumpIf + ") goto " + a + yield);
}
return;
}
@@ -412,15 +744,26 @@ public final class StratPseudo {
}
depth--;
out(-1, "}");
} else if (elseJumps.contains(i)) {
} else if (elseJumps.contains(i) || silentJumps.contains(i)) {
if (op == 0x2b) {
out(off, "yield");
}
} else if (jumpAs.containsKey(i)) {
out(off, (op == 0x2b ? "yield; " : "") + jumpAs.get(i));
} else {
out(off, (op == 0x2b ? "yield; " : "") + "goto " + a);
}
return;
}
if (op == StratOps.SWITCH && switchAt.containsKey(i)) {
String v = strip(pop());
int b0 = begin(off);
flush();
switchDepth.put(i, depth);
out(b0, "switch (" + v + ") { // " + a);
depth++;
return;
}
if (op == 0x11 || op == 0x10) {
flush();
out(off, "call " + a + (op == 0x10 ? " /*yield*/" : "") + "()");
@@ -37,30 +37,30 @@
0x23 error_0x23 - 0 0 Prints "stCollide_AlienVarAddress" not-implemented error; unused. high stCollide_AlienVarAddress
0x24 pushTarget2Prop prop 0 1 Pushes property n of the second target strat (DAT_006D9E34, set by 0xca), 0 if none; unused. medium stTarget_AlienVar
0x25 pushTarget2PropAddr prop 0 1 Pushes address of target2 property n (0 if none); unused. medium stTarget_AlienVarAddress
0x26 pushBossProp prop 0 1 Pushes property n of the strat in DAT_006D9E24 (set by 0xef), no null check; unused. low stBoss_AlienVar
0x27 pushBossPropAddr prop 0 1 Pushes address of property n of the DAT_006D9E24 strat; unused. low stBoss_AlienVarAddress
0x28 pushDialogProp prop 0 1 Pushes property n of the strat in DAT_006D9E2C (set by 0x113, used by NPC/puzzle strats), 0 if none. low stDialog_AlienVar
0x29 pushDialogPropAddr prop 0 1 Pushes address of property n of the DAT_006D9E2C strat (0 if none); unused. low stDialog_AlienVarAddress
0x26 pushBossProp prop 0 1 Pushes property n of the boss strat DAT_006D9E24 (set by 0xef isBoss) via the getter FUN_00550E60; no null check; unused in the levels. high stBoss_AlienVar
0x27 pushBossPropAddr prop 0 1 Pushes the address of property n of the boss strat DAT_006D9E24 (setter FUN_005509F0 returns the address); no null check; unused. high stBoss_AlienVarAddress
0x28 pushDialogProp prop 0 1 Pushes property n of the strat registered by 0x113 (DAT_006D9E2C: the NPC/puzzle/door that a cut-scene is about), 0 if none. WAD use: Camera and CutSceneCameraDirectional read its x/y/z/rotY to look at it (1585 of 1678 uses). high stDialog_AlienVar
0x29 pushDialogPropAddr prop 0 1 Pushes the address of property n of the DAT_006D9E2C strat (0 if none); unused. high stDialog_AlienVarAddress
0x2a shadow imm 0 0 arg!=0 sets, 0 clears +0xE8 bit 0x20 (shadow on; shadow pass requires it plus size from 0x59 and texture from 0x5a). medium stShadow
0x2b jmpYield rel 0 0 Jumps to next+arg*4 and yields this frame (loop back-edge / wait one frame). high -
0x2c nop_0x2c - 0 0 No-op (shared empty handler); unused. high -
0x2d nop_0x2d - 0 0 No-op (shared empty handler); unused. high -
0x2e nop_0x2e - 0 0 No-op (shared empty handler); unused. high -
0x2f flagEC10000 imm 0 0 arg!=0 sets, 0 clears +0xEC bit 0x10000 (with 0x8000 both set the strat skips player-distance updates; property 0xf2 reads it); meaning unclear. low -
0x2f flagEC10000 imm 0 0 arg!=0 sets, 0 clears +0xEC bit 0x10000 = "freeze when off-camera": the camera pass (FUN_00436190) sets +0xEC 0x8000 when the strat is outside the camera range; with both bits set the frame loop (0x54c603) and the strat-vs-strat collision loop (0x54cc1a) skip it. Used by ~1350 strats (pickups, triggers, enemies). medium -
0x30 trackPlayer imm 0 0 If no player yet: re-executes itself next frame (pc-=4, yield); else arg sets/clears +0xEC bit 0x4000 = update distance to player each frame (+0x148). medium -
0x31 flagEC2 imm 0 0 arg sets/clears +0xEC bit 2 (checked by collision/floor code via +0xC0, readable as property 0x1f). low -
0x32 flagE8_80000000 imm 0 0 arg sets/clears +0xE8 bit 31; unused. low -
0x33 flagEC100 imm 0 0 arg sets/clears +0xEC bit 0x100 (tested in the collision response, skips floor handling; used by drops/projectiles/rollercoaster). low -
0x34 flagEC200000 imm 0 0 arg sets/clears +0xEC bit 0x200000 (used by NPC helpers/canoe/llama). low -
0x31 flagEC2 imm 0 0 arg sets/clears +0xEC bit 2 = "others can stand on / touch me": in the strat collision loop (0x54cc96) a strat touching a strat with this bit gets +0xE8 0x400000 (and 0x800000 when it moves) = properties 32/33; also readable as property 0x1f. Set by Llama, Coin, Jar, EnemyCoin, RubyIdol. medium -
0x32 flagE8_80000000 imm 0 0 arg sets/clears +0xE8 bit 31; no reader of the bit found in the exe (scan of all +0xE8 flag tests); unused in the levels. medium -
0x33 flagEC100 imm 0 0 arg sets/clears +0xEC bit 0x100; the collision response (0x4044f7) skips the floor/slope handling when it is set. Used by LlamaSpit, DropObject, RollerCoaster, KuscoTurtle (objects that move by script, not by floor physics). medium -
0x34 flagEC200000 imm 0 0 arg sets/clears +0xEC bit 0x200000; no direct test of the bit found in the exe (all +0xEC flag tests scanned). Used by Llama, RiverCroc, RiverLog, Rock, river NPCs (river/canoe level objects). low -
0x35 nop_0x35 - 0 0 No-op (shared empty handler); unused. high -
0x36 nop_0x36 - 0 0 No-op (shared empty handler); unused. high -
0x37 popPlayerProp playerProp 1 0 Pops value and stores it into player property n (discarded if no player). high -
0x38 popCameraProp prop 1 0 Pops value into camera strat property n (discarded if none). high -
0x39 popTargetProp prop 1 0 Pops value into camera target (DAT_006D9E30) property n (discarded if none). medium -
0x3a popTarget2Prop prop 1 0 Pops value into DAT_006D9E34 strat property n (discarded if none); unused. medium -
0x3b popDialogProp prop 1 0 Pops value into DAT_006D9E2C strat property n (discarded if none); unused. low -
0x3b popDialogProp prop 1 0 Pops a value into property n of the DAT_006D9E2C strat (discarded if none); unused. high -
0x3c error_0x3c - 0 0 Prints "stCollide_AlienVar_Equals" not-implemented error; unused. high -
0x3d popBossProp prop 1 0 Pops value into DAT_006D9E24 strat property n (no null check); unused. low -
0x3d popBossProp prop 1 0 Pops a value into property n of the boss strat DAT_006D9E24 (no null check); unused. high -
0x3e popParentVar var 1 0 Pops value into variable n of the +0xC strat. high -
0x3f popParentProp prop 1 0 Pops value into property n of the +0xC strat; unused. high -
0x40 popProp prop 1 0 Pops value into own property n (via FUN_005509F0 address). high -
@@ -134,15 +134,15 @@
0x84 collHeight - 0 0 Unimplemented: logs "stCollheight" only. high stCollHeight
0x85 collExtent - 1 0 pops extent; stores its low 16 bits raw into short +0xC4. medium stCollextent
0x86 collView - 0 0 Unimplemented: logs "stCollview" only. high stCollView
0x87 nop_0x87 - 0 0 Shared no-op handler (0x426500); unused. Slot of Croc2 stCollPoints. low stCollPoints
0x88 nop_0x88 - 0 0 Shared no-op handler (0x426500); unused. Slot of Croc2 stCollSetPoint. low stCollSetPoint
0x87 nop_0x87 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollPoints. high stCollPoints
0x88 nop_0x88 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollSetPoint. high stCollSetPoint
0x89 createTrigger - 0 0 Sets flag 0x40 in +0xE8 (strat becomes a trigger/touchable, used by pickups like Health, Carrot). medium stCreateTrigger
0x8a killTrigger - 0 0 Clears flags 0x40 and 0x4000000 in +0xE8 (undoes createTrigger). medium stKillTrigger
0x8b wait - 1 0 pops frames (fixed, >>12) into wait counter short +0xFE and sets flag 0x2 in +0xE8 (strat sleeps until the counter runs out). high stWait
0x8c hold - 0 0 Sets flags 0x12 (0x2 wait + 0x10 stopped) in +0xE8: suspends the strat indefinitely until released. medium stHold
0x8d release - 0 0 Unimplemented: logs "stRelease" only. high stRelease
0x8e remove - 0 0 Sets flags 0x6 in +0xE8 (yield + kill); same handler as 0xb4 end. high stRemove
0x8f nop_0x8f - 0 0 Shared no-op handler (0x426500); unused. Slot of Croc2 stMapRemove. low stMapRemove
0x8f nop_0x8f - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stMapRemove. high stMapRemove
0x90 mapAdd - 0 0 Unimplemented: logs "stMapadd" only. high stMapAdd
0x91 mapReplace - 0 0 Unimplemented: logs "stMapreplace" only. high stMapReplace
0x92 activated - 0 0 Sets bit 0x8 in byte +0x44 of the strat's map-instance record (+0xF0): marks the map object as activated (persistent state). medium stActivated
@@ -155,8 +155,8 @@
0x99 soundStop - 1 0 pops sound channel; stops that channel if owned by this strat, or (channel <0/invalid) all sounds of this strat. high stSoundStop
0x9a musicPlay - 1 0 pops music track index (fixed, >>12); if different from the current track (0x586430) starts that ASF music stream (FUN_00546b70). high stCdPlay
0x9b streamPlay - 0 0 Unimplemented: logs "stStreamPlay" only. high -
0x9c speechSetPos - 1 0 pops a value (fixed, >>12) into global 0x6DA2B8 (meaning unknown, likely speech volume/range) and moves the speech/voice stream (handle 0x6D94B4, started by '#' text) to this strat's position. low -
0x9d discard_0x9d - 1 0 pops a value (>>12) and passes it to the no-op debug print; effectively drop. Croc2 slot midiVolume/cdFade. low stMidiVolume
0x9c speechSetPos - 1 0 Pops a value into global 0x6DA2B8 (written only, never read: no effect) and moves the speech/voice channel (DAT_006D94A0, started by "#" texts) to this strat's position (+0x30..0x38), so the voice plays from the speaker. high -
0x9d discard_0x9d - 1 0 Pops a value (>>12) and passes it to the empty function 0x426500: a pure drop on PC. Croc2 slot midiVolume/cdFade. high stMidiVolume
0x9e speechStop - 0 0 Stops the current speech/voice stream (handle 0x6D94B4) if one is playing and resets the handle to -1. high stMidiStop
0x9f streamQueue - 0 0 Unimplemented: logs "stStreamQueue" only. high stMidiQueue
0xa0 isLight - 0 0 Unimplemented: logs "stIsLight" only. high stIsLight
@@ -176,7 +176,7 @@
0xae cos - 1 1 pops angle; pushes cos (table 0x574318 offset by quarter turn 0x400). high stCos
0xaf not - 1 1 pops x; pushes 1 if x==0 else 0. high stNot
0xb0 pop - 1 0 Drops the top stack value (stack pointer +0x104 -= 4). high stPop
0xb1 stkCmp imm 0 1 Peeks (does not pop) the top stack entry, dereferences it as a pointer and pushes imm minus the pointed-to int (compare helper). low stStkCmp
0xb1 stkCmp imm 0 1 Reads one inline word imm (pc += 4), peeks the top stack entry (no pop), dereferences it as an int pointer and pushes imm − *top. Unused in the levels. high stStkCmp
0xb2 address addr 0 1 Patches itself into 0x45 (push immediate) after resolving the operand to an absolute address (negative = import table 0x6DA324, else + STPC base 0x6D9DBC) and pushes it (code/model/anim address). high stAddress
0xb3 return - 1 0 pops code address and jumps there (pc = popped). high stReturn
0xb4 end - 0 0 Sets flags 0x6 in +0xE8 (yield + kill): ends the strat. Same handler as 0x8e. high stEndStrat
@@ -186,36 +186,36 @@
0xb8 bitwiseAnd - 2 1 pops b, a; pushes a & b. high stBitwiseAnd
0xb9 discard_0xb9 - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 ext-var slots). medium -
0xba discard_0xba - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused. medium -
0xbb nop_0xbb - 0 0 Shared no-op handler (0x426500); unused. low -
0xbb nop_0xbb - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. high -
0xbc notEqual - 2 1 pops b, a; pushes 1 if a != b else 0. high stNotEqual
0xbd greaterEqual - 2 1 pops b, a; pushes 1 if a >= b else 0. high stGreaterEqual
0xbe lessEqual - 2 1 pops b, a; pushes 1 if a <= b else 0. high stLessEqual
0xbf rnd - 1 1 pops n; pushes random value (two rand() halves) modulo n, 0 if n==0; result is in the same units as n (fixed in, fixed out). high stRnd
0xc0 loseHeart - 0 0 In game state 3, unless player invulnerable (0x584710 bit 0): health 0x584E70 -= 1.0, or if already 0 sets 0x584638 = 2 (player dies); refreshes HUD (FUN_004268c0(1)). high stLoseHeart
0xc1 nop_0xc1 - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stResettoCheckPoint. low stResettoCheckPoint
0xc1 nop_0xc1 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stResettoCheckPoint. high stResettoCheckPoint
0xc2 forceCollision - 0 0 Runs world collision for this strat (FUN_00403020 with level geometry): updates floor/ceiling/wall info in the collision block +0xC0 (ground height, surface type). medium stForceCollision
0xc3 turnFromPlayerY - 1 0 pops max turn step (angle, 0x1000000 = full turn); turns yaw +0x24 toward the direction from the player (0x6D9E1C) to this strat, i.e. away from the player, by at most step. medium stTurnFromPlayerY
0xc4 rumble - 2 0 Shared stub 0x555090: pops two values and discards them (PSX pad rumble/vibrate not implemented on PC). low stRumble
0xc5 suspendIfTooFar - 1 0 Shared stub 0x554fb0: pops one value (likely distance) and discards it. low stSuspendIfTooFar
0xc4 rumble - 2 0 Shared stub 0x555090: pops two values and discards them (PSX pad rumble is not implemented on PC). high stRumble
0xc5 suspendIfTooFar - 1 0 Shared stub 0x554fb0: pops one value (distance) and discards it – no effect on PC (op 0x2f gives the off-camera freeze instead). high stSuspendIfTooFar
0xc6 collisionBone - 2 0 pops value (short), then bone id (fixed, >>12); sets/adds entry {id, value} in the bone table at +0xC8 (count +0xC6). medium stCollisionBone
0xc7 useBone - 1 0 pops one value (ignored); if a model/anim is set (+0x14) rebuilds the rotation matrix and stores a transformed point (matrix * local vector + translation +0x94) into +0xD8/+0xDC/+0xE0. low stUseBone
0xc7 useBone - 1 0 Pops bone n (>>12); if the strat has an animated model (+0x14) it takes bone n of the current animation frame (+0x110; anim+0x1C frame table, 64 bytes per bone, offset +0x30..0x38 = bone position), rotates it by the strat's rotation matrix and adds the position: world point in +0xD8/+0xDC/+0xE0 = properties boneX/Y/Z. (The Ghidra C output drops the bone index; read from the assembly.) Used to spawn effects at hands/mouths. high stUseBone
0xc8 isCamera - 0 0 Registers this strat as the camera (global 0x6D9E28) and sets flag 0x8000000 in +0xE8. high stIsCamera
0xc9 lookAtMe - 0 0 Sets camera look-at target global 0x6D9E30 = this strat. high stLookatme
0xca lookAtMe2 - 0 0 Sets secondary look-at global 0x6D9E34 = this strat. Unused. medium stLookatme
0xcb pushCamera - 0 0 Pushes current camera (0x6D9E28) and look-at (0x6D9E30) pair onto the camera stack 0x6D9DCC (index 0x6D9E40). high stPushCamera
0xcc popCamera - 0 0 If the camera stack is not empty, restores camera and look-at from it and increments camera-change counter 0x6D9D34. high stPopCamera
0xcd nop_0xcd - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stResetCamerapos. low stResetCamerapos
0xcd nop_0xcd - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stResetCamerapos. high stResetCamerapos
0xce gainHeart - 0 0 Health 0x584E70 += 1.0 if below max 0x584E6C; refreshes HUD (FUN_004268c0(1)). high stGainHeart
0xcf nop_0xcf - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stGainHeartPot. low stGainHeartPot
0xcf nop_0xcf - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainHeartPot. high stGainHeartPot
0xd0 addInv imm 0 0 Adds inline item to inventory via FUN_00426860(item,1,1,0,0), which is a stub returning 0 on PC (no effect). medium stAddInv
0xd1 nop_0xd1 - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stGainCrystal. low stGainCrystal
0xd2 discard_0xd2 - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). low stCutscene
0xd1 nop_0xd1 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainCrystal. high stGainCrystal
0xd2 discard_0xd2 - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). high stCutscene
0xd3 inventory imm 0 1 Queries inline item via stub FUN_00426860 and pushes result<<12 (always 0 on PC). medium stInventory
0xd4 debugName imm,count 0 0 Strat name: skips the first inline word, reads the byte length of the following name string, sets the name pointer (+0x18) to that string and jumps past it. high stDebugname
0xd5 soundPlay - 1 0 pops sound id; plays it on this strat (FUN_00546170); outside game states 3/4 first sets game state 0x5846E4 = 5. high stSoundPlay
0xd6 soundPlayAss - 1 1 pops sound id; in game states 3/4 plays it and pushes the channel (raw int), else pushes -1. high stSoundPlayass
0xd7 soundAddress sound 0 1 Pushes the inline sound id (same handler as 0x45 push immediate). high stSoundAddress
0xd8 nop_0xd8 - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stOnground/stObjectFallslow. low stOnground
0xd8 nop_0xd8 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stOnground/stObjectFallslow. high stOnground
0xd9 collisionOffset - 3 0 pops z, y, x; stores low 16 bits into shorts +0xD0, +0xCE, +0xCC (collision centre offset). high stCollisionOffset
0xda abs - 1 1 pops x; pushes |x|. high stAbs
0xdb pickup imm 0 0 inline flag: nonzero sets bit 0x200 in +0xEC, zero clears it (strat can be picked up/carried). medium stPickup
@@ -241,7 +241,7 @@
0xef isBoss - 0 0 registers this strat as the boss: DAT_006d9e24 = self high stIsboss
0xf0 topSay text 0 0 shows dialog text (inline text index) in the top text box: sets DAT_006d9858=1 and formats via FUN_0040d4c0 high stTopsay
0xf1 getParentPos - 0 0 copies parent's (+0xC) orientation+position block (+0x20..+0x3C, 8 words) into this strat high stGetparentpos
0xf2 setDrawMode - 1 0 pops mode (raw int): 1 sets flag 0x80000 in +0xEC, 2 sets 0x100000, other clears both (exact effect of the bits unknown); unused low -
0xf2 setDrawMode - 1 0 Pops mode (raw int): 1 sets +0xEC 0x80000, 2 sets 0x100000, anything else clears both; no reader of these two bits found in the exe; unused in the levels. low -
0xf3 afterBoss - 0 0 if no boss (DAT_006d9e24) rewinds pc and yields (waits); else relinks this strat in the process list right after the boss high stAfterboss
0xf4 afterPlayer - 0 0 if no player (DAT_006d9e1c) rewinds pc and yields (waits); else relinks this strat in the process list right after the player high stAfterPlayer
0xf5 beforePlayer - 0 0 if no player rewinds pc and yields (waits); else relinks this strat in the process list right before the player high stBeforePlayer
@@ -262,7 +262,7 @@
0x104 moveDownQ - 1 0 pops distance (fixed); y(+0x34) -= d high stMoveDownq
0x105 forcePlayerDist - 0 0 recomputes the cached squared player distance +0x148 (coords >>6) and clears +0x1C; 0x200000 if no player; unused high stForcePlayerdist
0x106 nop_0x106 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stShadeType?
0x107 setAnimSpeed - 1 0 pops value and stores its low 16 bits (raw, not >>12) in halfword +0x114 (animation timing field, zeroed when the anim frame is set) low stSetanimSpeed?
0x107 setAnimSpeed - 1 0 Pops the animation speed (20.12, 1.0 = one animation frame per game frame) into halfword +0x114; while animating (+0xE8 bit 8) the frame loop adds it to the sub-frame counter +0x116 and advances the frame (+0x110) each time it passes 1.0 (0x54c630). WAD use: 1.0, 0.25, 1.5 and computed values. high stSetanimSpeed?
0x108 nop_0x108 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stCheckleveldoor?
0x109 nop_0x109 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high -
0x10a nop_0x10a - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high -
@@ -329,7 +329,7 @@
0x147 nop_0x147 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high -
0x148 gotoLevel4_1 - 0 0 Hardcoded level change: next level (0x584EB4/EB8/EBC/EC0) = 4/1/1/0, previous level (0x585008..14) = 4/1/4/0, game state 0x584638 = 7 (load level). medium -
0x149 nop_0x149 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high -
0x14a hudReset2 - 0 0 Calls HUD/panel init FUN_004268c0(2): re-initialises the on-screen counter panels (coins, lives, etc.) for the current level. low -
0x14a hudReset2 - 0 0 Calls FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. high -
0x14b setGlobal5fd086 imm 0 0 Stores the low 16 bits of the inline word in global 0x5FD086 (never read elsewhere; dead leftover). medium -
0x14c removeCollPoint - 1 0 pops id (fixed, >>12); removes the collision point with that id from the strat's collision point list (+0xC8 array of 4-byte entries, count +0xC6). Counterpart of 0xc6 (add/set collision point). medium stSetCollPoint (inverse)
0x14d setFlagEC_400000 flags 0 0 Inline word: nonzero sets, zero clears bit 0x400000 of strat flags2 (+0xEC); the bit selects which height constant own property 0xF2 returns. high -
@@ -347,7 +347,7 @@
0x159 nop_0x159 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high -
0x15a setPosToPlayer - 0 0 Copies the player's position x/y/z (+0x30..+0x38) and +0x3C into the strat (player strat = 0x6D9E1C). high -
0x15b setPosToPlayerXZ - 0 0 Copies the player's x and z (+0x30, +0x38) into the strat; y unchanged. high -
0x15c setShadowHeight - 1 0 pops v; stores it in strat +0x14C, used by the shadow renderer as the shadow/ground height override (-0x1000 = use default 0x586424). low -
0x15c setShadowHeight - 1 0 Pops v into +0x14C, the water/floor height limit used by the shadow pass (FUN_00555140: -0x1000 = use the camera-cell floor height DAT_00586424, prop camFloorHeight); the shadow is skipped when the floor under the strat lies below it. One use (SinkingStatuePad). medium -
0x15d packRgb - 3 1 pops c (top), b, a (fixed 0..255); pushes (c<<16)|(b<<8)|a as a raw packed colour int (push order r,g,b gives 0x00BBGGRR). high -
0x15e trailCreate - 2 1 pops vertB (top), vertA (fixed vertex indices of the strat's model/anim frame); allocates a free trail/ribbon slot (0x5FD180, 0x42C bytes each) owned by this strat and pushes its handle (0 if none free). medium -
0x15f trailAddPoint - 3 0 pops colourB (top), colourA, trail handle; transforms the two trail vertices of the current anim frame into world space and appends them to the trail history (20 points) with the given colours; no-op if handle 0. medium -
@@ -393,8 +393,8 @@
0x187 emitterInit - 7 0 pops colour/texture (top, raw), z, y, x (vector/offset), type (fixed 1..0x2B preset, or >=0x1000 type flags), size/speed (fixed, 0x1000 default), slot (0..2, >=3 = extra slot +0x153); (re)initialises the strat's particle emitter in that slot (records at 0x5FE230, 0x34 bytes; texture/colour stored per type). medium stSpawnparticle
0x188 emitParticles - 3 0 pops yStep (top, fixed), count, slot; emits count particles from the slot's emitter at own position with random XZ velocity, each raised by yStep. medium -
0x189 randSigned - 1 1 pops n; pushes a random value in (-n, n) ((rand32 - rand32) % n, same fixed scale as n). high -
0x18a emitterSetParams - 5 0 pops e (top), d, c, b, slot; sets emitter slot params: +0x254 = b (rate/speed), +0x24A = c>>12 (life); if e==0 +0x25C = (d>>12)*32-32 else +0x246 = e, +0x23C = d. Allocates the slot if empty. low -
0x18b emitterSetColour - 5 0 pops colour (top, raw packed, see 0x15d), b, g, r, slot; sets emitter colour (+0x250) and fade/alpha (+0x240..243): if r has no fraction it is an alpha value only, else r,g,b are per-channel fade values. low -
0x18a emitterSetParams - 5 0 Pops e (top), d, c, b, slot (>>12): pending particle settings of this strat (slot 0–2 → record index at strat+0x150+slot, ≥3 → +0x153, record allocated on demand in the 0x34-byte table 0x5FE23C): b = size scale (1.0 = default), c>>12 = extra lifetime in frames, e≠0: +0x246 = e (drift), +0x23C = d; e==0: +0x25C = (d>>12)*32-32 (sprite size). spawnParticle consumes and resets the +0x153 record; same as spawnParticle type 0x19. medium -
0x18b emitterSetColour - 5 0 Pops colour (top, raw packed, see 0x15d), b, g, r, slot: pending particle colour for the next particle(s) of this strat (record as 0x18a; same as spawnParticle type 0x1e): +0x250 = colour; r without fraction = alpha r>>12 (7 bits), else r,g,b>>12 = per-channel fade, +0x243 = 0x80; alpha 0 = 0xFF/lifetime. medium -
0x18c emitParticlesStill - 3 0 pops yStep (top, raw fixed), count, slot; emits count particles from the slot's emitter at own position without velocity, stacked by yStep. medium -
0x18d stubPop2_0x18d - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high -
0x18e stubPop2_0x18e - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high -
@@ -424,7 +424,7 @@
0x1a6 emitterKill - 1 0 pops slot (fixed); frees the strat's particle emitter in that slot (+0x150+slot = 0xFF) and resets the record. high -
0x1a7 stubPush0_0x1a7 - 0 1 Stub FUN_0054e880: pushes 0. high -
0x1a8 processFirst - 0 0 Moves self to the head of the strat update list (0x6D9E38) if not already there. high -
0x1a9 setRain - 4 0 pops colour (top, raw -> 0x6D9BE8), b (->0x578EF4), a2 (->0x6D9BF4), a1 (->0x578EF0) (all >>12); if the three counts/params are 0 rain is stopped and cleared, else rain is enabled (0x6D9BE4 = 1). low -
0x1a9 setRain - 4 0 Weather: pops texture (top, raw tex index → 0x6D9BE8, read by the rain renderer), windZ (>>12 → 0x578EF4), c (>>12 → 0x6D9BF4, only used for the off test), windX (>>12 → 0x578EF0); each raindrop restarts with velocity (windX, -256, windZ) per frame. windX, c, windZ all 0 = rain off (drops cleared, 0x6D9BE4 = 0), else on. Rain type: setRain(v0, v1, v2, tex:768) with params 16.0, 0, 16.0. medium -
0x1aa stubPop1Push0_0x1aa - 1 1 Stub FUN_005550b0: pops 1 value and pushes 0. high -
0x1ab stubPop2_0x1ab - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high -
0x1ac stubPop1Push0_0x1ac - 1 1 Stub FUN_005550b0: pops 1 value and pushes 0. high -
Can't render this file because it contains an unexpected character in line 36 and column 30.
@@ -106,6 +106,86 @@ class StratEditsTest {
assertEquals(wad2.length, WadBuilder.build(r.scene()).length);
}
static int entry(Project p, String type) {
return p.scene().catalog().type(type).variant(0).code();
}
static StratEdits.CustomType withSource(StratEdits.CustomType t, String src) {
return new StratEdits.CustomType(t.name(), t.basedOn(), t.entryLabel(), src, t.proto());
}
/** Edit and delete custom types: codes of placed objects follow, undo/redo, save/reload. */
@Test
void editAndDeleteCustomTypes() throws Exception {
Bases cd = cd();
StratEdits.init();
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "types", "t1l1m001"), cd), cd);
History h = new History(p.scene());
StratEdits.CustomType a = StratEdits.cloneOf(p.scene(), "Coin", "CoinA");
h.execute(TypeEdits.create(p, a));
StratEdits.CustomType b = StratEdits.cloneOf(p.scene(), "Coin", "CoinB");
h.execute(TypeEdits.create(p, b));
h.execute(Edits.placeStrat(p.scene(), "CoinA", 0, 30, 2, 30, 0.0));
h.execute(Edits.placeStrat(p.scene(), "CoinB", 0, 31, 2, 30, 0.0));
h.execute(Edits.placeStrat(p.scene(), "CoinB", 0, 32, 2, 30, 0.0));
int n = p.scene().strats().size();
List<Strat> placed = List.copyOf(p.scene().strats().subList(n - 3, n));
int b0 = entry(p, "CoinB");
assertEquals(
List.of(1, 2),
List.of(
StratEdits.users(p.scene(), 0).size(),
StratEdits.users(p.scene(), 1).size()));
// edit A: three more words, faster spin → B's entry moves by 12 bytes
String src = a.source()
.replace("push 5.625deg", "push 90deg")
.replace("\nCoinA:\n", "\nCoinA:\n nop_0x00 0\n nop_0x00 0\n nop_0x00 0\n");
assertTrue(src.contains("nop_0x00"), src);
assertTrue(StratEdits.checkTypes(p.scene(), List.of(withSource(a, src), b), 0, null) == null);
h.execute(TypeEdits.edit(p, 0, withSource(a, src)));
assertEquals(b0 + 12, entry(p, "CoinB"));
assertEquals(entry(p, "CoinB"), placed.get(1).code());
assertEquals(entry(p, "CoinB"), placed.get(2).code());
assertEquals("CoinB", placed.get(2).type());
assertEquals(entry(p, "CoinA"), placed.get(0).code());
assertEquals(0x400000, spin(p, "CoinA").value());
assertTrue(StratEdits.records(p).types().get(0).source().contains("nop_0x00"));
assertTrue(StratEdits.analysis(p.scene()).code().errors.isEmpty());
h.undo();
assertEquals(b0, entry(p, "CoinB"));
assertEquals(b0, placed.get(1).code());
assertEquals(0x40000, spin(p, "CoinA").value());
h.redo();
assertEquals(b0 + 12, placed.get(2).code());
// delete A: its object goes, B moves to A's place
int a0 = entry(p, "CoinA");
byte[] before = WadBuilder.build(p.scene());
h.execute(TypeEdits.delete(p, 0));
assertTrue(p.scene().catalog().type("CoinA") == null);
assertTrue(!p.scene().strats().contains(placed.get(0)));
assertEquals(1, StratEdits.records(p).types().size());
assertEquals(entry(p, "CoinB"), placed.get(1).code());
assertTrue(entry(p, "CoinB") < b0, "CoinB moved down to the start of the custom block");
assertEquals("CoinB", nameAt(WadBuilder.build(p.scene()), placed.get(1).code()));
Project q = ProjectIO.read(ProjectIO.write(p, cd), cd);
assertTrue(q.warnings().isEmpty(), q.warnings().toString());
assertArrayEquals(
WadBuilder.build(p.scene()), WadBuilder.build(q.scene()), "reloaded project builds the same WAD");
h.undo();
assertTrue(p.scene().strats().contains(placed.get(0)));
assertEquals(a0, placed.get(0).code());
assertEquals("CoinA", placed.get(0).type());
assertArrayEquals(before, WadBuilder.build(p.scene()), "undo restores the level");
// undo the creation of both types: no custom code left
while (h.canUndo()) {
h.undo();
}
assertTrue(StratEdits.records(p).types().isEmpty());
assertEquals(-1, StratEdits.firstCustomUnit(p.scene()));
}
@Test
void templateAssemblesAndVerifies() throws Exception {
Bases cd = cd();
@@ -0,0 +1,122 @@
package com.eliasmueller.llamamod.editor.tools.strats;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.format.strat.StratCompiler;
import java.nio.file.Path;
import java.util.List;
/**
* Builds the in-game test level for custom types and compiled scripts (not a unit test):
* {@code java -cp all.jar:build/classes/java/test ...StratsGameLevel OUT.llamalevel [rain]}. Village 1
* with, in front of the start: BigCoin (a Coin clone, edited afterwards to scale 3), Bobber (script:
* bobs up and down), Pulser (script: grows and shrinks), and a deleted type (its object is gone).
*/
public final class StratsGameLevel {
static final String BOBBER =
"""
// bobs one cell up and down and turns
strat Bobber {
alias base = v0
alias phase = v1
setModel(model:5)
base = self.y + 1.0
phase = 0
loop {
phase = phase + 4deg
self.y = base + sin(phase)
turnLeft(4deg)
yield
}
}
""";
static final String PULSER =
"""
// grows to 4x, shrinks back to 1x, forever
strat Pulser {
alias size = v0
setModel(model:5)
self.y = self.y + 1.0
size = 1.0
loop {
while (size < 4.0) {
size = size + 0.05
scale(size)
yield
}
do {
size = size - 0.05
scale(size)
yield
} while (size > 1.0)
}
}
""";
/** Rain with strong wind in x from the start (setRain: windX, unused, windZ, texture). */
static final String STORM =
"""
strat Storm {
setRain(200.0, 0, 0, tex:768)
loop {
yield
}
}
""";
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd"));
StratEdits.init();
if (a.length > 1 && a[1].equals("rain")) {
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "edit", "t1l2m001"), cd), cd);
History h = new History(p.scene());
StratEdits.CustomType coin = StratEdits.cloneOf(p.scene(), "Coin", "X");
h.execute(TypeEdits.create(
p, new StratEdits.CustomType("Storm", null, "Storm", StratCompiler.compile(STORM), coin.proto())));
Strat start = p.scene().playerStart();
h.execute(Edits.placeStrat(
p.scene(), "Storm", 0, start.x() / 4096.0, start.y() / 4096.0, start.z() / 4096.0, 0.0));
ProjectIO.save(p, Path.of(a[0]), cd);
System.out.println("saved " + a[0]);
return;
}
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "edit", "t1l1m001"), cd), cd);
History h = new History(p.scene());
StratEdits.CustomType big = StratEdits.cloneOf(p.scene(), "Coin", "BigCoin");
h.execute(TypeEdits.create(p, big));
StratEdits.CustomType temp = StratEdits.cloneOf(p.scene(), "Coin", "TempCoin");
h.execute(TypeEdits.create(p, temp));
for (String[] s : List.of(new String[] {"Bobber", BOBBER}, new String[] {"Pulser", PULSER})) {
String asm = StratCompiler.compile(s[1]);
h.execute(TypeEdits.create(p, new StratEdits.CustomType(s[0], null, s[0], asm, big.proto())));
}
Strat start = p.scene().playerStart();
double sx = start.x() / 4096.0, sy = start.y() / 4096.0, sz = start.z() / 4096.0;
double yaw = start.ry() * 2 * Math.PI / 4096.0;
double fx = Math.sin(yaw), fz = Math.cos(yaw), rx = Math.cos(yaw), rz = -Math.sin(yaw);
// a row between the start view and the VillagerHerder (34.5, 7.25, 66.1), 1 cell apart
String[] types = {"Coin", "BigCoin", "Bobber", "Pulser", "TempCoin"};
for (int i = 0; i < types.length; i++) {
h.execute(Edits.placeStrat(p.scene(), types[i], 0, 31.5 + i, 8.0, 64.6, 0.0));
}
// edit BigCoin after placing it: three times the size (the object follows the new code)
String src =
big.source().replace(" setModel\n", " setModel\n pushShort 3.0\n scale\n");
if (src.equals(big.source())) {
throw new IllegalStateException("no setModel line in the Coin source");
}
h.execute(
TypeEdits.edit(p, 0, new StratEdits.CustomType("BigCoin", "Coin", big.entryLabel(), src, big.proto())));
// delete TempCoin together with its object
h.execute(TypeEdits.delete(p, 1));
System.out.println("types: " + StratEdits.customNames(p));
ProjectIO.save(p, Path.of(a[0]), cd);
System.out.println("saved " + a[0] + " (" + p.scene().strats().size() + " objects, start " + sx + "," + sy + ","
+ sz + ")");
}
}
@@ -0,0 +1,119 @@
package com.eliasmueller.llamamod.editor.tools.strats;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.ui.EditorApp;
import com.eliasmueller.llamamod.editor.ui.EditorEnv;
import com.eliasmueller.llamamod.editor.ui.EditorWindow;
import com.eliasmueller.llamamod.format.strat.StratCompiler;
import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.Window;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.AtomicReference;
import javax.imageio.ImageIO;
import javax.swing.JDialog;
import javax.swing.SwingUtilities;
/**
* Screenshots of custom-type editing and the script view (not a unit test):
* {@code java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR ...StratsTour OUTDIR}.
*/
public final class StratsTour {
static EditorWindow w;
static Path out;
public static void main(String[] args) throws Exception {
out = Path.of(args[0]);
Files.createDirectories(out);
EditorEnv env = EditorEnv.resolve();
edt(() -> {
EditorApp.applyTheme("light");
w = EditorApp.open(env, "t1l1m001");
w.setBounds(0, 0, 1600, 1000);
return null;
});
sleep(4000);
for (int i = 0; i < 300 && edt(() -> w.busy() || w.scene() == null); i++) {
sleep(200);
}
sleep(2000);
edt(() -> {
var p = w.document().project();
StratEdits.CustomType big = StratEdits.cloneOf(w.scene(), "Coin", "BigCoin");
w.execute(TypeEdits.create(p, big));
String asm = StratCompiler.compile(StratsGameLevel.PULSER);
w.execute(TypeEdits.create(p, new StratEdits.CustomType("Pulser", null, "Pulser", asm, big.proto())));
Strat st = w.scene().playerStart();
double x = st.x() / 4096.0, y = st.y() / 4096.0, z = st.z() / 4096.0;
w.execute(Edits.placeStrat(w.scene(), "Pulser", 0, x + 1, y, z + 2, 0.0));
w.execute(Edits.placeStrat(w.scene(), "Pulser", 0, x - 1, y, z + 2, 0.0));
w.execute(Edits.placeStrat(w.scene(), "BigCoin", 0, x, y, z + 3, 0.0));
Strat last = w.scene().strats().get(w.scene().strats().size() - 2);
w.selection().set(last, null);
w.showPanel(StratsEditorPlugin.PANEL);
return null;
});
sleep(2500);
shot("strats_custom_panel");
SwingUtilities.invokeLater(() -> TypeDialog.showEdit(w, 1));
sleep(2500);
shot("strats_edit_script");
closeDialogs();
SwingUtilities.invokeLater(() -> TypeDialog.delete(w, 1));
sleep(2000);
shot("strats_delete_warning");
closeDialogs();
edt(() -> {
w.selection().set(w.scene().playerStart(), null);
return null;
});
sleep(2500);
shot("strats_view_llama");
System.out.println("tour done");
System.exit(0);
}
static void closeDialogs() throws Exception {
edt(() -> {
for (Window x : Window.getWindows()) {
if (x instanceof JDialog d && d.isShowing()) {
d.dispose();
}
}
return null;
});
sleep(800);
}
static void shot(String name) throws Exception {
Rectangle r = new Rectangle(0, 0, 1600, 1000);
ImageIO.write(
new Robot().createScreenCapture(r),
"png",
out.resolve(name + ".png").toFile());
System.out.println("shot " + name);
}
static void sleep(long ms) throws InterruptedException {
Thread.sleep(ms);
}
static <T> T edt(Callable<T> c) throws Exception {
AtomicReference<T> r = new AtomicReference<>();
AtomicReference<Exception> e = new AtomicReference<>();
SwingUtilities.invokeAndWait(() -> {
try {
r.set(c.call());
} catch (Exception x) {
e.set(x);
}
});
if (e.get() != null) {
throw e.get();
}
return r.get();
}
}
@@ -0,0 +1,86 @@
package com.eliasmueller.llamamod.format.strat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Script language → assembly → words → script view again. */
class StratCompilerTest {
static final String SPINNER =
"""
// spins up, bobs, then waits for the player
strat Spinner {
alias count = v0
count = 0
while (count < 10.0) {
turnLeft(4deg)
count += 1.0
yield
}
loop {
if (self.y > 2.0 && !self.animPlaying) {
self.y = self.y - 0.01
} else if (self.distToPlayerSqXZ < 4.0) {
break
} else {
call wobble
}
yield
}
do {
count -= 1.0
} while (count > 0)
}
sub wobble {
self.rotZ = sin(self.rotY) * 0.1
}
""";
@Test
void compilesStructuredCode() {
String asm = StratCompiler.compile(SPINNER);
assertEquals(SPINNER.stripTrailing() + "\n", StratCompiler.embeddedSource(asm));
assertEquals("Spinner", StratCompiler.firstStrat(SPINNER));
StratListing li = StratPseudoTest.listing(asm, "Spinner");
String view = StratPseudo.of(li).text();
assertFalse(view.contains("goto"), view);
assertTrue(view.contains("strat Spinner:"), view);
assertTrue(view.contains("while (v0 < 10.0) {\n turnLeft(4deg)\n v0 += 1.0\n yield\n}"), view);
assertTrue(view.contains("if ((self.y > 2.0) && !self.animPlaying) {"), view);
assertTrue(
view.contains(
"} else if (self.distToPlayerSqXZ < 4.0) {\n break\n } else {\n call L_00b4()"),
view);
assertTrue(view.contains("do {\n v0 -= 1.0\n} while (v0 > 0)"), view);
assertTrue(view.contains("self.rotZ = sin(self.rotY) * 0.1\nreturn"), view);
}
@Test
void numbersMatchTheAssembly() {
String asm = StratCompiler.compile("strat N { v0 = 1.5\n v1 = 100.0\n v2 = 90deg\n v3 = -3 }");
assertTrue(asm.contains("pushShort 1.5\n"), asm);
assertTrue(asm.contains("push 100.0\n"), asm);
assertTrue(asm.contains("push 90deg\n"), asm);
assertTrue(asm.contains("pushShort -3\n"), asm);
assertTrue(asm.trim().endsWith("end"), asm);
}
@Test
void reportsErrorsWithLines() {
StratCompiler.CompileException e = assertThrows(
StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n v0 = \n}"));
assertEquals(3, e.line);
e = assertThrows(
StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n turnLeft(1, 2)\n}"));
assertTrue(e.getMessage().contains("turnLeft takes 1 value"), e.getMessage());
e = assertThrows(StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n break\n}"));
assertEquals(2, e.line);
e = assertThrows(
StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n self.nope = 1\n}"));
assertTrue(e.getMessage().contains("unknown property nope"), e.getMessage());
}
}
@@ -0,0 +1,175 @@
package com.eliasmueller.llamamod.format.strat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
/** Structuring of the script view: loops, do-while, while, break/continue, switch, else-if. */
class StratPseudoTest {
/** A one-unit STPC (no models/animations) holding the assembled source. */
static StratListing listing(String src, String entry) {
StratAssembler.Result r =
StratAssembler.assemble(src, new StratAssembler.Layout(16, List.of(), List.of(), List.of()));
int[] w = r.words();
// header: 0 models, 0 animation sets, 1 unit
byte[] stpc = new byte[16 + 4 * w.length];
put(stpc, 0, 0);
put(stpc, 4, 0);
put(stpc, 8, 1);
put(stpc, 12, w.length);
for (int i = 0; i < w.length; i++) {
put(stpc, 16 + 4 * i, w[i]);
}
StpcImage img = new StpcImage(stpc, 0);
Set<Integer> roots = Set.of(16 + r.labels().get(entry));
return StratListing.of(img, StratCode.analyse(img, roots), 0, roots, StratListing.Names.NONE);
}
static void put(byte[] d, int o, int v) {
d[o] = (byte) v;
d[o + 1] = (byte) (v >> 8);
d[o + 2] = (byte) (v >> 16);
d[o + 3] = (byte) (v >> 24);
}
static final String SRC =
"""
Main:
push 0
popVar v0
top:
pushVar v0 ; while (v0 < 10)
push 10.0
lt
jz done
pushVar v0
push 5.0
eq
jz notFive
jmp done ; break
notFive:
pushVar v1
jz skip
jmp top ; continue
skip:
pushVarAddr v0
inc
jmpYield top
done:
again:
pushVar v2 ; do … while (v2)
push 1.0
sub
popVar v2
pushVar v2
jnz again
pushVar v3
switch tab
jmp end
one:
push 1.0
popVar v4
jmp end
two:
push 2.0
popVar v4
end:
pushVar v5
jz e1
push 1.0
popVar v6
jmp e3
e1:
pushVar v7
jz e2
push 2.0
popVar v6
jmp e3
e2:
push 3.0
popVar v6
e3:
end
tab:
.switch 2
.case imm 1.0, one
.case imm 2.0, two
""";
@Test
void structuresLoopsSwitchAndElseIf() {
StratListing li = listing(SRC, "Main");
String t = StratPseudo.of(li).text();
assertFalse(t.contains("goto"), t);
assertTrue(t.contains("while (v0 < 10.0) {"), t);
assertTrue(t.contains("if (v0 == 5.0) {\n break\n }"), t);
assertTrue(t.contains("if (v1) {\n continue\n }"), t);
assertTrue(t.contains("do {\n v2 = v2 - 1.0\n} while (v2)"), t);
assertTrue(
t.contains("switch (v3) { // sw_00d4\ncase 1.0:\n v4 = 1.0\n break\ncase 2.0:\n v4 = 2.0\n}"),
t);
assertTrue(t.contains("if (v5) {\n v6 = 1.0\n} else if (v7) {\n v6 = 2.0\n} else {\n v6 = 3.0\n}"), t);
// the assembly still round-trips
StratAssembler.Result back = StratAssembler.assemble(li.text(), StratListing.layout(li.img, li.start));
assertEquals(List.of(toList(li.words())), List.of(toList(back.words())));
}
static List<Integer> toList(int[] a) {
return java.util.Arrays.stream(a).boxed().toList();
}
/** Over all units of the CD: balanced blocks, every remaining goto has its label, few gotos left. */
@Test
void everyUnitIsWellFormed() throws IOException {
int gotos = 0, units = 0;
Pattern g = Pattern.compile("goto (\\w+)");
for (Path p : StratRoundTripTest.levels()) {
StratRoundTripTest.Loaded l = StratRoundTripTest.load(p);
if (l == null) {
continue;
}
for (int u = 0; u < l.img().units.size(); u++) {
StratListing li = StratListing.of(l.img(), l.code(), u, l.entries(), StratListing.Names.NONE);
StratPseudo ps = StratPseudo.of(li);
units++;
int open = 0;
Set<String> labels = new HashSet<>();
for (StratPseudo.Line x : ps.lines) {
String s = x.text().replaceAll("\\s*//.*$", "");
if (s.startsWith("}")) {
open--;
}
assertTrue(open >= 0, p + " unit " + u + ": unbalanced at " + s);
if (s.endsWith("{")) {
open++;
}
if (s.matches("\\w+:")) {
labels.add(s.substring(0, s.length() - 1));
}
}
assertEquals(0, open, p + " unit " + u + ": unclosed block");
for (StratPseudo.Line x : ps.lines) {
Matcher m = g.matcher(x.text());
if (m.find()) {
gotos++;
assertTrue(labels.contains(m.group(1)), p + " unit " + u + ": goto without label " + x.text());
}
}
}
}
System.out.printf("script view: %d units, %d gotos%n", units, gotos);
assertTrue(units >= 1400);
assertTrue(gotos < 500, "gotos: " + gotos);
}
}