···18181919import javax.imageio.ImageIO;
2020import javax.swing.AbstractButton;
2121+import javax.swing.BorderFactory;
2122import javax.swing.ImageIcon;
2223import javax.swing.JButton;
2324import javax.swing.JLabel;
···39404041import org.apache.commons.io.FilenameUtils;
41424242-import app.build.BuildEnvironment;
4343-import app.build.BuildOutputListener;
4444-import app.build.BuildResult;
4545-import app.build.NixEnvironment;
4646-import app.build.WslNixOsEnvironment;
4343+import project.engine.BuildEnvironment;
4444+import project.engine.BuildOutputListener;
4545+import project.engine.BuildResult;
4746import app.config.Options;
4847import app.input.InvalidInputException;
4948import app.pane.Dock;
···5150import assets.AssetManager;
5251import assets.ExpectedAsset;
5352import common.BaseEditor;
5353+import project.engine.Engine;
5454import game.globals.editor.GlobalsEditor;
5555import game.map.Map;
5656import game.map.compiler.BuildException;
···6464import game.texture.editor.ImageEditor;
6565import game.worldmap.WorldMapEditor;
6666import net.miginfocom.swing.MigLayout;
6767+import project.Project;
6768import util.Logger;
6869import util.Priority;
6970···101102 setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
102103 setMinimumSize(new Dimension(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT));
103104104104-105105- // Display current project path (read-only, restart app to change projects)
106106- JLabel projectPathLabel = new JLabel(Environment.getProjectDirectory().getAbsolutePath());
107107- SwingUtils.setFontSize(projectPathLabel, 11);
105105+ // TODO: click this to change project
106106+ JLabel projectIdLabel = new JLabel(Environment.getProject().getManifest().getId());
107107+ SwingUtils.setFontSize(projectIdLabel, 11);
108108109109 JButton mapEditorButton = new JButton("Map Editor");
110110 trySetIcon(mapEditorButton, ExpectedAsset.ICON_MAP_EDITOR);
···162162 });
163163 buttons.add(themesMenuButton);
164164165165- // Extract Data button
166166- JButton extractDataButton = new JButton("Extract Map Data");
167167- trySetIcon(extractDataButton, ExpectedAsset.ICON_EXTRACT);
168168- SwingUtils.setFontSize(extractDataButton, 12);
169169- extractDataButton.addActionListener((e) -> {
170170- action_extractMapData();
171171- });
172172- buttons.add(extractDataButton);
173173-174165 // Build Project button
175166 JButton buildProjectButton = new JButton("Build Project");
176167 trySetIcon(buildProjectButton, ExpectedAsset.ICON_GOLD);
···222213223214 // Left pane - buttons panel
224215 JPanel leftPane = new JPanel(new MigLayout("fill, ins 8, wrap 1"));
225225- leftPane.add(new JLabel("Project:"), "split 2");
226226- leftPane.add(projectPathLabel, "pushx, growx, gapbottom 8, wrap");
227216228217 JPanel buttonsPanel = new JPanel(new MigLayout("fillx, wrap 1, hidemode 3"));
229218 buttonsPanel.add(mapEditorButton, "growx");
···233222 buttonsPanel.add(worldEditorButton, "growx");
234223 buttonsPanel.add(imageEditorButton, "growx");
235224 buttonsPanel.add(themesMenuButton, "growx");
236236- buttonsPanel.add(extractDataButton, "growx");
237225 buttonsPanel.add(openConfigDirButton, "growx");
238226 buttonsPanel.add(openProjectDirButton, "growx");
239227 buttonsPanel.add(buildProjectButton, "growx, gaptop 8");
···272260 verticalSplit.setDividerSize(4);
273261 verticalSplit.setResizeWeight(1.0); // Give most space to top pane
274262263263+ // Status bar
264264+ JLabel statusBarLabel = new JLabel("Status bar");
265265+ statusBarLabel.setBorder(BorderFactory.createCompoundBorder(
266266+ BorderFactory.createMatteBorder(1, 0, 0, 0, UIManager.getColor("Separator.foreground")),
267267+ BorderFactory.createEmptyBorder(2, 8, 2, 8)
268268+ ));
269269+ SwingUtils.setFontSize(statusBarLabel, 11);
270270+275271 // Layout
276276- setLayout(new MigLayout("fill, ins 0"));
272272+ setLayout(new MigLayout("fill, ins 0, wrap"));
277273 add(verticalSplit, "grow, push");
274274+ add(statusBarLabel, "growx, h 20!");
278275279276 pack();
280277 setLocationRelativeTo(null);
···376373 });
377374 }
378375379379- private void action_extractMapData()
380380- {
381381- new EditorWorker(() -> {
382382- if (!Environment.projectConfig.getBoolean(Options.ExtractedMapData)) {
383383- int choice = SwingUtils.getConfirmDialog()
384384- .setTitle("Extraction Warning")
385385- .setMessage("This action will modify the source files of almost every map.",
386386- "Consider creating a backup or committing any changes before proceeding.",
387387- "Are you ready to begin extracting?")
388388- .setMessageType(JOptionPane.WARNING_MESSAGE)
389389- .setOptionsType(JOptionPane.YES_NO_CANCEL_OPTION)
390390- .choose();
391391-392392- if (choice == JOptionPane.YES_OPTION) {
393393- Logger.log("Extracting map data...", Priority.MILESTONE);
394394- Extractor.extractAll();
395395-396396- SwingUtils.getMessageDialog()
397397- .setTitle("All Data Extracted")
398398- .setMessage("Complete!")
399399- .setMessageType(JOptionPane.PLAIN_MESSAGE)
400400- .show();
401401-402402- Environment.projectConfig.setBoolean(Options.ExtractedMapData, true);
403403- Environment.projectConfig.saveConfigFile();
404404- }
405405- }
406406- else {
407407- SwingUtils.getWarningDialog()
408408- .setTitle("Data Already Extracted")
409409- .setMessage("Map data has already been extracted for this project.")
410410- .show();
411411- }
412412- });
413413- }
414414-415376 private void action_buildProject()
416377 {
417378 BuildOutputDialog dialog = new BuildOutputDialog(this);
···607568 break;
608569609570 case "-BUILDPROJECT":
610610- BuildEnvironment env = null;
611571 try {
612612- if (Environment.isWindows()) {
613613- env = new WslNixOsEnvironment();
614614- }
615615- else {
616616- env = new NixEnvironment();
617617- }
618618-619619- BuildResult result = env.configure(BuildOutputListener.toLogger());
620620- if (result.isSuccess()) {
621621- result = env.build(BuildOutputListener.toLogger());
622622- }
623623-624624- if (!result.isSuccess()) {
625625- throw new StarRodException("Build failed: " + result.getErrorMessage().orElse("unknown"));
626626- }
627627- Logger.log("ROM built: " + result.getOutputRom().get());
628628- }
629629- catch (app.build.BuildException e) {
630630- if (!e.isSilent()) {
631631- Logger.logError("Build environment error: " + e.getMessage());
632632- }
633633- else {
634634- Logger.log(e.getMessage());
635635- }
572572+ Environment.getProject().build();
636573 }
637637- catch (IOException e) {
638638- Logger.logError("Build environment error: " + e.getMessage());
574574+ catch (Exception e) {
575575+ Logger.logError("Build failed: " + e.getMessage());
639576 }
640577 break;
641578···647584648585 private static final void trySetIcon(AbstractButton button, ExpectedAsset asset)
649586 {
650650- if (!(new File(Directories.getDumpPath())).exists()) {
587587+ if (Directories.getDumpPath() == null || !(new File(Directories.getDumpPath())).exists()) {
651588 Logger.log("Dump directory could not be found.");
652589 SwingUtils.addBorderPadding(button);
653590 return;
-61
src/main/java/app/build/BuildEnvironment.java
···11-package app.build;
22-33-import java.io.IOException;
44-import java.util.concurrent.CompletableFuture;
55-66-/**
77- * Interface for building Paper Mario decomp projects.
88- * Implementations handle different build environments (native Nix, WSL+NixOS).
99- */
1010-public interface BuildEnvironment
1111-{
1212- /**
1313- * Returns a human-readable name for this environment type.
1414- */
1515- String getName();
1616-1717- /**
1818- * Runs configure (./configure).
1919- * @param listener Callback for real-time build output
2020- * @return The result of the configure operation
2121- * @throws BuildException If the build environment is not properly set up
2222- * @throws IOException If an I/O error occurs
2323- */
2424- BuildResult configure(BuildOutputListener listener) throws BuildException, IOException;
2525-2626- /**
2727- * Builds the project (ninja).
2828- * @param listener Callback for real-time build output
2929- * @return The result of the build operation
3030- * @throws BuildException If the build environment is not properly set up
3131- * @throws IOException If an I/O error occurs
3232- */
3333- BuildResult build(BuildOutputListener listener) throws BuildException, IOException;
3434-3535- /**
3636- * Cleans the build directory (./configure --clean).
3737- * @param listener Callback for real-time build output
3838- * @return The result of the clean operation
3939- * @throws BuildException If the build environment is not properly set up
4040- * @throws IOException If an I/O error occurs
4141- */
4242- BuildResult clean(BuildOutputListener listener) throws BuildException, IOException;
4343-4444- /**
4545- * Builds the project asynchronously.
4646- * @param listener Callback for real-time build output
4747- * @return A CompletableFuture that completes with the build result
4848- */
4949- CompletableFuture<BuildResult> buildAsync(BuildOutputListener listener);
5050-5151- /**
5252- * Cancels any running build operation.
5353- * @return True if a build was cancelled, false if no build was running
5454- */
5555- boolean cancel();
5656-5757- /**
5858- * Returns whether a build is currently in progress.
5959- */
6060- boolean isBuilding();
6161-}
···3838 }
39394040 @Override
4141- public synchronized List<Project> getAllProjects()
4141+ public synchronized List<ProjectListing> getAllProjects()
4242 {
4343 List<ProjectData> dataList = loadProjectData();
4444- List<Project> projects = new ArrayList<>();
4444+ List<ProjectListing> projects = new ArrayList<>();
45454646- // Convert to Project objects, filtering out invalid entries
4646+ // Convert to ProjectListing objects, filtering out invalid entries
4747 Iterator<ProjectData> iter = dataList.iterator();
4848 boolean modified = false;
4949···5151 ProjectData data = iter.next();
52525353 try {
5454- projects.add(new Project(new File(data.path), data.lastOpened));
5454+ projects.add(new ProjectListing(new File(data.path), data.lastOpened));
5555 } catch (IOException | KdlParseException e) {
5656 Logger.logWarning("Ignoring invalid project: " + data.path);
5757 iter.remove();
···7171 }
72727373 @Override
7474- public synchronized void addProject(Project project)
7474+ public synchronized void addProject(ProjectListing listing)
7575 {
7676 List<ProjectData> dataList = loadProjectData();
77777878 // Remove existing entry with same path (will be re-added with new timestamp)
7979- String absolutePath = project.getPath();
7979+ String absolutePath = listing.getPath();
8080 dataList.removeIf(data -> data.path.equals(absolutePath));
81818282 // Add new entry
8383 ProjectData newData = new ProjectData();
8484 newData.path = absolutePath;
8585- newData.lastOpened = project.getLastOpened();
8585+ newData.lastOpened = listing.getLastOpened();
8686 dataList.add(0, newData); // Add to beginning (most recent)
87878888 saveProjectData(dataList);
8989 }
90909191 @Override
9292- public synchronized void removeProject(Project project)
9292+ public synchronized void removeProject(ProjectListing listing)
9393 {
9494 List<ProjectData> dataList = loadProjectData();
9595- String absolutePath = project.getPath();
9595+ String absolutePath = listing.getPath();
9696 dataList.removeIf(data -> data.path.equals(absolutePath));
9797 saveProjectData(dataList);
9898 }
9999100100 @Override
101101- public synchronized void updateLastOpened(Project project)
101101+ public synchronized void updateLastOpened(ProjectListing listing)
102102 {
103103 List<ProjectData> dataList = loadProjectData();
104104- String absolutePath = project.getPath();
104104+ String absolutePath = listing.getPath();
105105106106 for (ProjectData data : dataList) {
107107 if (data.path.equals(absolutePath)) {
+32-2
src/main/java/project/Manifest.java
···1515 private final File file;
1616 private final KdlDocument doc;
17171818- public Manifest(Project project) throws IOException, KdlParseException {
1919- file = new File(project.getPath(), FILENAME);
1818+ public Manifest(File directory) throws IOException, KdlParseException {
1919+ file = new File(directory, FILENAME);
20202121 if (!file.exists()) {
2222 throw new IOException(FILENAME + " does not exist");
···24242525 var parser = KdlParser.v2();
2626 doc = parser.parse(Path.of(file.getAbsolutePath()));
2727+2828+ if (!hasEngine())
2929+ throw new IOException(FILENAME + " is missing required 'engine' node");
2730 }
2831 public String toString() {
2932 return "Manifest(" + file.getPath() + ")";
···3538 .findFirst()
3639 .map(n -> n.arguments().get(0).value().toString())
3740 .orElse(file.getParentFile().getName());
4141+ }
4242+4343+ public String getId() {
4444+ return doc.nodes().stream()
4545+ .filter(n -> n.name().equals("id"))
4646+ .findFirst()
4747+ .map(n -> n.arguments().get(0).value().toString())
4848+ .orElse(null);
4949+ }
5050+5151+ private boolean hasEngine() {
5252+ return doc.nodes().stream()
5353+ .anyMatch(n -> n.name().equals("engine"));
5454+ }
5555+5656+ /**
5757+ * Returns the engine ref (git ref to checkout).
5858+ * Defaults to "main" if the ref property is omitted.
5959+ */
6060+ public String getEngineRef() {
6161+ return doc.nodes().stream()
6262+ .filter(n -> n.name().equals("engine"))
6363+ .findFirst()
6464+ .map(n -> n.getProperty("ref")
6565+ .map(v -> v.value().toString())
6666+ .orElse("main"))
6767+ .orElse("main");
3868 }
3969}
+25-70
src/main/java/project/Project.java
···88import org.apache.commons.io.FileUtils;
991010import dev.kdl.parse.KdlParseException;
1111+import project.engine.BuildException;
1212+import project.engine.Engine;
11131212-public class Project implements Comparable<Project>
1414+/**
1515+ * A fully-loaded project with an initialized engine.
1616+ * Extends {@link ProjectListing} so it can be used anywhere a listing is expected.
1717+ */
1818+public class Project extends ProjectListing
1319{
1414- private final File directory;
1515- private final long lastOpened; // TODO: move this, Comparable, and compareTo to a new class
1616- private final Manifest manifest;
1717-1818- /** Loads a project from a directory. */
1919- public Project(File path, long lastOpened) throws IOException, KdlParseException
2020- {
2121- if (!path.isDirectory())
2222- throw new IllegalArgumentException("Project path must be a directory: " + path);
2323- this.directory = path.getAbsoluteFile();
2424- this.lastOpened = lastOpened;
2525- this.manifest = new Manifest(this);
2626- }
2020+ private final Engine engine;
27212828- /** Loads a project from a directory. */
2222+ /** Loads a project from a directory, initializing the engine. */
2923 public Project(File path) throws IOException, KdlParseException
3024 {
3131- this(path, System.currentTimeMillis());
3232- }
3333-3434- public String getPath()
3535- {
3636- return directory.getPath();
3737- }
3838-3939- public File getDirectory()
4040- {
4141- return directory;
2525+ super(path);
2626+ try {
2727+ this.engine = Engine.forProject(this);
2828+ }
2929+ catch (BuildException e) {
3030+ throw new IOException("Failed to initialize engine: " + e.getMessage(), e);
3131+ }
4232 }
43334444- public long getLastOpened()
3434+ public Engine getEngine()
4535 {
4646- return lastOpened;
4747- }
4848-4949- public String getName()
5050- {
5151- return manifest.getName();
5252- }
5353-5454- public Manifest getManifest()
5555- {
5656- return manifest;
3636+ return engine;
5737 }
58385939 /** Creates a new project from a template. */
6060- public static Project create(File path, String template, String id, String name) throws IOException, KdlParseException
4040+ public static ProjectListing create(File path, String template, String id, String name) throws IOException, KdlParseException
6141 {
6242 if (!path.exists())
6343 path.mkdirs();
···7454 File manifestFile = new File(path, Manifest.FILENAME);
7555 if (manifestFile.exists()) {
7656 String content = FileUtils.readFileToString(manifestFile, "UTF-8");
7777- content = content.replace("$PROJECT_ID", id);
7878- content = content.replace("$PROJECT_NAME", name);
7979- content = content.replace("$PROJECT_DESCRIPTION", "");
5757+ content = content.replace("$PROJECT_ID", '"' + id + '"');
5858+ content = content.replace("$PROJECT_NAME", '"' + name.replace('"', '\\') + '"');
5959+ content = content.replace("$PROJECT_DESCRIPTION", '"' + "An amazing mod of Paper Mario" + '"'); // TODO: ui
8060 FileUtils.writeStringToFile(manifestFile, content, "UTF-8");
8161 }
82628383- return new Project(path);
6363+ return new ProjectListing(path);
8464 }
85658686- @Override
8787- public int compareTo(Project other)
6666+ public void build()
8867 {
8989- // Sort by lastOpened descending (most recent first)
9090- return Long.compare(other.lastOpened, this.lastOpened);
9191- }
9292-9393- @Override
9494- public boolean equals(Object obj)
9595- {
9696- if (this == obj)
9797- return true;
9898- if (obj == null || getClass() != obj.getClass())
9999- return false;
100100- Project other = (Project) obj;
101101- return directory.equals(other.directory);
102102- }
103103-104104- @Override
105105- public int hashCode()
106106- {
107107- return directory.hashCode();
108108- }
109109-110110- @Override
111111- public String toString()
112112- {
113113- return getName() + " (" + directory.getAbsolutePath() + ")";
6868+ // TODO
11469 }
11570}
+85
src/main/java/project/ProjectListing.java
···11+package project;
22+33+import java.io.File;
44+import java.io.IOException;
55+66+import dev.kdl.parse.KdlParseException;
77+88+/**
99+ * Lightweight project metadata for display in the project switcher.
1010+ * Parses the manifest for name/path but does not initialize the engine.
1111+ */
1212+public class ProjectListing implements Comparable<ProjectListing>
1313+{
1414+ private final File directory;
1515+ private final long lastOpened;
1616+ private final Manifest manifest;
1717+1818+ public ProjectListing(File path, long lastOpened) throws IOException, KdlParseException
1919+ {
2020+ if (!path.isDirectory())
2121+ throw new IllegalArgumentException("Project path must be a directory: " + path);
2222+ this.directory = path.getAbsoluteFile();
2323+ this.lastOpened = lastOpened;
2424+ this.manifest = new Manifest(this.directory);
2525+ }
2626+2727+ public ProjectListing(File path) throws IOException, KdlParseException
2828+ {
2929+ this(path, System.currentTimeMillis());
3030+ }
3131+3232+ public String getPath()
3333+ {
3434+ return directory.getPath();
3535+ }
3636+3737+ public File getDirectory()
3838+ {
3939+ return directory;
4040+ }
4141+4242+ public long getLastOpened()
4343+ {
4444+ return lastOpened;
4545+ }
4646+4747+ public String getName()
4848+ {
4949+ return manifest.getName();
5050+ }
5151+5252+ public Manifest getManifest()
5353+ {
5454+ return manifest;
5555+ }
5656+5757+ @Override
5858+ public int compareTo(ProjectListing other)
5959+ {
6060+ // Sort by lastOpened descending (most recent first)
6161+ return Long.compare(other.lastOpened, this.lastOpened);
6262+ }
6363+6464+ @Override
6565+ public boolean equals(Object obj)
6666+ {
6767+ if (this == obj)
6868+ return true;
6969+ if (!(obj instanceof ProjectListing other))
7070+ return false;
7171+ return directory.equals(other.directory);
7272+ }
7373+7474+ @Override
7575+ public int hashCode()
7676+ {
7777+ return directory.hashCode();
7878+ }
7979+8080+ @Override
8181+ public String toString()
8282+ {
8383+ return getName() + " (" + directory.getAbsolutePath() + ")";
8484+ }
8585+}
+8-10
src/main/java/project/ProjectManager.java
···3838 * Gets all recent projects, sorted by last opened (most recent first).
3939 * Invalid projects (non-existent paths) are automatically removed.
4040 */
4141- public List<Project> getRecentProjects()
4141+ public List<ProjectListing> getRecentProjects()
4242 {
4343 return repository.getAllProjects();
4444 }
···4646 /**
4747 * Records that a project was opened (adds or updates its timestamp).
4848 */
4949- public void recordProjectOpened(Project project)
4949+ public void recordProjectOpened(ProjectListing listing)
5050 {
5151- repository.updateLastOpened(project);
5151+ repository.updateLastOpened(listing);
5252 }
53535454 /**
5555 * Removes a project from the recent projects list.
5656 * Does NOT delete files from disk.
5757- * @param project The project to remove
5857 */
5959- public void removeFromHistory(Project project)
5858+ public void removeFromHistory(ProjectListing listing)
6059 {
6161- repository.removeProject(project);
6060+ repository.removeProject(listing);
6261 }
63626463 /**
6564 * Deletes a project from disk and removes it from the history.
6666- * @param project The project to delete
6765 * @return true if deletion was successful, false otherwise
6866 */
6969- public boolean deleteFromDisk(Project project)
6767+ public boolean deleteFromDisk(ProjectListing listing)
7068 {
7171- File projectDir = new File(project.getPath());
6969+ File projectDir = new File(listing.getPath());
72707373- repository.removeProject(project);
7171+ repository.removeProject(listing);
74727573 try {
7674 FileUtils.deleteDirectory(projectDir);
+7-7
src/main/java/project/ProjectRepository.java
···1010{
1111 /**
1212 * Gets all projects sorted by last opened (most recent first).
1313- * @return List of projects, or empty list if none exist
1313+ * @return List of project listings, or empty list if none exist
1414 */
1515- List<Project> getAllProjects();
1515+ List<ProjectListing> getAllProjects();
16161717 /**
1818 * Adds a project to the repository or updates its timestamp if it already exists.
1919- * @param project The project to add or update
1919+ * @param listing The project listing to add or update
2020 */
2121- void addProject(Project project);
2121+ void addProject(ProjectListing listing);
22222323 /**
2424 * Removes a project from the repository.
2525 */
2626- void removeProject(Project project);
2626+ void removeProject(ProjectListing listing);
27272828 /**
2929 * Updates the last opened timestamp for a project.
3030- */
3131- void updateLastOpened(Project project);
3030+ */
3131+ void updateLastOpened(ProjectListing listing);
3232}
···11+package project.engine;
22+33+import java.io.File;
44+import java.io.IOException;
55+import java.util.concurrent.CompletableFuture;
66+77+/**
88+ * Interface for building Paper Mario decomp projects.
99+ * Implementations handle different build environments (native Nix, WSL+NixOS).
1010+ */
1111+public interface BuildEnvironment
1212+{
1313+ // --- Engine storage and git operations ---
1414+1515+ /**
1616+ * Returns the base directory for engine storage (bare repo + worktrees),
1717+ * as a host-side File that Java can use for file operations.
1818+ */
1919+ File getEngineBaseDir();
2020+2121+ /**
2222+ * Clones a bare git repository.
2323+ * @param url The repository URL
2424+ * @param targetDir Host-side File for the bare repo destination
2525+ * @param listener Callback for output
2626+ * @throws IOException If the command fails
2727+ */
2828+ void gitCloneBare(String url, File targetDir, BuildOutputListener listener) throws IOException;
2929+3030+ /**
3131+ * Creates a git worktree from a bare repository.
3232+ * @param bareRepo Host-side File for the bare repo
3333+ * @param worktreeDir Host-side File for the new worktree
3434+ * @param ref Git ref to check out
3535+ * @param listener Callback for output
3636+ * @throws IOException If the command fails
3737+ */
3838+ void gitWorktreeAdd(File bareRepo, File worktreeDir, String ref, BuildOutputListener listener) throws IOException;
3939+4040+ /**
4141+ * Fetches all remotes in a git repository.
4242+ * @param repo Host-side File for the repo/worktree
4343+ * @param listener Callback for output
4444+ * @throws IOException If the command fails
4545+ */
4646+ void gitFetchAll(File repo, BuildOutputListener listener) throws IOException;
4747+4848+ /**
4949+ * Checks out a git ref in a repository.
5050+ * @param dir Host-side File for the repo/worktree
5151+ * @param ref Git ref to check out
5252+ * @param listener Callback for output
5353+ * @throws IOException If the command fails
5454+ */
5555+ void gitCheckout(File dir, String ref, BuildOutputListener listener) throws IOException;
5656+5757+ // --- Build operations ---
5858+5959+ /**
6060+ * Returns a human-readable name for this environment type.
6161+ */
6262+ String getName();
6363+6464+ /**
6565+ * Runs configure (./configure).
6666+ * @param projectDir The project directory to build in
6767+ * @param listener Callback for real-time build output
6868+ * @return The result of the configure operation
6969+ * @throws BuildException If the build environment is not properly set up
7070+ * @throws IOException If an I/O error occurs
7171+ */
7272+ BuildResult configure(File projectDir, BuildOutputListener listener) throws BuildException, IOException;
7373+7474+ /**
7575+ * Builds the project (ninja).
7676+ * @param projectDir The project directory to build in
7777+ * @param listener Callback for real-time build output
7878+ * @return The result of the build operation
7979+ * @throws BuildException If the build environment is not properly set up
8080+ * @throws IOException If an I/O error occurs
8181+ */
8282+ BuildResult build(File projectDir, BuildOutputListener listener) throws BuildException, IOException;
8383+8484+ /**
8585+ * Cleans the build directory (./configure --clean).
8686+ * @param projectDir The project directory to clean
8787+ * @param listener Callback for real-time build output
8888+ * @return The result of the clean operation
8989+ * @throws BuildException If the build environment is not properly set up
9090+ * @throws IOException If an I/O error occurs
9191+ */
9292+ BuildResult clean(File projectDir, BuildOutputListener listener) throws BuildException, IOException;
9393+9494+ /**
9595+ * Builds the project asynchronously.
9696+ * @param projectDir The project directory to build in
9797+ * @param listener Callback for real-time build output
9898+ * @return A CompletableFuture that completes with the build result
9999+ */
100100+ CompletableFuture<BuildResult> buildAsync(File projectDir, BuildOutputListener listener);
101101+102102+ /**
103103+ * Cancels any running build operation.
104104+ * @return True if a build was cancelled, false if no build was running
105105+ */
106106+ boolean cancel();
107107+108108+ /**
109109+ * Returns whether a build is currently in progress.
110110+ */
111111+ boolean isBuilding();
112112+}
+142
src/main/java/project/engine/Engine.java
···11+package project.engine;
22+33+import java.io.File;
44+import java.io.IOException;
55+66+import app.Environment;
77+import project.Project;
88+import util.Logger;
99+1010+/**
1111+ * A checkout of papermario-dx that has been built, ready for modding.
1212+ *
1313+ * Also maintains a bare clone, so that each Engine can be a worktree of the bare repo.
1414+ * Engines may also be a submodule inside a project for users who want to modify the engine.
1515+ */
1616+public class Engine
1717+{
1818+ private static final String BARE_REPO_NAME = "papermario-dx.git";
1919+ private static final String REPO_URL = "https://github.com/bates64/papermario-dx.git";
2020+ private static final String BASEROM_PATH = "ver/us/baserom.z64";
2121+ private static final String DUMP_PATH = "ver/us/build/star-rod-dump";
2222+2323+ private final File directory; // the worktree or submodule directory
2424+ private final String ref;
2525+ private final boolean isSubmodule;
2626+ private final BuildEnvironment buildEnv;
2727+2828+ private Engine(File directory, String ref, boolean isSubmodule, BuildEnvironment buildEnv) throws IOException, BuildException
2929+ {
3030+ this.directory = directory;
3131+ this.ref = ref;
3232+ this.isSubmodule = isSubmodule;
3333+ this.buildEnv = buildEnv;
3434+3535+ if (!isSubmodule) {
3636+ File bareRepo = getBareRepoDir();
3737+ if (!bareRepo.exists())
3838+ cloneBareRepo();
3939+4040+ if (!directory.exists() || !isGitRepo(directory))
4141+ createWorktree();
4242+ else
4343+ checkoutRef();
4444+ }
4545+ }
4646+4747+ // --- Factory ---
4848+4949+ /**
5050+ * Resolves and sets up the engine for a project.
5151+ * If the engine uses a worktree, it will be cloned/checked out synchronously.
5252+ */
5353+ public static Engine forProject(Project project) throws BuildException, IOException
5454+ {
5555+ BuildEnvironment buildEnv = createBuildEnvironment();
5656+ String ref = project.getManifest().getEngineRef();
5757+5858+ // Check for submodule first
5959+ File submoduleDir = new File(project.getDirectory(), "papermario-dx");
6060+ if (isGitRepo(submoduleDir))
6161+ return new Engine(submoduleDir, ref, true, buildEnv);
6262+6363+ // Worktree-based engine
6464+ File engineBase = buildEnv.getEngineBaseDir();
6565+6666+ String projectId = project.getManifest().getId();
6767+ if (projectId == null || projectId.isEmpty())
6868+ projectId = project.getDirectory().getName();
6969+ File worktreeDir = new File(engineBase, "worktrees/" + projectId);
7070+7171+ return new Engine(worktreeDir, ref, false, buildEnv);
7272+ }
7373+7474+ private static BuildEnvironment createBuildEnvironment() throws BuildException
7575+ {
7676+ if (Environment.isWindows())
7777+ return new WslNixOsEnvironment();
7878+ return new NixEnvironment();
7979+ }
8080+8181+ private File getBareRepoDir()
8282+ {
8383+ return new File(buildEnv.getEngineBaseDir(), BARE_REPO_NAME);
8484+ }
8585+8686+ public File getDirectory()
8787+ {
8888+ return directory;
8989+ }
9090+9191+ public File getBaseRom()
9292+ {
9393+ return new File(directory, BASEROM_PATH);
9494+ }
9595+9696+ public File getDumpDir()
9797+ {
9898+ return new File(directory, DUMP_PATH);
9999+ }
100100+101101+ public String getRef()
102102+ {
103103+ return ref;
104104+ }
105105+106106+ public boolean isSubmodule()
107107+ {
108108+ return isSubmodule;
109109+ }
110110+111111+ public BuildEnvironment getBuildEnvironment()
112112+ {
113113+ return buildEnv;
114114+ }
115115+116116+ private void cloneBareRepo() throws IOException
117117+ {
118118+ buildEnv.gitCloneBare(REPO_URL, getBareRepoDir(), BuildOutputListener.toLogger());
119119+ }
120120+121121+ private void createWorktree() throws IOException
122122+ {
123123+ buildEnv.gitWorktreeAdd(getBareRepoDir(), directory, ref, BuildOutputListener.toLogger());
124124+ }
125125+126126+ private void checkoutRef() throws IOException
127127+ {
128128+ buildEnv.gitFetchAll(getBareRepoDir(), BuildOutputListener.toLogger());
129129+ buildEnv.gitCheckout(directory, ref, BuildOutputListener.toLogger());
130130+ }
131131+132132+ // TODO: call this somewhere!
133133+ public BuildResult splitAssets() throws BuildException, IOException
134134+ {
135135+ return buildEnv.configure(directory, BuildOutputListener.toLogger());
136136+ }
137137+138138+ private static boolean isGitRepo(File dir)
139139+ {
140140+ return dir.isDirectory() && (new File(dir, ".git").exists() || new File(dir, "HEAD").exists());
141141+ }
142142+}