strats: custom types stay editable after importing object types (custom code kept last), references between custom types by name, edit can change where fields come from, opcode aliases with clearer names, script view gotos 400 -> 31 (labeled break/continue, condition chains, jumps to return), script language 2: event handlers, switch, short-circuit && ||, constant folding, schema parameter names, plain numbers in cells

This commit is contained in:
MineTec committed 2026-11-07 14:12:49 +01:00
1 parent d6f84be125
commit 386a06006d
21 files changed
+1771 -252

No files matched your search

@@ -56,6 +56,23 @@ public final class CrossImport {
/** Makes sure the load hook is registered. */
public static void init() {}
/**
* Lets the custom strat types (editor.tools.strats) step aside during an import: the merge
* runs on mergeBase (the code without them, null = unchanged) and the commit inside around,
* which takes them off and appends them again behind the imported code.
*/
public interface CodeHook {
byte[] mergeBase(Scene s);
void around(Project p, Runnable commit);
}
private static volatile CodeHook codeHook;
public static void setCodeHook(CodeHook h) {
codeHook = h;
}
/** One imported type: source level, type name and its code entry in the source STPC. */
public record TypeRef(String level, String type, int code) {
Map<String, Object> json() {
@@ -172,8 +189,16 @@ public final class CrossImport {
int pages = ref.pages().size(),
tex = ref.textures().size(),
anims = ref.textureAnims().size();
apply(o, ref, false, -1, -1, -1);
apply(o, cur, true, pages, tex, anims);
Runnable merge = () -> {
apply(o, ref, false, -1, -1, -1);
apply(o, cur, true, pages, tex, anims);
};
CodeHook h = codeHook;
if (h != null) {
h.around(p, merge);
} else {
merge.run();
}
@SuppressWarnings("unchecked")
List<Object> l = p.extra().get(KEY) instanceof List<?> x ? (List<Object>) x : new ArrayList<>();
List<Object> nl = new ArrayList<>(l);
@@ -326,12 +351,16 @@ public final class CrossImport {
if (c == null) {
throw new IllegalStateException("the level has no STPC chunk");
}
return analysis(s, c.data());
}
private static StpcMerge.Analysis analysis(Scene s, byte[] stpc) {
Set<Integer> roots = new TreeSet<>();
for (Strat x : s.strats()) {
roots.add(x.code());
}
roots.addAll(s.catalog().names().keySet());
StpcImage img = new StpcImage(c.data(), s.settings().wfpc());
StpcImage img = new StpcImage(stpc, s.settings().wfpc());
roots.removeIf(o -> o < img.codeStart || img.unitOf(o) < 0);
return new StpcMerge.Analysis(img, roots);
}
@@ -361,7 +390,9 @@ public final class CrossImport {
/** Merges a batch into (a copy of) the scene's level data; the scene is not changed. */
public static Outcome compute(Scene s, List<TypeRef> batch, Bases cd) throws IOException {
StpcMerge.Analysis tgt = analysis(s);
CodeHook h = codeHook;
byte[] base = h == null ? null : h.mergeBase(s);
StpcMerge.Analysis tgt = base == null ? analysis(s) : analysis(s, base);
TextMerge.Textures tex = textures(s);
int p0 = tex.pages.size(), e0 = tex.entries.size(), a0 = tex.anims.size();
RawChunk sm = chunk(s, "SMPC");
@@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.tools.catalog.CrossImport;
import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema;
import com.eliasmueller.llamamod.editor.tools.objects.Schemas;
import com.eliasmueller.llamamod.format.Bytes;
@@ -64,14 +65,32 @@ public final class StratEdits {
/**
* A custom type: name, source text, the entry label in the source, type fields (MAP record
* prototype), and where it came from.
* prototype), where it came from (basedOn, the cloned type) and the type its fields and
* parameter names are taken from (fieldsFrom).
*/
public record CustomType(
String name, String basedOn, String entryLabel, String source, StratCatalog.Variant proto) {
String name,
String basedOn,
String entryLabel,
String source,
StratCatalog.Variant proto,
String fieldsFrom) {
/** A type whose fields come from the type it is based on. */
public CustomType(String name, String basedOn, String entryLabel, String source, StratCatalog.Variant proto) {
this(name, basedOn, entryLabel, source, proto, basedOn);
}
public CustomType withSource(String src) {
return new CustomType(name, basedOn, entryLabel, src, proto, fieldsFrom);
}
Map<String, Object> json() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("name", name);
m.put("basedOn", basedOn == null ? "" : basedOn);
if (fieldsFrom != null && !fieldsFrom.isEmpty() && !fieldsFrom.equals(basedOn)) {
m.put("fieldsFrom", fieldsFrom);
}
m.put("entry", entryLabel);
m.put("source", source);
Map<String, Object> p = new LinkedHashMap<>();
@@ -129,12 +148,14 @@ public final class StratEdits {
params,
List.copyOf(chain),
"custom");
String based = (String) m.get("basedOn");
return new CustomType(
(String) m.get("name"),
(String) m.get("basedOn"),
based,
(String) m.get("entry"),
(String) m.get("source"),
v);
v,
m.get("fieldsFrom") instanceof String ff ? ff : based);
}
}
@@ -181,6 +202,8 @@ public final class StratEdits {
boolean edited;
int firstCustom = -1;
final List<Integer> customEntries = new ArrayList<>();
final List<String> customNames = new ArrayList<>();
final List<String> customFields = new ArrayList<>();
}
private static final Map<Scene, State> STATE = Collections.synchronizedMap(new WeakHashMap<>());
@@ -217,6 +240,20 @@ public final class StratEdits {
/** Makes sure the load hooks are registered. */
public static void init() {}
static {
CrossImport.setCodeHook(new CrossImport.CodeHook() {
@Override
public byte[] mergeBase(Scene s) {
return importBase(s);
}
@Override
public void around(Project p, Runnable commit) {
aroundImport(p, commit);
}
});
}
private static int num(Map<?, ?> m, String k) {
return ((Number) m.get(k)).intValue();
}
@@ -247,7 +284,24 @@ public final class StratEdits {
return;
}
int startBefore = codeEnd(cur);
for (CustomType t : r.types) {
Map<?, ?> ses = json.get(KEY) instanceof Map<?, ?> m && m.get("session") instanceof Map<?, ?> x ? x : null;
// unit:N operands of older projects that point at custom types → unit:Name
int savedFirst = ses != null && ses.get("firstUnit") instanceof Number n ? n.intValue() : units(cur);
List<String> names = r.types.stream().map(CustomType::name).toList();
List<Object> stored = json.get(KEY) instanceof Map<?, ?> m && m.get("types") instanceof List<?> l
? List.copyOf(l)
: List.of();
for (int i = 0; i < r.types.size(); i++) {
CustomType t = r.types.get(i);
String src = symbolic(t.source, savedFirst, names.subList(0, i));
if (!src.equals(t.source)) {
t = t.withSource(src);
if (i < stored.size() && stored.get(i) instanceof Map<?, ?> sm) {
@SuppressWarnings("unchecked")
Map<String, Object> w = (Map<String, Object>) sm;
w.put("source", src);
}
}
try {
appendType(ref, t, false);
appendType(cur, t, false);
@@ -255,7 +309,7 @@ public final class StratEdits {
warnings.add("custom strat type " + t.name + " could not be assembled: " + e.getMessage());
}
}
if (json.get(KEY) instanceof Map<?, ?> m && m.get("session") instanceof Map<?, ?> ses) {
if (ses != null) {
int cs = num(ses, "start"), ce = num(ses, "end");
int b = startBefore - cs;
if (b != 0) {
@@ -359,6 +413,10 @@ public final class StratEdits {
if (n == null) {
n = com.eliasmueller.llamamod.format.Strats.name(a.img.data, code, a.img.codeStart);
}
int k = customIndex(s, a.img.unitOf(code));
if (k >= 0 && state(s).customFields.get(k) != null) {
n = state(s).customFields.get(k); // parameter names of the type the fields come from
}
ParamSchema.TypeSchema ts = Schemas.of(n);
ParamSchema.ParamDef d = ts == null ? null : ts.param(i);
return d == null ? null : ident(d.name());
@@ -368,9 +426,30 @@ public final class StratEdits {
public String entry(int code) {
return cat.nameOf(code);
}
@Override
public String unitName(int u) {
int k = customIndex(s, u);
return k >= 0 ? state(s).customNames.get(k) : null;
}
});
}
/** Script names of a type's parameters from its object schema: name → "vN" (empty if unknown). */
public static Map<String, String> paramNames(String type) {
Map<String, String> m = new LinkedHashMap<>();
ParamSchema.TypeSchema ts = type == null ? null : Schemas.of(type);
if (ts != null) {
for (ParamSchema.ParamDef d : ts.params()) {
String n = ident(d.name());
if (n != null && !m.containsKey(n)) {
m.put(n, "v" + d.index());
}
}
}
return m;
}
static String ident(String label) {
StringBuilder b = new StringBuilder();
boolean up = false;
@@ -519,14 +598,28 @@ 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);
return build(a.img.data, s.settings().wfpc(), a.entries, t);
return build(a.img.data, s.settings().wfpc(), a.entries, t, customUnits(s));
}
/** 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) {
/** Unit index of each custom type of the scene by name (for unit:Name operands). */
static Map<String, Integer> customUnits(Scene s) {
State st = state(s);
Map<String, Integer> m = new LinkedHashMap<>();
for (int i = 0; i < st.customNames.size(); i++) {
m.put(st.customNames.get(i), st.firstCustom + i);
}
return m;
}
/**
* Assembles a type's source as a new unit appended to the STPC data old and verifies it;
* names = unit index of the custom types before it (unit:Name).
*/
static Built build(byte[] old, int wfpc, Set<Integer> entries, CustomType t, Map<String, Integer> names) {
StpcImage img0 = new StpcImage(old, wfpc);
int base = old.length + 4;
StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(img0, base));
StratAssembler.Result r = StratAssembler.assemble(
t.source, StratListing.layout(img0, base).withUnitNames(names));
Integer e = r.labels().get(t.entryLabel);
if (e == null) {
throw new IllegalArgumentException("entry label " + t.entryLabel + " not found in the source");
@@ -628,11 +721,14 @@ public final class StratEdits {
st.firstCustom = unit;
}
st.customEntries.add(b.entry);
st.customNames.add(t.name);
st.customFields.add(t.fieldsFrom);
return b.entry;
}
/** Adds a custom type to an open project (reference + scene) and records it. Returns the entry code. */
public static int addType(Project p, CustomType t) {
t = t.withSource(symbolic(p.scene(), t.source));
appendType(p.reference(), t, false);
int code = appendType(p.scene(), t, true);
Records r = Records.of(p.extra());
@@ -642,6 +738,36 @@ public final class StratEdits {
return code;
}
private static final java.util.regex.Pattern UNIT_REF = java.util.regex.Pattern.compile("\\bunit:(\\d+)\\b");
/** Source with unit:N operands that point at custom types written as unit:TypeName (survives reordering). */
public static String symbolic(Scene s, String src) {
State st = state(s);
return symbolic(src, st.firstCustom, st.customNames);
}
static String symbolic(String src, int first, List<String> names) {
if (first < 0 || names.isEmpty()) {
return src;
}
java.util.regex.Matcher m = UNIT_REF.matcher(src);
StringBuilder b = new StringBuilder();
while (m.find()) {
int k = Integer.parseInt(m.group(1)) - first;
String rep = k >= 0 && k < names.size() ? "unit:" + names.get(k) : m.group();
m.appendReplacement(b, java.util.regex.Matcher.quoteReplacement(rep));
}
m.appendTail(b);
return b.toString();
}
/** Source with unit:Old operands renamed to unit:New. */
static String renameUnitRefs(String src, String oldName, String newName) {
return src.replaceAll(
"\\bunit:" + java.util.regex.Pattern.quote(oldName) + "(?![\\w])",
java.util.regex.Matcher.quoteReplacement("unit:" + newName));
}
/** 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);
@@ -678,9 +804,19 @@ public final class StratEdits {
Set<Integer> entries = new TreeSet<>(analysis(s).entries);
entries.removeIf(o -> o >= d.length);
byte[] cur = d;
int unit = new StpcImage(d, s.settings().wfpc()).units.size();
Map<String, Integer> names = new LinkedHashMap<>();
for (int i = 0; i < types.size(); i++) {
CustomType t = types.get(i);
Built x = build(cur, s.settings().wfpc(), entries, t);
Built x;
try {
x = build(cur, s.settings().wfpc(), entries, t, names);
} catch (StratAssembler.AsmException e) {
if (i == index) {
throw e;
}
return List.of(t.name + ": " + e.getMessage());
}
if (!x.problems.isEmpty()) {
return x.problems.stream().map(m -> t.name + ": " + m).toList();
}
@@ -691,6 +827,7 @@ public final class StratEdits {
for (int v : x.asm.strats().values()) {
entries.add(x.base + v);
}
names.put(t.name, unit + i);
cur = x.stpc;
}
return null;
@@ -709,7 +846,7 @@ public final class StratEdits {
}
}
for (Scene s : List.of(p.reference(), p.scene())) {
Runnable r = () -> rebuild(s, types, remap);
Runnable r = () -> append(s, types, remap, cut(s));
if (s == p.scene()) {
s.bulk(r);
} else {
@@ -720,20 +857,24 @@ public final class StratEdits {
new Records(rec.constants, List.copyOf(types)).store(p.extra(), session(p.scene()));
}
private static void rebuild(Scene s, List<CustomType> types, int[] remap) {
/** Objects of the custom types while their code is cut off: custom type index and old code; old entries. */
private record Detached(Map<Strat, int[]> objects, List<Integer> entries) {}
/** Cuts the custom units off a scene's code and removes them from the catalog (objects keep code 0). */
private static Detached cut(Scene s) {
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<>();
int to = st.firstCustom < 0
? a.img.data.length
: a.img.units.get(st.firstCustom + st.customEntries.size() - 1).end();
Map<Strat, int[]> objects = new java.util.IdentityHashMap<>();
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) {
if (x.code() >= from && x.code() < to) {
objects.put(x, new int[] {a.img.unitOf(x.code()) - st.firstCustom, x.code()});
s.set(x, "code", 0); // keep stale codes out of the analysis roots while appending
}
}
@@ -751,28 +892,61 @@ public final class StratEdits {
chunk(s).setRaw("data", cut);
st.firstCustom = -1;
st.customEntries.clear();
st.customNames.clear();
st.customFields.clear();
return new Detached(objects, oldEntries);
}
/** Appends types to a scene whose custom units were cut off; objects of old type k follow type remap[k]. */
private static void append(Scene s, List<CustomType> types, int[] remap, Detached old) {
State st = state(s);
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) {
for (Strat x : s.strats()) {
int[] o = old.objects.get(x);
if (o == null) {
continue;
}
Strat x = strats.get(i);
int nk = k < remap.length ? remap[k] : -1;
int k = o[0];
int nk = k >= 0 && 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);
int oldEntry = old.entries.get(k);
// objects placed at the entry follow the entry; other codes keep their offset in the unit
int c0 = oldCode.get(i);
int c0 = o[1];
int code = c0 == oldEntry ? st.customEntries.get(nk) : c0 - oldEntry + st.customEntries.get(nk);
s.set(x, "code", code);
}
}
/**
* Runs an import of object types (CrossImport) with the custom units cut off first and
* appended again afterwards, so that they stay the last units (as on load) and can still be
* edited and deleted.
*/
static void aroundImport(Project p, Runnable commit) {
List<CustomType> types = records(p).types;
if (state(p.scene()).firstCustom < 0 || types.isEmpty() || rebuildBlocker(p.scene()) != null) {
commit.run();
return;
}
Detached r = cut(p.reference());
Detached[] sc = new Detached[1];
p.scene().bulk(() -> sc[0] = cut(p.scene()));
commit.run();
int[] id = TypeEdits.identity(types.size());
append(p.reference(), types, id, r);
p.scene().bulk(() -> append(p.scene(), types, id, sc[0]));
updateSession(p);
}
/** STPC data an import merges into: the code without the custom units (null = the scene's own). */
static byte[] importBase(Scene s) {
return state(s).firstCustom < 0 || rebuildBlocker(s) != null ? null : withoutCustom(s);
}
/** 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);
@@ -802,6 +976,7 @@ public final class StratEdits {
m.put("start", (long) (a.img.units.get(st.firstCustom).start() - 4));
m.put("end", (long)
a.img.units.get(Math.min(last, a.img.units.size() - 1)).end());
m.put("firstUnit", (long) st.firstCustom);
return m;
}
@@ -83,7 +83,7 @@ final class TypeDialog {
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). */
/** Edits custom type k in place: its name, entry label, source and (optionally) where its type fields come from. */
static void showEdit(EditorContext ctx, int k) {
if (ctx.document() == null) {
return;
@@ -105,7 +105,7 @@ final class TypeDialog {
"Edit object type " + t.name(),
t.name(),
t.source(),
t.basedOn(),
t.fieldsFrom(),
t.entryLabel(),
t.basedOn() != null && !t.basedOn().isEmpty(),
k);
@@ -170,6 +170,13 @@ final class TypeDialog {
return n;
}
static final String KEEP = "(keep current fields)";
/** Parameter names (from the object schema) the script may use for the chosen fields type. */
private static java.util.Map<String, String> names(String fieldsType) {
return StratEdits.paramNames(fieldsType);
}
private static void open(
EditorContext ctx,
String title,
@@ -183,14 +190,19 @@ final class TypeDialog {
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);
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");
from.addItem(KEEP);
from.setToolTipText("New objects of the type start with these type fields and parameters;"
+ " objects already placed keep theirs");
}
ctx.scene().catalog().types().forEach(t -> {
if (!t.name().equals(oldName)) {
from.addItem(t.name());
}
});
from.setSelectedItem(editing ? KEEP : fieldsFrom);
String script = StratCompiler.embeddedSource(source);
JTextArea text = new JTextArea(script != null ? script : source, 30, 90);
JComboBox<String> lang = new JComboBox<>(new String[] {"Assembly", "Script"});
@@ -206,8 +218,9 @@ final class TypeDialog {
top.add(entryField);
top.add(new JLabel("Language"));
top.add(lang);
top.add(new JLabel("Type fields / parameters from"));
top.add(from);
JPanel fieldsRow = new JPanel(new FlowLayout(FlowLayout.LEADING));
fieldsRow.add(new JLabel("Type fields / parameters from"));
fieldsRow.add(from);
JButton check = new JButton("Check");
JButton create = new JButton(editing ? "Apply" : "Create type");
JButton cancel = new JButton("Cancel");
@@ -228,7 +241,10 @@ final class TypeDialog {
: "")
+ "Syntax: notes/strat-language.md (manual chapter “Strat scripts”). The new code is appended to the level and verified by disassembling it again.</font></html>"));
JPanel north = new JPanel(new BorderLayout());
north.add(top, BorderLayout.NORTH);
JPanel rows = new JPanel(new java.awt.GridLayout(2, 1));
rows.add(top);
rows.add(fieldsRow);
north.add(rows, BorderLayout.NORTH);
north.add(help, BorderLayout.SOUTH);
d.getContentPane().add(north, BorderLayout.NORTH);
d.getContentPane().add(new JScrollPane(text), BorderLayout.CENTER);
@@ -243,7 +259,8 @@ final class TypeDialog {
String cur = text.getText();
if (to == 0) {
try {
text.setText(StratCompiler.compile(cur));
String ft = (String) from.getSelectedItem();
text.setText(StratCompiler.compile(cur, names(KEEP.equals(ft) ? fieldsFrom : ft)));
} catch (StratCompiler.CompileException ex) {
result.setText("⚠ " + ex.getMessage() + " – fix the script before switching to assembly");
highlight(text, ex.line);
@@ -281,9 +298,11 @@ final class TypeDialog {
return;
}
String src = text.getText();
String chosen = (String) from.getSelectedItem();
String fieldsType = KEEP.equals(chosen) ? fieldsFrom : chosen;
if (lang.getSelectedIndex() == 1) {
try {
src = StratCompiler.compile(src);
src = StratCompiler.compile(src, names(fieldsType));
} catch (StratCompiler.CompileException e) {
result.setText("⚠ " + e.getMessage());
highlight(text, e.line);
@@ -294,12 +313,18 @@ final class TypeDialog {
entryField.setText(first);
}
}
StratCatalog.Type ft = s.catalog().type((String) from.getSelectedItem());
StratCatalog.Type ft = s.catalog().type(chosen);
if (editing) {
StratEdits.CustomType old =
StratEdits.records(ctx.document().project()).types().get(editIndex);
boolean keep = KEEP.equals(chosen) || ft == null;
StratEdits.CustomType t = new StratEdits.CustomType(
n, old.basedOn(), entryField.getText().trim(), src, old.proto());
n,
old.basedOn(),
entryField.getText().trim(),
src,
keep ? old.proto() : ft.variant(0),
keep ? old.fieldsFrom() : chosen);
List<StratEdits.CustomType> all = new java.util.ArrayList<>(
StratEdits.records(ctx.document().project()).types());
all.set(editIndex, t);
@@ -330,7 +355,7 @@ final class TypeDialog {
return;
}
StratEdits.CustomType t = new StratEdits.CustomType(
n, clone ? fieldsFrom : null, entryField.getText().trim(), src, ft.variant(0));
n, clone ? fieldsFrom : null, entryField.getText().trim(), src, ft.variant(0), chosen);
try {
StratEdits.Built b = StratEdits.build(s, t);
if (!b.problems().isEmpty()) {
@@ -44,16 +44,26 @@ final class TypeEdits {
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);
after.add(t.withSource(StratEdits.symbolic(p.scene(), t.source())));
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). */
/**
* Replaces custom type k by t (objects of the type stay and use the new code); a new name is
* also changed in the unit:Name references of the other types.
*/
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);
after.set(k, t.withSource(StratEdits.symbolic(p.scene(), t.source())));
String oldName = before.get(k).name();
if (!oldName.equals(t.name())) {
for (int i = 0; i < after.size(); i++) {
StratEdits.CustomType x = after.get(i);
after.set(i, x.withSource(StratEdits.renameUnitRefs(x.source(), oldName, t.name())));
}
}
return new SetTypes(
p, "Edit object type " + t.name(), before, after, identity(before.size()), identity(before.size()));
}
@@ -34,6 +34,9 @@ public final class OpTable {
private final Op[] ops = new Op[StratOps.COUNT];
private final Map<String, Op> byName = new HashMap<>();
/** Former mnemonics (lower case) that still assemble (resource op-aliases.tsv). */
private final Map<String, Op> aliases = new HashMap<>();
private final Map<Integer, Named> props = new HashMap<>();
private final Map<String, Named> propsByName = new HashMap<>();
private final Map<Integer, Named> globals = new HashMap<>();
@@ -64,6 +67,14 @@ public final class OpTable {
byName.put(name, ops[i]);
}
}
for (String[] f : rows("op-aliases.tsv")) {
Op op = f.length < 2 ? null : byName.get(lc(f[1].trim()));
String old = lc(f[0].trim());
if (op != null && !old.isEmpty() && !byName.containsKey(old)) {
byName.put(old, op);
aliases.put(old, op);
}
}
named("props.tsv", props, propsByName, true);
named("globals.tsv", globals, globalsByName, false);
}
@@ -128,7 +139,12 @@ public final class OpTable {
return ops[code];
}
/** Opcode by mnemonic (case-insensitive) or "op_XX", or null. */
/** The opcode a former mnemonic now names, or null if name is not an old alias. */
public Op alias(String name) {
return aliases.get(lc(name));
}
/** Opcode by mnemonic (case-insensitive, current or former name) or "op_XX", or null. */
public Op byName(String name) {
Op o = byName.get(lc(name));
if (o == null && lc(name).startsWith("op_")) {
@@ -16,9 +16,19 @@ public final class StratAssembler {
* unit's first word; models/anims/units = STPC offsets of model headers, animation sets, unit
* starts.
*/
public record Layout(int base, List<Integer> models, List<Integer> anims, List<Integer> units) {
public record Layout(
int base, List<Integer> models, List<Integer> anims, List<Integer> units, Map<String, Integer> unitNames) {
public Layout(int base, List<Integer> models, List<Integer> anims, List<Integer> units) {
this(base, models, anims, units, Map.of());
}
public Layout withBase(int b) {
return new Layout(b, models, anims, units);
return new Layout(b, models, anims, units, unitNames);
}
/** The same layout where unit:Name resolves to the unit with that index (type names). */
public Layout withUnitNames(Map<String, Integer> names) {
return new Layout(base, models, anims, units, Map.copyOf(names));
}
}
@@ -662,7 +672,17 @@ public final class StratAssembler {
add = d == null ? 0 : d;
r = r.substring(0, plus);
}
Integer idx = number(ln, r);
String key = r.trim();
Integer idx = kind.equals("unit") && key.matches("[A-Za-z_]\\w*")
? l.unitNames().get(key)
: null;
if (idx == null && kind.equals("unit") && key.matches("[A-Za-z_]\\w*")) {
err(ln, "unknown unit name '" + key + "' (custom object types are named, others use unit:N)");
return null;
}
if (idx == null) {
idx = number(ln, r);
}
if (idx == null) {
return null;
}
@@ -10,8 +10,9 @@ 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).
* assembly of {@link StratAssembler}. Numbers: a plain integer is in game units ({@code 10} = 10.0),
* {@code 10r} and hex are raw words, {@code 1.5} and {@code 90deg} as in the assembly. Scripts
* compiled before this rule ({@code use rawNumbers}) read plain integers as raw words.
*/
public final class StratCompiler {
/** A compile error at a 1-based source line. */
@@ -27,8 +28,24 @@ public final class StratCompiler {
/** Prefix of the comment lines that carry the script source inside the generated assembly. */
public static final String SOURCE_PREFIX = ";| ";
/** Marker line behind the embedded source of scripts with unit numbers (language version 2). */
static final String VERSION_MARK = "; script language 2";
/** Statement that keeps the old number rule (plain integers are raw words). */
public static final String RAW_NUMBERS = "use rawNumbers";
private record Tok(String kind, String text, int line) {}
/**
* Expression tree. num: value (raw word) and text (assembly literal); ins: one instruction that
* pushes a value; bin/neg/not: operator over kids; call: instruction text after the kids.
*/
private record Node(String kind, String text, int value, List<Node> kids, int line) {
boolean isNum() {
return kind.equals("num");
}
}
private static final Map<String, Integer> PREC = Map.ofEntries(
Map.entry("||", 1),
Map.entry("&&", 2),
@@ -78,43 +95,78 @@ public final class StratCompiler {
private static final List<String> REFS = List.of("model", "anim", "unit", "sound", "tex", "text");
/** Named event types for {@code on name { … }}. */
private static final Map<String, Integer> EVENTS = Map.of("touched", 4);
private final OpTable ops = OpTable.get();
private final List<Tok> toks;
private int p;
private final StringBuilder out = new StringBuilder();
private StringBuilder out = new StringBuilder();
/** Event handler code of the current strat/sub, placed behind its code. */
private StringBuilder deferred = new StringBuilder();
/** Event entries and switch tables, placed at the start of the unit. */
private final StringBuilder tables = 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 int eventSlot;
private boolean rawNumbers;
/** Enclosing loops/switches: {break label, continue label (null = none)}. */
private Deque<String[]> loops = new ArrayDeque<>();
private StratCompiler(String src) {
private StratCompiler(String src, Map<String, String> names) {
toks = lex(src);
names.forEach((n, v) -> {
if (v.matches("v\\d+") && n.matches("[A-Za-z_]\\w*")) {
aliases.put(n, v);
}
});
}
/** 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();
return compile(src, Map.of());
}
/** The script embedded in compiled assembly, or null if the text is not compiler output. */
/**
* Compiles a script; names = predefined variable names (parameter names of the object schema →
* "vN"), overridden by the script's own aliases.
*/
public static String compile(String src, Map<String, String> names) {
StratCompiler c = new StratCompiler(src, names);
StringBuilder b = new StringBuilder();
for (String l : src.split("\n", -1)) {
b.append(SOURCE_PREFIX).append(l.stripTrailing()).append('\n');
}
b.append(VERSION_MARK).append('\n');
c.program();
// tables first: data behind a terminating instruction would be tried as dead code
return b.append(c.tables).append(c.out).toString();
}
/**
* The script embedded in compiled assembly, or null if the text is not compiler output. Scripts
* compiled with the old number rule get a {@value #RAW_NUMBERS} line, so they mean the same.
*/
public static String embeddedSource(String asm) {
if (!asm.startsWith(SOURCE_PREFIX)) {
return null;
}
StringBuilder b = new StringBuilder();
boolean v2 = false;
for (String l : asm.split("\n", -1)) {
if (!l.startsWith(SOURCE_PREFIX.trim())) {
v2 = l.equals(VERSION_MARK);
break;
}
b.append(l.length() >= SOURCE_PREFIX.length() ? l.substring(SOURCE_PREFIX.length()) : "")
.append('\n');
}
return b.toString().stripTrailing() + "\n";
String src = b.toString().stripTrailing() + "\n";
if (!v2 && !src.contains(RAW_NUMBERS)) {
src = RAW_NUMBERS + " // written when a plain 10 meant 10 raw units (1/4096)\n" + src;
}
return src;
}
/** Entry label of the first strat of a script (null if none). */
@@ -165,6 +217,10 @@ public final class StratCompiler {
}
if (s.startsWith("deg", j)) {
j += 3;
} else if (j < s.length()
&& s.charAt(j) == 'r'
&& (j + 1 >= s.length() || !Character.isLetterOrDigit(s.charAt(j + 1)))) {
j++;
}
}
out.add(new Tok("num", s.substring(i, j), line));
@@ -215,10 +271,6 @@ public final class StratCompiler {
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++;
@@ -270,10 +322,12 @@ public final class StratCompiler {
out.append(" .string \"").append(name).append("\"\n");
place(name);
emit("debugName \"" + name + "\"");
eventSlot = 0;
boolean ends = block();
if (!ends) {
emit("end");
}
flushDeferred();
} else if (accept("sub")) {
String name = ident();
place(name);
@@ -283,14 +337,20 @@ public final class StratCompiler {
if (!ends) {
emit("return");
}
} else if (at("alias")) {
flushDeferred();
} else if (at("alias") || at("use")) {
statement();
} else {
throw err("expected 'strat', 'sub' or 'alias' but found " + describe(peek()));
throw err("expected 'strat', 'sub', 'alias' or 'use' but found " + describe(peek()));
}
}
}
private void flushDeferred() {
out.append(deferred);
deferred = new StringBuilder();
}
/** Parses { statements }; returns whether control never falls out of its end. */
private boolean block() {
expect("{");
@@ -319,7 +379,7 @@ public final class StratCompiler {
String top = label(), done = label();
place(top);
expect("(");
cond(done);
jumpIfFalse(expr(), done);
expect(")");
loops.push(new String[] {done, top});
loopBody(top);
@@ -347,18 +407,35 @@ public final class StratCompiler {
expect("while");
expect("(");
place(check);
expr();
jumpIfTrue(expr(), top);
expect(")");
emit("jnz " + top);
place(done);
return false;
}
case "switch" -> {
next();
switchStatement();
return false;
}
case "on" -> {
next();
onEvent(t);
return false;
}
case "break", "continue" -> {
next();
if (loops.isEmpty()) {
throw new CompileException(t.line, t.text + " outside a loop");
boolean brk = t.text.equals("break");
String target = null;
for (String[] l : loops) {
target = brk ? l[0] : l[1];
if (target != null) {
break;
}
}
emit("jmp " + loops.peek()[t.text.equals("break") ? 0 : 1]);
if (target == null) {
throw new CompileException(t.line, t.text + " outside a " + (brk ? "loop or switch" : "loop"));
}
emit("jmp " + target);
return true;
}
case "yield" -> {
@@ -399,6 +476,15 @@ public final class StratCompiler {
out.append(" .alias ").append(n).append(" = ").append(v).append('\n');
return false;
}
case "use" -> {
next();
String what = ident();
if (!what.equals("rawNumbers")) {
throw new CompileException(t.line, "unknown option " + what + " (use rawNumbers)");
}
rawNumbers = true;
return false;
}
default -> {
// fall through to assignments and calls
}
@@ -407,12 +493,13 @@ public final class StratCompiler {
throw err("expected a statement but found " + describe(t));
}
if (isOp(t.text) && !isLvalueStart()) {
String mn = callExpr();
OpTable.Op op = ops.byName(mn);
Node call = callExpr();
value(call);
OpTable.Op op = ops.byName(call.text.split(" ")[0]);
if (op.pushes() == 1) {
emit("pop");
}
return mn.equalsIgnoreCase("end") || mn.equalsIgnoreCase("remove");
return op.name().equalsIgnoreCase("end") || op.name().equalsIgnoreCase("remove");
}
assignment();
return false;
@@ -421,7 +508,7 @@ public final class StratCompiler {
private void ifRest() {
String other = label();
expect("(");
cond(other);
jumpIfFalse(expr(), other);
expect(")");
block();
if (accept("else")) {
@@ -439,29 +526,157 @@ public final class StratCompiler {
}
}
/** 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;
boolean yieldEdge = false;
while (!at("}")) {
if (peek().kind.equals("eof")) {
throw err("missing '}'");
}
mark = at("yield") && peek(1).text.equals("}") ? out.length() : -1;
if (mark >= 0) {
if (at("yield") && peek(1).text.equals("}")) {
next();
yieldEdge = true;
break;
}
statement();
}
expect("}");
emit((mark >= 0 ? "jmpYield " : "jmp ") + top);
emit((yieldEdge ? "jmpYield " : "jmp ") + top);
}
/**
* switch (e) { case c: … default: … }: C rules (cases fall through, break leaves the switch).
* Compiles to the VM's switch table (placed behind the strat) and a jump to default or the end.
*/
private void switchStatement() {
Tok t = peek();
expect("(");
Node v = expr();
expect(")");
expect("{");
String table = "sw_" + (++labels), done = label();
value(v);
emit("switch " + table);
int jmpAt = out.length();
emit("jmp " + done);
List<String[]> cases = new ArrayList<>();
String dflt = null;
String cont = loops.isEmpty() ? null : loops.peek()[1];
loops.push(new String[] {done, cont});
while (!at("}")) {
if (peek().kind.equals("eof")) {
throw err("missing '}'");
}
if (accept("case")) {
Tok ct = peek();
Node c = expr();
if (!c.isNum()) {
throw new CompileException(ct.line, "a case value must be a constant");
}
expect(":");
String l = label();
for (String[] x : cases) {
if (Integer.parseInt(x[0]) == c.value) {
throw new CompileException(ct.line, "duplicate case " + c.text);
}
}
cases.add(new String[] {Integer.toString(c.value), l});
place(l);
} else if (accept("default")) {
expect(":");
if (dflt != null) {
throw new CompileException(t.line, "two default labels");
}
dflt = label();
place(dflt);
} else {
statement();
}
}
expect("}");
loops.pop();
place(done);
if (dflt != null) {
out.replace(jmpAt, jmpAt + (" jmp " + done + "\n").length(), " jmp " + dflt + "\n");
}
if (cases.isEmpty()) {
throw new CompileException(t.line, "a switch needs at least one case");
}
tables.append(table).append(":\n");
tables.append(" .switch ").append(cases.size()).append('\n');
for (String[] c : cases) {
int x = Integer.parseInt(c[0]);
boolean small = x >= Short.MIN_VALUE && x <= Short.MAX_VALUE;
tables.append(" .case ")
.append(small ? "" : "imm ")
.append(Num.literal(x))
.append(", ")
.append(c[1])
.append('\n');
}
}
/**
* on touched { … } / on event(type[, param]) [slot n] { … }: installs an event handler here
* (onEvent); the handler body is placed behind the strat and ends with return.
*/
private void onEvent(Tok t) {
String type, param = "0";
if (accept("event")) {
expect("(");
type = rawWord();
if (accept(",")) {
param = rawWord();
}
expect(")");
} else {
String n = ident();
Integer k = EVENTS.get(n);
if (k == null) {
throw new CompileException(t.line, "unknown event " + n + " (touched, or event(type, param))");
}
type = Integer.toString(k);
}
int slot = eventSlot;
if (accept("slot")) {
slot = Num.parse(rawWord());
}
eventSlot = Math.max(eventSlot, slot + 1);
String entry = "ev_" + (++labels), handler = "on_" + labels;
emit("onEvent " + entry + ", " + slot);
// the handler: compiled into its own buffer with its own loop context
StringBuilder saved = out;
Deque<String[]> savedLoops = loops;
out = new StringBuilder();
loops = new ArrayDeque<>();
place(handler);
if (!block()) {
emit("return");
}
StringBuilder body = out;
out = saved;
loops = savedLoops;
tables.append(entry).append(":\n");
tables.append(" .event ")
.append(type)
.append(", ")
.append(param)
.append(", ")
.append(handler)
.append('\n');
deferred.append(body);
}
/** A raw word operand (event type/param, slot): number as in the assembly. */
private String rawWord() {
boolean neg = accept("-");
Tok t = next();
String s = (neg ? "-" : "") + (t.text.endsWith("r") ? t.text.substring(0, t.text.length() - 1) : t.text);
if (!t.kind.equals("num") || Num.parse(s) == null) {
throw new CompileException(t.line, "expected a number but found " + describe(t));
}
return s;
}
private boolean isOp(String name) {
@@ -505,7 +720,7 @@ public final class StratCompiler {
}
throw new CompileException(
t.line,
"unknown name " + a + " (variables are vN or aliases, globals gN, "
"unknown name " + a + " (variables are vN, aliases or parameter names, globals gN, "
+ "properties self.name, player.name, …)");
}
@@ -515,18 +730,17 @@ public final class StratCompiler {
String op = next().text;
switch (op) {
case "=" -> {
expr();
value(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();
Node e = expr();
if ((op.equals("+=") || op.equals("-=")) && pl.addr != null && e.isNum() && e.value == Num.ONE) {
emit(pl.addr);
emit(op.equals("+=") ? "inc" : "dec");
} else {
emit(pl.push);
expr();
value(e);
emit(BINOP.get(op.substring(0, 1)));
emit(pl.pop);
}
@@ -535,45 +749,44 @@ public final class StratCompiler {
}
}
// ------------------------------------------------------------------ expressions
// ------------------------------------------------------------------ expressions (parse)
private void expr() {
binary(1);
private Node expr() {
return binary(1);
}
private void binary(int min) {
unary();
private Node binary(int min) {
Node l = 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));
Tok t = next();
Node r = binary(PREC.get(t.text) + 1);
l = fold(new Node("bin", t.text, 0, List.of(l, r), t.line));
}
return l;
}
private void unary() {
private Node unary() {
Tok t = peek();
if (accept("-")) {
if (peek().kind.equals("num")) {
number("-" + next().text);
return;
}
unary();
emit("neg");
} else if (accept("!")) {
unary();
emit("not");
} else {
primary();
return fold(new Node("neg", "-", 0, List.of(unary()), t.line));
}
if (accept("!")) {
return fold(new Node("not", "!", 0, List.of(unary()), t.line));
}
return primary();
}
private void primary() {
private Node primary() {
Tok t = peek();
if (t.kind.equals("num")) {
number(next().text);
} else if (accept("(")) {
expr();
return number(next());
}
if (accept("(")) {
Node e = expr();
expect(")");
} else if (t.kind.equals("id") && REFS.contains(t.text) && peek(1).text.equals(":")) {
return e;
}
if (t.kind.equals("id") && REFS.contains(t.text) && peek(1).text.equals(":")) {
next();
next();
StringBuilder ref = new StringBuilder(t.text + ":" + next().text);
@@ -581,34 +794,46 @@ public final class StratCompiler {
next();
ref.append('+').append(next().text);
}
switch (t.text) {
case "sound" -> emit("soundAddress " + ref);
case "tex" -> emit("push " + ref);
return switch (t.text) {
case "sound" -> new Node("ins", "soundAddress " + ref, 0, List.of(), t.line);
case "tex" -> new Node("ins", "push " + ref, 0, List.of(), t.line);
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));
default -> new Node("ins", "address " + ref, 0, List.of(), t.line);
};
}
if (t.kind.equals("id") && isOp(t.text) && !isLvalueStart()) {
Node c = callExpr();
if (ops.byName(c.text.split(" ")[0]).pushes() != 1) {
throw new CompileException(t.line, c.text.split(" ")[0] + " gives no value");
}
return c;
}
if (t.kind.equals("id")) {
return new Node("ins", place().push, 0, List.of(), t.line);
}
throw err("expected a value but found " + describe(t));
}
private void number(String text) {
/** A number token: plain integers are game units (×4096) unless rawNumbers; "r" = raw. */
private Node number(Tok t) {
String s = t.text;
String text;
if (s.endsWith("r")) {
text = s.substring(0, s.length() - 1);
} else if (rawNumbers || s.startsWith("0x") || s.startsWith("0X") || s.contains(".") || s.endsWith("deg")) {
text = s;
} else {
text = s + ".0";
}
Integer v = Num.parse(text);
if (v == null) {
throw err("bad number " + text);
throw new CompileException(t.line, "bad number " + s);
}
emit((v >= Short.MIN_VALUE && v <= Short.MAX_VALUE ? "pushShort " : "push ") + text);
return new Node("num", text, v, List.of(), t.line);
}
/** op<inline, …>(args): pushes the arguments and emits the opcode; returns its mnemonic. */
private String callExpr() {
/** op<inline, …>(args): a call node; the argument count must match the opcode's pops. */
private Node callExpr() {
Tok t = peek();
String mn = ident();
OpTable.Op op = ops.byName(mn);
@@ -625,28 +850,150 @@ public final class StratCompiler {
inline.add(cur.toString());
cur.setLength(0);
} else {
cur.append(x.text);
cur.append(
x.kind.equals("num") && x.text.endsWith("r")
? x.text.substring(0, x.text.length() - 1)
: x.text);
}
}
expect(">");
inline.add(cur.toString());
}
int args = 0;
List<Node> args = new ArrayList<>();
if (accept("(")) {
while (!at(")")) {
expr();
args++;
args.add(expr());
if (!accept(",")) {
break;
}
}
expect(")");
}
if (op.pops() >= 0 && args != op.pops()) {
if (op.pops() >= 0 && args.size() != op.pops()) {
throw new CompileException(
t.line, mn + " takes " + op.pops() + " value" + (op.pops() == 1 ? "" : "s") + ", got " + args);
t.line,
mn + " takes " + op.pops() + " value" + (op.pops() == 1 ? "" : "s") + ", got " + args.size());
}
return new Node("call", mn + (inline.isEmpty() ? "" : " " + String.join(", ", inline)), 0, args, t.line);
}
// ------------------------------------------------------------------ constant folding
/** Folds operators over constants with the VM's integer semantics (20.12 mul/div). */
private static Node fold(Node n) {
if (!n.kids.stream().allMatch(Node::isNum)) {
return n;
}
int a = n.kids.get(0).value;
Integer v =
switch (n.kind) {
case "neg" -> -a;
case "not" -> a == 0 ? 1 : 0;
default -> {
int b = n.kids.get(1).value;
yield switch (n.text) {
case "+" -> a + b;
case "-" -> a - b;
case "*" -> (int) (((long) a * b) >> 12);
case "/" -> b == 0 ? null : ((a << 8) / b) << 4;
case "&" -> a & b;
case "|", "||" -> a | b;
case "&&" -> a != 0 && b != 0 ? 1 : 0;
case "==" -> a == b ? 1 : 0;
case "!=" -> a != b ? 1 : 0;
case "<" -> a < b ? 1 : 0;
case ">" -> a > b ? 1 : 0;
case "<=" -> a <= b ? 1 : 0;
case ">=" -> a >= b ? 1 : 0;
case "<<" -> a << (b >> 12);
case ">>" -> a >> (b >> 12);
default -> null;
};
}
};
if (v == null) {
return n;
}
String text =
n.kind.equals("neg") && !n.kids.get(0).text.startsWith("-") ? "-" + n.kids.get(0).text : Num.literal(v);
return new Node("num", text, v, List.of(), n.line);
}
// ------------------------------------------------------------------ code generation
/** Code that pushes the value of n (&& and || as the and/or opcodes: both sides evaluated). */
private void value(Node n) {
switch (n.kind) {
case "num" -> emit(
(n.value >= Short.MIN_VALUE && n.value <= Short.MAX_VALUE ? "pushShort " : "push ") + n.text);
case "ins" -> emit(n.text);
case "neg" -> {
value(n.kids.get(0));
emit("neg");
}
case "not" -> {
value(n.kids.get(0));
emit("not");
}
case "bin" -> {
value(n.kids.get(0));
value(n.kids.get(1));
emit(BINOP.get(n.text));
}
case "call" -> {
n.kids.forEach(this::value);
emit(n.text);
}
default -> throw new IllegalStateException(n.kind);
}
}
/** Jumps to target when n is false (0), falls through otherwise; && and || short-circuit. */
private void jumpIfFalse(Node n, String target) {
if (n.isNum()) {
if (n.value == 0) {
emit("jmp " + target);
}
} else if (n.kind.equals("not") && isLogic(n.kids.get(0))) {
jumpIfTrue(n.kids.get(0), target);
} else if (n.kind.equals("bin") && n.text.equals("&&")) {
jumpIfFalse(n.kids.get(0), target);
jumpIfFalse(n.kids.get(1), target);
} else if (n.kind.equals("bin") && n.text.equals("||")) {
String ok = label();
jumpIfTrue(n.kids.get(0), ok);
jumpIfFalse(n.kids.get(1), target);
place(ok);
} else {
value(n);
emit("jz " + target);
}
}
/** && / || / ! (whose negation is folded into the jumps); a plain !x stays "not" + jump. */
private static boolean isLogic(Node n) {
return n.kind.equals("not") || n.kind.equals("bin") && (n.text.equals("&&") || n.text.equals("||"));
}
/** Jumps to target when n is true (non-zero), falls through otherwise. */
private void jumpIfTrue(Node n, String target) {
if (n.isNum()) {
if (n.value != 0) {
emit("jmp " + target);
}
} else if (n.kind.equals("not") && isLogic(n.kids.get(0))) {
jumpIfFalse(n.kids.get(0), target);
} else if (n.kind.equals("bin") && n.text.equals("||")) {
jumpIfTrue(n.kids.get(0), target);
jumpIfTrue(n.kids.get(1), target);
} else if (n.kind.equals("bin") && n.text.equals("&&")) {
String no = label();
jumpIfFalse(n.kids.get(0), no);
jumpIfTrue(n.kids.get(1), target);
place(no);
} else {
value(n);
emit("jnz " + target);
}
emit(mn + (inline.isEmpty() ? "" : " " + String.join(", ", inline)));
return mn;
}
}
@@ -86,6 +86,11 @@ public final class StratListing {
return null;
}
/** Symbolic name of another unit for "unit:Name" operands (null = by index). */
default String unitName(int unit) {
return null;
}
Names NONE = new Names() {};
}
@@ -323,6 +328,10 @@ public final class StratListing {
/** Reference text for an absolute STPC offset (operand of pushAddr). */
String addrRef(int v) {
return addrRef(v, Names.NONE);
}
String addrRef(int v, Names names) {
if (in(v) && labels.containsKey(v)) {
return "@" + labels.get(v);
}
@@ -336,7 +345,9 @@ public final class StratListing {
}
int u = img.unitOf(v);
if (u >= 0 && u != unit) {
return "unit:" + u + plus(v - img.units.get(u).start());
String n = names.unitName(u);
return "unit:" + (n != null ? n : Integer.toString(u))
+ plus(v - img.units.get(u).start());
}
return Num.integer(v);
}
@@ -673,7 +684,7 @@ public final class StratListing {
}
case StratOps.PUSH_ADDR -> {
int v = w(o + 4);
args.add(addrRef(v));
args.add(addrRef(v, names));
comment = addrComment(v, names);
}
case StratOps.PUSH_SOUND -> {
@@ -183,6 +183,7 @@ public final class StratPseudo {
}
}
Map<String, List<String[]>> tables = switchTables();
findChains();
// candidate blocks
List<Block> cand = new ArrayList<>();
Map<Integer, Integer> elseOf = new HashMap<>();
@@ -199,7 +200,7 @@ public final class StratPseudo {
continue;
}
Integer t = labelIdx.get(operand(l));
if (t == null) {
if (t == null || chainMember.contains(i)) {
continue;
}
if (condJump(op) && t > i && clean(i, t)) {
@@ -292,6 +293,9 @@ public final class StratPseudo {
}
opensAt.values().forEach(l -> l.sort((a, b) -> Integer.compare(b.close, a.close)));
resolveJumps(accepted, consumed);
for (int m : chainMember) {
consumed.merge(ls.get(target(m)).label(), 1, Integer::sum);
}
Map<String, Integer> refs = new HashMap<>();
for (StratListing.Line l : ls) {
if (l.kind() == StratListing.Kind.INSTR || l.kind() == StratListing.Kind.DATA) {
@@ -342,7 +346,8 @@ public final class StratPseudo {
out(l.offset(), l.label() + ":");
}
for (Block b : opensAt.getOrDefault(i, List.of())) {
out(-1, b.kind.equals("do") ? "do {" : "loop {");
String name = loopNames.get(b.open);
out(-1, (name == null ? "" : name + ": ") + (b.kind.equals("do") ? "do {" : "loop {"));
depth++;
}
}
@@ -356,6 +361,7 @@ public final class StratPseudo {
flush();
whileLoops();
elseIfChains();
andChains();
}
private final Map<Integer, Integer> caseOwner = new HashMap<>();
@@ -375,6 +381,8 @@ public final class StratPseudo {
}
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);
// a jump from the then-part to the else label enters the else part: not a nested if
ok &= !(b.kind.equals("if") && b.close == a.close && b.open > a.open);
}
if (!ok) {
return false;
@@ -442,19 +450,22 @@ public final class StratPseudo {
// 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;
}
// a default part behind the cases (compiled switches): a case body jumps over it
int last = d < e ? e : d;
int found = -1;
for (int i = s + 2; i < ls.size() && i < (d < e ? ls.size() : d) && found < 0; i++) {
if (ls.get(i).kind() == StratListing.Kind.INSTR && ls.get(i).op() == 0x16) {
Integer t = target(i);
if (t != null && t > last) {
found = t;
}
}
if (endIdx < 0) {
return;
}
}
if (d < e && found < 0) {
return;
}
if (found >= 0) {
endIdx = found;
cs.add(new int[] {d, -1});
}
if (!clean(s, endIdx)) {
@@ -487,7 +498,8 @@ public final class StratPseudo {
|| ifAt.containsKey(i)
|| loopEnd.containsKey(i)
|| elseJumps.contains(i)
|| silentJumps.contains(i)) {
|| silentJumps.contains(i)
|| chainMember.contains(i)) {
continue;
}
Integer t = target(i);
@@ -503,15 +515,20 @@ public final class StratPseudo {
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 (in != null) {
int endAt = in.kind.equals("switch") ? in.close : in.close + 1;
if (anchor(t) == anchor(endAt)) {
as = "break";
} else if (in.isLoop() && anchor(t) == anchor(in.open)) {
as = "continue";
}
}
if (as == null) {
as = terminal(anchor(t));
}
if (as == null) {
as = labeledJump(accepted, in, i, t);
}
if (as != null) {
jumpAs.put(i, as);
@@ -520,24 +537,306 @@ public final class StratPseudo {
}
}
// ---------------------------------------------------------- short-circuit condition chains
/** Conditional jumps folded into the condition of a later one (chain members). */
private final Set<Integer> chainMember = new HashSet<>();
/** Last jump of a chain → its members in order and the body condition. */
private record Chain(List<Integer> members, Cond body) {}
private final Map<Integer, Chain> chains = new HashMap<>();
private final List<String> pendingChain = new ArrayList<>();
private int chainOff = -1;
/** Boolean formula over the jumps of a chain: T, F, L (leaf k: jump k is taken; pos=false negated), AND, OR. */
private record Cond(String kind, int k, boolean pos, Cond a, Cond b) {
static final Cond TRUE = new Cond("T", 0, true, null, null), FALSE = new Cond("F", 0, true, null, null);
static Cond leaf(int k) {
return new Cond("L", k, true, null, null);
}
Cond not() {
return new Cond("L", k, !pos, null, null);
}
static Cond and(Cond x, Cond y) {
return y == TRUE ? x : x == TRUE ? y : new Cond("AND", 0, true, x, y);
}
static Cond or(Cond x, Cond y) {
return y == FALSE ? x : x == FALSE ? y : new Cond("OR", 0, true, x, y);
}
/** "if j then a else b" as and/or of j, a and b, or null if it does not simplify. */
static Cond ite(Cond j, Cond a, Cond b) {
if (a == TRUE) {
return or(j, b);
}
if (a == FALSE) {
return and(j.not(), b);
}
if (b == TRUE) {
return or(j.not(), a);
}
if (b == FALSE) {
return and(j, a);
}
if (b.kind.equals("OR") && b.b.equals(a)) {
return or(and(j.not(), b.a), a);
}
if (b.kind.equals("AND") && b.b.equals(a)) {
return and(or(j, b.a), a);
}
return null;
}
}
/**
* Finds chains of conditional jumps that compiled || / && conditions leave ("a; jnz T; b; jz F;
* T:"): separated only by expression code, each going to the false target F of the last one,
* to the code right behind it, or to the start of a later member's expression; at least one
* member (an else part would otherwise be hidden behind nested ifs that all jump to it).
*/
private void findChains() {
for (int n = ls.size() - 1; n >= 0; n--) {
if (ls.get(n).kind() != StratListing.Kind.INSTR
|| !condJump(ls.get(n).op())
|| chainMember.contains(n)) {
continue;
}
Integer f = target(n);
if (f == null || f <= n) {
continue;
}
int body = anchor(n + 1);
// members backwards: segment starts (anchors) → formula from there
List<Integer> members = new ArrayList<>();
Map<Integer, Cond> from = new HashMap<>();
Cond last = Cond.leaf(n).not();
int seg = segmentStart(n);
from.put(seg, last);
Cond best = null;
List<Integer> bestMembers = null;
Cond cur = last;
while (seg > 0) {
int j = seg - 1;
while (j >= 0 && ls.get(j).kind() == StratListing.Kind.LABEL) {
j--;
}
if (j < 0
|| ls.get(j).kind() != StratListing.Kind.INSTR
|| !condJump(ls.get(j).op())
|| chainMember.contains(j)) {
break;
}
Integer t = target(j);
if (t == null || t <= j || !labelsOnlyFrom(j, seg)) {
break;
}
int at = anchor(t);
Cond whenJumps = at == body ? Cond.TRUE : at == anchor(f) ? Cond.FALSE : from.get(at);
if (whenJumps == null) {
break;
}
cur = Cond.ite(Cond.leaf(j), whenJumps, cur);
if (cur == null) {
break;
}
members.add(0, j);
seg = segmentStart(j);
if (seg < 0) {
break;
}
from.put(seg, cur);
best = cur;
bestMembers = List.copyOf(members);
}
if (best == null) {
continue;
}
chainMember.addAll(bestMembers);
chains.put(n, new Chain(bestMembers, best));
}
}
/** Labels between line indexes j and seg are only targets of conditional jumps at or before j. */
private boolean labelsOnlyFrom(int j, int seg) {
for (int k = j + 1; k < seg; k++) {
String lb = ls.get(k).label();
for (int x = 0; x < ls.size(); x++) {
StratListing.Line l = ls.get(x);
boolean uses = l.kind() != StratListing.Kind.LABEL
&& List.of(l.text().split("[ ,@+]+")).contains(lb);
if (uses && (x > j || l.kind() != StratListing.Kind.INSTR || !condJump(l.op()))) {
return false;
}
}
}
return true;
}
/**
* Start (line index) of the expression code that the conditional jump at j pops: the lines
* before it must be pure expression code pushing exactly one value; −1 if not.
*/
private int segmentStart(int j) {
int need = 1;
for (int k = j - 1; k >= 0; k--) {
StratListing.Line l = ls.get(k);
if (l.kind() != StratListing.Kind.INSTR) {
return -1;
}
int d = stackEffect(l);
if (d == Integer.MIN_VALUE) {
return -1;
}
need -= d;
if (need == 0) {
return k;
}
if (need < 0) {
return -1;
}
}
return -1;
}
/** Net pushes of a pure expression instruction (MIN_VALUE if it is not one). */
private int stackEffect(StratListing.Line l) {
int op = l.op();
if (PUSH.containsKey(op)
|| ADDR.containsKey(op)
|| op == StratOps.PUSH_SHORT
|| op == StratOps.PUSH_IMM
|| op == StratOps.PUSH_ADDR
|| op == StratOps.PUSH_SOUND) {
return 1;
}
if (BINARY.containsKey(mn(l))) {
return -1;
}
if (op == 0x46 || op == 0xaf) {
return 0;
}
OpTable.Op info = ops.op(op);
if (info.pushes() == 1 && info.pops() >= 0 && op >= StratOps.PUSH_IMM && op != StratOps.SPAWN) {
return 1 - info.pops();
}
return Integer.MIN_VALUE;
}
/** Text of a chain's body condition; leaves: members' expressions (pendingChain), last = e. */
private String chainCondition(int n, Chain c, String e) {
Map<Integer, String> text = new HashMap<>();
for (int k = 0; k < c.members.size(); k++) {
text.put(c.members.get(k), k < pendingChain.size() ? pendingChain.get(k) : "pop()");
}
text.put(n, e);
pendingChain.clear();
return render(c.body, text);
}
private String render(Cond c, Map<Integer, String> text) {
return switch (c.kind) {
case "L" -> {
int op = ls.get(c.k).op();
boolean jnz = op == 0x13 || op == 0x15;
String e = text.get(c.k);
yield jnz == c.pos ? e : not(e);
}
case "AND" -> andOperand(render(c.a, text)) + " && " + andOperand(render(c.b, text));
case "OR" -> orOperand(render(c.a, text)) + " || " + orOperand(render(c.b, text));
default -> c.kind.equals("T") ? "1r" : "0";
};
}
/** Parenthesizes an || operand with a top-level && (for reading). */
private static String orOperand(String c) {
return topLevel(c).contains("&&") ? "(" + c + ")" : c;
}
private static String not(String e) {
if (e.startsWith("!(") && e.endsWith(")") && balanced(e.substring(2, e.length() - 1))) {
return e.substring(2, e.length() - 1);
}
return e.matches("[\\w.]+") ? "!" + e : "!(" + e + ")";
}
/** Loops that a labeled break/continue names: open line index → name. */
private final Map<Integer, String> loopNames = new HashMap<>();
/** "break name"/"continue name" for a jump from line i to t out of an outer loop (null if none). */
private String labeledJump(List<Block> accepted, Block inner, int i, int t) {
Block best = null;
String as = null;
for (Block b : accepted) {
if (b == inner || !(b.isLoop() || b.kind.equals("do")) || b.open >= i || i >= b.close) {
continue;
}
String k = anchor(t) == anchor(b.close + 1)
? "break"
: b.isLoop() && anchor(t) == anchor(b.open) ? "continue" : null;
if (k != null && (best == null || b.open > best.open)) {
best = b;
as = k;
}
}
if (best == null || inner == null) {
return null;
}
String name = ls.get(best.open).label();
loopNames.put(best.open, name);
return as + " " + name;
}
/**
* "return"/"end"/"remove" when the instruction at line index i ends the strat or subroutine,
* so that a jump to it is the same as executing it (null otherwise).
*/
private String terminal(int i) {
if (i >= ls.size() || ls.get(i).kind() != StratListing.Kind.INSTR) {
return null;
}
int op = ls.get(i).op();
return op == StratOps.JUMP_POP ? "return" : op == StratOps.END || op == 0x8e ? mn(ls.get(i)) : null;
}
/** "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 {")
if (a.text.endsWith("loop {")
&& (a.text.equals("loop {") || a.text.matches("\\w+: 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 + ") {"));
String w = breakCondition(b.text);
lines.remove(i + 1);
// further "if (c) break" lines at the top: while (w && !c)
while (i + 1 < lines.size()
&& lines.get(i + 1).depth == a.depth + 1
&& lines.get(i + 1).text.startsWith("if (")
&& lines.get(i + 1).text.endsWith(") break")) {
w = andOperand(w) + " && " + andOperand(breakCondition(lines.get(i + 1).text));
lines.remove(i + 1);
}
String name = a.text.equals("loop {") ? "" : a.text.substring(0, a.text.length() - 6);
lines.set(i, new Line(b.offset, a.depth, name + "while (" + w + ") {"));
}
}
}
/** The loop condition that "if (c) break" leaves: !c. */
private static String breakCondition(String line) {
String c = line.substring(4, line.length() - 7);
return 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 + ")";
}
/** "} 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++) {
@@ -591,6 +890,59 @@ public final class StratPseudo {
}
}
/** "if (a) {" whose whole body is "if (b) {…}" becomes "if (a && b) {…}". */
private void andChains() {
for (int i = 0; i + 1 < lines.size(); ) {
Line a = lines.get(i), b = lines.get(i + 1);
int close = -1;
if (isPlainIf(a.text) && isPlainIf(b.text) && b.depth == a.depth + 1) {
for (int k = i + 2; k < lines.size(); k++) {
Line x = lines.get(k);
if (x.depth <= b.depth) {
close = x.depth == b.depth && x.text.equals("}") ? k : -1;
break;
}
}
}
if (close < 0
|| close + 1 >= lines.size()
|| !lines.get(close + 1).text.equals("}")
|| lines.get(close + 1).depth != a.depth) {
i++;
continue;
}
String c = andOperand(a.text.substring(4, a.text.length() - 3)) + " && "
+ andOperand(b.text.substring(4, b.text.length() - 3));
lines.set(i, new Line(a.offset, a.depth, "if (" + c + ") {"));
lines.remove(close + 1);
lines.remove(i + 1);
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));
}
}
}
/** c with everything inside brackets blanked out. */
private static String topLevel(String c) {
StringBuilder top = new StringBuilder();
int d = 0;
for (char ch : c.toCharArray()) {
d += ch == '(' ? 1 : ch == ')' ? -1 : 0;
top.append(d == 0 ? ch : ' ');
}
return top.toString();
}
private static boolean isPlainIf(String t) {
return t.startsWith("if (") && t.endsWith(") {") && balanced(t.substring(4, t.length() - 3));
}
private static String andOperand(String c) {
String t = topLevel(c);
return t.contains("||") || t.contains(" | ") || t.contains(" & ") ? "(" + c + ")" : c;
}
private static boolean nests(int a0, int a1, int b0, int b1) {
return a1 <= b0 || b1 <= a0 || (a0 >= b0 && a1 <= b1) || (b0 >= a0 && b1 <= a1);
}
@@ -686,7 +1038,9 @@ public final class StratPseudo {
|| op == StratOps.PUSH_IMM
|| op == StratOps.PUSH_ADDR
|| op == StratOps.PUSH_SOUND) {
push(off, a + (l.comment() != null && l.comment().startsWith("strat") ? " /*" + l.comment() + "*/" : ""));
// the script language reads a plain integer as units: raw words get the "r" suffix
String v = a.matches("-?[1-9]\\d*") ? a + "r" : a;
push(off, v + (l.comment() != null && l.comment().startsWith("strat") ? " /*" + l.comment() + "*/" : ""));
return;
}
if (op == 0x47 || op == 0x48) {
@@ -709,11 +1063,23 @@ public final class StratPseudo {
push(off, "!" + pop());
return;
}
if (condJump(op) && chainMember.contains(i)) {
if (pendingChain.isEmpty()) {
chainOff = begin(off);
}
pendingChain.add(strip(pop()));
return;
}
if (condJump(op)) {
String e = strip(pop());
boolean neg = op == 0x13 || op == 0x15;
String yield = op <= 0x13 ? " /*yield*/" : "";
int b0 = begin(off);
if (chains.containsKey(i)) {
e = chainCondition(i, chains.get(i), e);
neg = false;
b0 = chainOff;
}
String yield = op <= 0x13 ? " /*yield*/" : "";
flush();
Block lp = loopEnd.get(i);
String jumpIf = neg ? e : "!(" + e + ")";
@@ -723,8 +1089,9 @@ public final class StratPseudo {
} 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));
if (last != null && last.text.endsWith("do {") && last.depth == depth) {
String name = last.text.substring(0, last.text.length() - 4);
lines.set(lines.size() - 1, new Line(b0, depth, name + "do {} while (" + jumpIf + ")" + yield));
} else {
out(b0, "} while (" + jumpIf + ")" + yield);
}
@@ -0,0 +1,5 @@
# old mnemonic current mnemonic (old names still assemble; stored sources keep working)
flagEC10000 freezeOffCamera
flagEC2 touchable
flagEC100 noFloorPhysics
hudReset2 hudResetNoop
1 # old mnemonic current mnemonic (old names still assemble; stored sources keep working)
2 flagEC10000 freezeOffCamera
3 flagEC2 touchable
4 flagEC100 noFloorPhysics
5 hudReset2 hudResetNoop
@@ -46,11 +46,11 @@
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 = "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 -
0x2f freezeOffCamera 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 = "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 -
0x31 touchable 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 -
0x33 noFloorPhysics 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 -
@@ -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 FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. high -
0x14a hudResetNoop - 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 -
Can't render this file because it contains an unexpected character in line 36 and column 30.
@@ -213,4 +213,142 @@ class StratEditsTest {
}
throw new AssertionError();
}
/** Import in the same session behind custom types: they stay last, can be edited/deleted, creating is undoable. */
@Test
void customTypesAfterImportInTheSameSession() throws Exception {
Bases cd = cd();
StratEdits.init();
CrossImport.init();
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "imp", "t1l1m001"), cd), cd);
History h = new History(p.scene());
h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "CoinA")));
h.execute(Edits.placeStrat(p.scene(), "CoinA", 0, 30, 2, 30, 0.0));
Strat a = p.scene().strats().get(p.scene().strats().size() - 1);
CrossImport.importInto(p, List.of(new CrossImport.TypeRef("t1l2m001", "Spider", spiderCode(cd))), cd);
h.clear();
int units = StratEdits.analysis(p.scene()).img().units.size();
assertEquals(units - 1, StratEdits.firstCustomUnit(p.scene()), "the custom unit is the last one again");
assertEquals(null, StratEdits.rebuildBlocker(p.scene()));
assertEquals(entry(p, "CoinA"), a.code());
assertEquals("CoinA", nameAt(WadBuilder.build(p.scene()), a.code()));
assertNotNull(p.scene().catalog().type("Spider"));
assertTrue(StratEdits.analysis(p.scene()).code().errors.isEmpty());
assertArrayEquals(
StratEdits.chunk(p.reference()).data(),
StratEdits.chunk(p.scene()).data(),
"reference = scene");
// create (undoable), edit, delete
h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "CoinB")));
assertEquals(2, StratEdits.records(p).types().size());
h.undo();
assertEquals(1, StratEdits.records(p).types().size());
assertTrue(p.scene().catalog().type("CoinB") == null);
h.redo();
StratEdits.CustomType ta = StratEdits.records(p).types().get(0);
h.execute(TypeEdits.edit(p, 0, withSource(ta, ta.source().replace("push 5.625deg", "push 90deg"))));
assertEquals(0x400000, spin(p, "CoinA").value());
assertEquals(entry(p, "CoinA"), a.code());
byte[] wad = WadBuilder.build(p.scene());
Project q = ProjectIO.read(ProjectIO.write(p, cd), cd);
assertTrue(q.warnings().isEmpty(), q.warnings().toString());
assertArrayEquals(wad, WadBuilder.build(q.scene()), "reloaded project builds the same WAD");
h.execute(TypeEdits.delete(p, 0));
assertTrue(!p.scene().strats().contains(a));
h.undo();
assertEquals(entry(p, "CoinA"), a.code());
}
/** unit:N operands that point at custom types are stored by name and follow the type when indexes shift. */
@Test
void referencesBetweenCustomTypesAreSymbolic() throws Exception {
Bases cd = cd();
StratEdits.init();
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "refs", "t1l1m001"), cd), cd);
History h = new History(p.scene());
h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "First")));
h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "Target")));
int target = StratEdits.firstCustomUnit(p.scene()) + 1;
int off = entry(p, "Target")
- StratEdits.analysis(p.scene()).img().units.get(target).start();
String src =
"""
.unit RefB
.string "RefB"
RefB:
debugName "RefB"
address unit:%d+%d
pop
spin:
jmpYield spin
end
"""
.formatted(target, off);
StratCatalog.Variant v = p.scene().catalog().type("Coin").variant(0);
h.execute(TypeEdits.create(p, new StratEdits.CustomType("RefB", null, "RefB", src, v, "Coin")));
assertTrue(StratEdits.records(p).types().get(2).source().contains("address unit:Target+" + off));
int tu = StratEdits.analysis(p.scene()).img().unitOf(entry(p, "Target"));
assertEquals(StratEdits.analysis(p.scene()).img().units.get(tu).start() + off, addressIn(p, "RefB"));
// delete the first type: Target moves one unit down, RefB still points at it
h.execute(TypeEdits.delete(p, 0));
tu = StratEdits.analysis(p.scene()).img().unitOf(entry(p, "Target"));
assertEquals(StratEdits.analysis(p.scene()).img().units.get(tu).start() + off, addressIn(p, "RefB"));
int ru = StratEdits.analysis(p.scene()).img().unitOf(entry(p, "RefB"));
assertTrue(StratEdits.listing(p.scene(), ru).text().contains("unit:Target+0x" + Integer.toHexString(off)));
// renaming the target renames the reference; deleting it is refused
StratEdits.CustomType t = StratEdits.records(p).types().get(0);
h.execute(TypeEdits.edit(
p, 0, new StratEdits.CustomType("Goal", t.basedOn(), t.entryLabel(), t.source(), t.proto())));
assertTrue(StratEdits.records(p).types().get(1).source().contains("unit:Goal+" + off));
List<StratEdits.CustomType> rest = List.of(StratEdits.records(p).types().get(1));
List<String> problems = StratEdits.checkTypes(p.scene(), rest, -1, null);
assertNotNull(problems);
assertTrue(problems.get(0).contains("unknown unit name"), problems.toString());
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()));
}
/** Value of the first address operand in a type's unit. */
static int addressIn(Project p, String type) {
StratEdits.Analysis a = StratEdits.analysis(p.scene());
int u = a.img().unitOf(entry(p, type));
for (StratCode.Site s : a.code().addrSites) {
if (a.img().unitOf(s.offset()) == u) {
return s.value();
}
}
throw new AssertionError("no address operand in " + type);
}
/** Edit can switch the type the fields come from; it is saved. */
@Test
void editChangesTheFieldsType() throws Exception {
Bases cd = cd();
StratEdits.init();
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "fields", "t1l1m001"), cd), cd);
History h = new History(p.scene());
StratEdits.CustomType t = StratEdits.cloneOf(p.scene(), "Coin", "CoinF");
h.execute(TypeEdits.create(p, t));
String other = p.scene().catalog().types().stream()
.map(StratCatalog.Type::name)
.filter(n -> !n.equals("Coin")
&& !n.equals("CoinF")
&& p.scene().catalog().type(n).variant(0).nVars()
!= p.scene().catalog().type("Coin").variant(0).nVars())
.findFirst()
.orElseThrow();
StratCatalog.Variant ov = p.scene().catalog().type(other).variant(0);
h.execute(TypeEdits.edit(
p, 0, new StratEdits.CustomType("CoinF", "Coin", t.entryLabel(), t.source(), ov, other)));
assertEquals(ov.nVars(), p.scene().catalog().type("CoinF").variant(0).nVars());
Project q = ProjectIO.read(ProjectIO.write(p, cd), cd);
assertEquals(other, StratEdits.records(q).types().get(0).fieldsFrom());
assertEquals("Coin", StratEdits.records(q).types().get(0).basedOn());
h.undo();
assertEquals(
p.scene().catalog().type("Coin").variant(0).nVars(),
p.scene().catalog().type("CoinF").variant(0).nVars());
}
}
@@ -69,9 +69,64 @@ public final class StratsGameLevel {
}
""";
/** Script language 2: switch over a state that an event handler advances, unit numbers, && / ||. */
static final String TOUCHER =
"""
// grows each time the player touches it: 1x, 2x, 3x, then spins fast
strat Toucher {
alias hits = v5
alias cooldown = v6
setModel(model:5)
collisionOn<0x20000>()
collRadius(0.6)
self.y = self.y + 1
hits = 0
cooldown = 0
on touched {
if (cooldown == 0 && hits < 3) {
hits += 1
cooldown = 20
soundPlay(sound:6)
}
}
loop {
if (cooldown > 0 || hits > 9) {
cooldown -= 1
}
switch (hits) {
case 0:
scale(1)
turnLeft(2deg)
break
case 1:
scale(2)
turnLeft(4deg)
break
case 2:
scale(3)
turnLeft(8deg)
break
default:
scale(3)
turnLeft(45deg)
}
yield
}
}
""";
/** The same with a timer event (type 0x40) instead of touched: grows without the player. */
static final String TICKER = TOUCHER.replace("strat Toucher", "strat Ticker")
.replace("on touched", "on event(0x40, 30)")
.replace(" collisionOn<0x20000>()\n collRadius(0.6)\n", "");
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("v2")) {
v2(cd, Path.of(a[0]));
return;
}
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());
@@ -119,4 +174,42 @@ public final class StratsGameLevel {
System.out.println("saved " + a[0] + " (" + p.scene().strats().size() + " objects, start " + sx + "," + sy + ","
+ sz + ")");
}
/**
* Village 1: a custom type, then an import (Spider) in the same session, then the custom type
* edited (3x scale) and a compiled script type created (Toucher, 2.5 cells ahead of the start).
*/
static void v2(Bases cd, Path out) throws Exception {
com.eliasmueller.llamamod.editor.tools.catalog.CrossImport.init();
Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "strats2", "t1l1m001"), cd), cd);
History h = new History(p.scene());
StratEdits.CustomType big = StratEdits.cloneOf(p.scene(), "Coin", "BigCoin");
h.execute(TypeEdits.create(p, big));
com.eliasmueller.llamamod.editor.tools.catalog.CrossImport.importInto(
p,
List.of(new com.eliasmueller.llamamod.editor.tools.catalog.CrossImport.TypeRef(
"t1l2m001", "Spider", StratEditsTest.spiderCode(cd))),
cd);
h.clear();
String src =
big.source().replace(" setModel\n", " setModel\n pushShort 3.0\n scale\n");
h.execute(TypeEdits.edit(p, 0, big.withSource(src)));
String asm = StratCompiler.compile(TOUCHER, StratEdits.paramNames("Coin"));
h.execute(TypeEdits.create(p, new StratEdits.CustomType("Toucher", null, "Toucher", asm, big.proto(), "Coin")));
Strat start = p.scene().playerStart();
double sx = start.x() / 4096.0, sy = start.y() / 4096.0, sz = start.z() / 4096.0;
String tick = StratCompiler.compile(TICKER, StratEdits.paramNames("Coin"));
h.execute(TypeEdits.create(p, new StratEdits.CustomType("Ticker", null, "Ticker", tick, big.proto(), "Coin")));
// the start view looks along +z (the row of the first in-game test is in front of it)
h.execute(Edits.placeStrat(p.scene(), "Toucher", 0, sx, sy, sz + 2.5, 0.0));
h.execute(Edits.placeStrat(p.scene(), "Ticker", 0, 30.0, 8.0, 64.6, 0.0));
String[] types = {"Coin", "BigCoin", "Spider"};
for (int i = 0; i < types.length; i++) {
h.execute(Edits.placeStrat(p.scene(), types[i], 0, 31.5 + 1.5 * i, 8.0, 64.6, 0.0));
}
System.out.println(
"types: " + StratEdits.customNames(p) + ", start " + sx + "," + sy + "," + sz + " yaw " + start.ry());
ProjectIO.save(p, out, cd);
System.out.println("saved " + out);
}
}
@@ -44,12 +44,13 @@ public final class StratsTour {
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())));
String asm = StratCompiler.compile(StratsGameLevel.TOUCHER, StratEdits.paramNames("Coin"));
w.execute(TypeEdits.create(
p, new StratEdits.CustomType("Toucher", null, "Toucher", asm, big.proto(), "Coin")));
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(), "Toucher", 0, x + 1, y, z + 2, 0.0));
w.execute(Edits.placeStrat(w.scene(), "Toucher", 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);
@@ -50,7 +50,7 @@ class StratCompilerTest {
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("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()"),
@@ -60,13 +60,176 @@ class StratCompilerTest {
}
@Test
void numbersMatchTheAssembly() {
String asm = StratCompiler.compile("strat N { v0 = 1.5\n v1 = 100.0\n v2 = 90deg\n v3 = -3 }");
void plainIntegersAreUnits() {
String asm = StratCompiler.compile(
"strat N { v0 = 1.5\n v1 = 100\n v2 = 90deg\n v3 = -3\n v4 = 215r\n v5 = 0x10\n v6 = -7r }");
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.contains("pushShort -3.0\n"), asm);
assertTrue(asm.contains("pushShort 215\n"), asm);
assertTrue(asm.contains("pushShort 0x10\n"), asm);
assertTrue(asm.contains("pushShort -7\n"), asm);
assertTrue(asm.trim().endsWith("end"), asm);
// the old rule: plain integers are raw words
String old = StratCompiler.compile("use rawNumbers\nstrat N { v3 = -3\n v4 = 10 }");
assertTrue(old.contains("pushShort -3\n") && old.contains("pushShort 10\n"), old);
}
@Test
void scriptsCompiledWithTheOldRuleKeepTheirMeaning() {
// generated before language version 2: no marker line behind the embedded source
String asm = ";| strat N {\n;| v0 = 10\n;| }\n .string \"N\"\nN:\n end\n";
String src = StratCompiler.embeddedSource(asm);
assertTrue(src.startsWith(StratCompiler.RAW_NUMBERS), src);
assertTrue(StratCompiler.compile(src).contains("pushShort 10\n"));
// new output: the source comes back unchanged
String v2 = StratCompiler.compile("strat N {\n v0 = 10\n}");
assertEquals("strat N {\n v0 = 10\n}\n", StratCompiler.embeddedSource(v2));
}
static String view(String script, String entry) {
return StratPseudo.of(StratPseudoTest.listing(StratCompiler.compile(script), entry))
.text();
}
@Test
void foldsConstants() {
String asm = StratCompiler.compile("strat N { v0 = 2 * 3 + 0.5\n v1 = -(1 << 4)\n v2 = 90deg / 2 }");
assertTrue(asm.contains("pushShort 6.5\n"), asm);
assertTrue(asm.contains("push -16.0\n"), asm);
assertTrue(asm.contains("push 512.0\n"), asm); // 45deg
assertFalse(asm.contains("mul") || asm.contains("add\n") || asm.contains("shl"), asm);
}
@Test
void shortCircuitConditions() {
String script =
"""
strat C {
if (v0 > 2 && v1 || !v2) {
turnLeft(4deg)
}
while (v3 < 3 && v4) {
v3 += 1
}
}
""";
String asm = StratCompiler.compile(script);
assertFalse(asm.contains(" and\n") || asm.contains(" or\n"), asm);
String v = view(script, "C");
assertFalse(v.contains("goto"), v);
assertTrue(v.contains("if ((v0 > 2.0 && v1) || !v2) {\n turnLeft(4deg)\n}"), v);
assertTrue(v.contains("while (v3 < 3.0 && v4) {\n v3 += 1.0\n}"), v);
// value context: both sides evaluated (the and/or opcodes)
assertTrue(StratCompiler.compile("strat D { v0 = v1 && v2 }").contains(" and\n"));
}
@Test
void switchWithFallThroughAndDefault() {
String script =
"""
strat S {
loop {
switch (v0) {
case 1:
v1 = 2
break
case 2:
case 3:
v1 = 100000
default:
v1 = 0
}
yield
}
}
""";
String asm = StratCompiler.compile(script);
assertTrue(asm.contains(".switch 3\n .case 1.0, "), asm);
String v = view(script, "S");
assertFalse(v.contains("goto"), v);
assertTrue(
v.contains("case 1.0:\n v1 = 2.0\n break\n case 2.0:\n case 3.0:\n"
+ " v1 = 100000.0\n default:\n v1 = 0\n }"),
v);
CompileExceptionCheck.fails("strat S { switch (v0) { case v1: end } }", "constant");
CompileExceptionCheck.fails("strat S { switch (v0) { case 1: case 1: end } }", "duplicate");
}
@Test
void eventHandlers() {
String script =
"""
strat E {
on touched {
soundPlay(sound:6)
remove
}
on event(0x40, 30) slot 3 {
v0 += 1
}
loop { yield }
}
""";
String asm = StratCompiler.compile(script);
assertTrue(asm.contains("onEvent ev_1, 0\n"), asm);
assertTrue(asm.contains("onEvent ev_2, 3\n"), asm);
assertTrue(asm.indexOf(".event 4, 0, on_1") < asm.indexOf("\nE:\n"), "entries before the code: " + asm);
String v = view(script, "E");
assertTrue(v.contains("onEvent ev_0000, 0 // event type 4 → "), v);
assertTrue(v.contains("soundPlay(sound:6)\nremove\n"), v);
assertTrue(v.contains("v0 += 1.0\nreturn\n"), v);
assertFalse(v.contains("goto"), v);
}
@Test
void parameterNamesFromTheSchema() {
String asm = StratCompiler.compile(
"strat P { if (snapToFloor) { self.y = self.floorHeight } }", java.util.Map.of("snapToFloor", "v0"));
assertTrue(asm.contains("pushVar v0\n"), asm);
// the script's own alias wins
asm = StratCompiler.compile(
"strat P { alias snapToFloor = v4\n v0 = snapToFloor }", java.util.Map.of("snapToFloor", "v0"));
assertTrue(asm.contains("pushVar v4\n"), asm);
}
/** Old and new opcode names assemble to the same words (op-aliases.tsv). */
@Test
void renamedOpcodesKeepTheirOldNames() {
StratAssembler.Layout l =
new StratAssembler.Layout(16, java.util.List.of(), java.util.List.of(), java.util.List.of());
int[] a = StratAssembler.assemble("x:\n flagEC10000 1\n flagEC2 1\n flagEC100 0\n hudReset2\n end", l)
.words();
int[] b = StratAssembler.assemble(
"x:\n freezeOffCamera 1\n touchable 1\n noFloorPhysics 0\n hudResetNoop\n end", l)
.words();
org.junit.jupiter.api.Assertions.assertArrayEquals(a, b);
assertEquals("freezeOffCamera", OpTable.get().byName("flagEC10000").name());
assertEquals("freezeOffCamera", OpTable.get().alias("FLAGEC10000").name());
assertTrue(StratCompiler.compile("strat A { flagEC10000<1>() }").contains("freezeOffCamera 1"));
}
/** unit:Name resolves through the layout's unit names. */
@Test
void unitNames() {
StratAssembler.Layout l = new StratAssembler.Layout(
100, java.util.List.of(), java.util.List.of(), java.util.List.of(16, 40))
.withUnitNames(java.util.Map.of("Other", 1));
int[] w =
StratAssembler.assemble("x:\n address unit:Other+0x8\n end", l).words();
assertEquals(48, w[1]);
StratAssembler.AsmException e = assertThrows(
StratAssembler.AsmException.class, () -> StratAssembler.assemble("x:\n address unit:Nope\n end", l));
assertTrue(e.getMessage().contains("unknown unit name"), e.getMessage());
}
static final class CompileExceptionCheck {
static void fails(String src, String part) {
StratCompiler.CompileException e =
assertThrows(StratCompiler.CompileException.class, () -> StratCompiler.compile(src));
assertTrue(e.getMessage().contains(part), e.getMessage());
}
}
@Test
@@ -125,6 +125,50 @@ class StratPseudoTest {
assertEquals(List.of(toList(li.words())), List.of(toList(back.words())));
}
/** Jumps out of nested loops and to a return: labeled break/continue and return instead of goto. */
@Test
void multiLevelBreaksAndJumpsToReturn() {
String src =
"""
Main:
outer:
pushVar v0
jz skip
inner:
pushVar v1
jz next
jmp out ; break out of both loops
next:
pushVar v5
jz c2
jmp ret ; leaves both loops to the return
c2:
pushVar v2
jz cont
jmp outer ; continue the outer loop
cont:
jmpYield inner
skip:
jmpYield outer
out:
pushVar v3
jz fin
jmp ret
fin:
push 1.0
popVar v4
ret:
return
""";
String t = StratPseudo.of(listing(src, "Main")).text();
assertFalse(t.contains("goto"), t);
assertTrue(t.contains("entry_0000: loop {"), t);
assertTrue(t.contains("break entry_0000"), t);
assertTrue(t.contains("continue entry_0000"), t);
assertTrue(t.contains("if (v5) {\n return\n }"), t);
assertTrue(t.contains("if (!(v3)) {\n v4 = 1.0\n}\nreturn"), t);
}
static List<Integer> toList(int[] a) {
return java.util.Arrays.stream(a).boxed().toList();
}
@@ -132,7 +176,7 @@ class StratPseudoTest {
/** 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;
int gotos = 0, units = 0, labeled = 0;
Pattern g = Pattern.compile("goto (\\w+)");
for (Path p : StratRoundTripTest.levels()) {
StratRoundTripTest.Loaded l = StratRoundTripTest.load(p);
@@ -144,7 +188,7 @@ class StratPseudoTest {
StratPseudo ps = StratPseudo.of(li);
units++;
int open = 0;
Set<String> labels = new HashSet<>();
Set<String> labels = new HashSet<>(), loopNames = new HashSet<>();
for (StratPseudo.Line x : ps.lines) {
String s = x.text().replaceAll("\\s*//.*$", "");
if (s.startsWith("}")) {
@@ -157,6 +201,10 @@ class StratPseudoTest {
if (s.matches("\\w+:")) {
labels.add(s.substring(0, s.length() - 1));
}
Matcher ln = Pattern.compile("^(\\w+): (loop|do|while)").matcher(s.trim());
if (ln.find()) {
loopNames.add(ln.group(1));
}
}
assertEquals(0, open, p + " unit " + u + ": unclosed block");
for (StratPseudo.Line x : ps.lines) {
@@ -165,11 +213,16 @@ class StratPseudoTest {
gotos++;
assertTrue(labels.contains(m.group(1)), p + " unit " + u + ": goto without label " + x.text());
}
Matcher b = Pattern.compile("(break|continue) (\\w+)").matcher(x.text());
if (b.find()) {
labeled++;
assertTrue(loopNames.contains(b.group(2)), p + " unit " + u + ": no loop named in " + x.text());
}
}
}
}
System.out.printf("script view: %d units, %d gotos%n", units, gotos);
System.out.printf("script view: %d units, %d gotos, %d labeled break/continue%n", units, gotos, labeled);
assertTrue(units >= 1400);
assertTrue(gotos < 500, "gotos: " + gotos);
assertTrue(gotos <= 40, "gotos: " + gotos);
}
}