diff --git a/x2-data-explorer/docs/bloat-analysis-tab.png b/x2-data-explorer/docs/bloat-analysis-tab.png new file mode 100644 index 0000000..fe53b4d Binary files /dev/null and b/x2-data-explorer/docs/bloat-analysis-tab.png differ diff --git a/x2-data-explorer/docs/save-general-tab.PNG b/x2-data-explorer/docs/save-general-tab.PNG index 58ae53e..0c81afd 100644 Binary files a/x2-data-explorer/docs/save-general-tab.PNG and b/x2-data-explorer/docs/save-general-tab.PNG differ diff --git a/x2-data-explorer/docs/user-guide.adoc b/x2-data-explorer/docs/user-guide.adoc index 71dcd4d..47f1ccb 100644 --- a/x2-data-explorer/docs/user-guide.adoc +++ b/x2-data-explorer/docs/user-guide.adoc @@ -18,7 +18,7 @@ Multiple files can be loaded at the same time. Each file will be in a separate t Save files are produced when you save a regular game. History files are produced when you save a ladder game. Save and history files have the same format and work the same way, except that save files have an additional header with some extra information. Both file types contain the entire game state history since the last time the history was archived (which occurs during each map transition). -After loading one of these files, the tab will contain three sub-tabs: the <>, the <>, and the <>. +After loading one of these files, the tab will contain four sub-tabs: the <>, the <>, the <>, and the <>. [#general-tab] === General Tab @@ -29,7 +29,7 @@ For a save file, the General tab contains four sections: * Top left: information from the save file header. This can tell you things like: when the save file was created, whether it was created in tactical or strategy, and which version of the game created it. * Top right: a list of mods and DLCs that were active when the save was created. -* Bottom left: a list of singleton XComGameStateObjects. Singleton objects are objects whose class has `bSingletonStateType` set to true. Historical versions of these objects are not serialized, so unlike other objects, there is no way to know what a singleton object looked like at each frame in the history. Fortunately, singleton objects are very rare. XComGameState_Analytics is the only such class in the base game. +* Bottom left: a list of singleton XComGameStateObjects. Singleton objects are objects whose class has `bSingletonStateType` set to true. Historical versions of these objects are not serialized, except in strategy saves, where the archive frame holds the original state of the object. So unlike other objects, there is no way to know what a singleton object looked like at each frame in the history. Fortunately, singleton objects are very rare. XComGameState_Analytics is the only such class in the base game. * Bottom right: the object properties tree, populated when you click on any singleton object. For a history file, the General tab is mostly the same, except the list of mods and DLCs is removed, and the header information only contains a few fields that are available from XComGameStateHistory. @@ -83,6 +83,20 @@ IMPORTANT: When writing expressions to match fields that are Unreal names, remem The frames and objects tables have a summary column that provides a short description of what that frame or object is about. The summaries are powered by Groovy scripts. Default scripts are included in the application, but can be modified if you like. In the Preferences menu at the top of the screen, click the State Object Summary Script or Context Summary Script menu items to edit the scripts. +[#bloat-tab] +=== Bloat Analysis Tab + +image::bloat-analysis-tab.png[] + +The Bloat Analysis tab helps you figure out what's using the most space in your save files. It has six sub-tabs: + +. *Object Class Stats* provides summary statistics for objects that are subclasses of `XComGameState_BaseObject`. It shows the number of objects, min/max/average number of deltas per object (i.e. the number of times an object was changed), and the min/max/average size of each delta. +. *Context Class Stats* provides summary statistics for objects that are subclasses of `XComGameStateContext`. It shows the number of frames that used a context of each class, and the min/max/average/total bytes used by those contexts. +. *Largest Delta Objects* lists the top 500 largest delta objects in the file. The object ID and frame number are provided so you can switch to the Frames tab to get a better idea of what was happening in that frame. +. *Largest Full Objects* lists the top 500 largest full (non-delta) objects in the file. The object ID and frame number are provided so you can switch to the Frames tab to get a better idea of what was happening in that frame. +. *Largest Contexts* lists the top 500 largest contexts in the file. The frame number is provided so you can switch to the Frames tab to get a better idea of what was happening in that frame. +. *Singletons* shows the size of all singleton state objects in the file. + [#problems-tab] === Problems Tab diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateContext.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateContext.java index b31325e..2fecd23 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateContext.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateContext.java @@ -11,12 +11,13 @@ import groovy.lang.Script; -public class GameStateContext { +public class GameStateContext implements ISizedObject { private static final UnrealName INTERRUPTION_HISTORY_INDEX = new UnrealName("InterruptionHistoryIndex"); private static final UnrealName HISTORY_INDEX_INTERRUPTED_BY_SELF = new UnrealName("HistoryIndexInterruptedBySelf"); private static final UnrealName INTERRUPTION_STATUS = new UnrealName("InterruptionStatus"); + private final int sizeInFile; private final UnrealName type; private final Map fields; private final HistoryFrame frame; @@ -26,8 +27,9 @@ public class GameStateContext { private final HistoryFrame interruptedByThis; private HistoryFrame resumedBy; - public GameStateContext(GenericObject object, HistoryFrame frame, Map frames, Script summarizer, + public GameStateContext(int sizeInFile, GenericObject object, HistoryFrame frame, Map frames, Script summarizer, List problemsDetected) { + this.sizeInFile = sizeInFile; this.frame = frame; type = object.type; @@ -68,6 +70,11 @@ public Object propertyMissing(String name) { return fields.get(new UnrealName(name)); } + @Override + public int getSizeInFile() { + return sizeInFile; + } + public UnrealName getType() { return type; } diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateObject.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateObject.java index 943afae..6e38106 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateObject.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/GameStateObject.java @@ -13,11 +13,12 @@ import groovy.lang.Script; import javafx.scene.control.TreeItem; -public class GameStateObject { +public class GameStateObject implements ISizedObject { private static final UnrealName OBJECT_ID = new UnrealName("ObjectID"); private static final UnrealName REMOVED = new UnrealName("bRemoved"); + private final int sizeInFile; private final int objectId; private final boolean removed; // note that it is possible for an object to be added and removed in the same state private final UnrealName type; @@ -27,8 +28,9 @@ public class GameStateObject { private final GameStateObject previousVersion; private GameStateObject nextVersion; - public GameStateObject(Map stateObjects, GenericObject currentVersion, HistoryFrame frame, Script summarizer, - List problemsDetected) { + public GameStateObject(int sizeInFile, Map stateObjects, GenericObject currentVersion, + HistoryFrame frame, Script summarizer, List problemsDetected) { + this.sizeInFile = sizeInFile; this.frame = frame; objectId = (int) currentVersion.properties.get(OBJECT_ID); @@ -71,6 +73,11 @@ public TreeItem getFieldsAsTreeNode(boolean onlyMo return root; } + @Override + public int getSizeInFile() { + return sizeInFile; + } + public int getObjectId() { return objectId; } diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistoryFileReader.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistoryFileReader.java index 91af52b..f6aadb9 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistoryFileReader.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistoryFileReader.java @@ -6,11 +6,12 @@ import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.function.Consumer; import java.util.function.DoubleConsumer; @@ -38,38 +39,65 @@ public HistoryFile read(FileChannel in, DoubleConsumer progressPercentCallback, reader.decompress(in, decompressedIn); progressTextCallback.accept("Building index"); try (var historyIndex = reader.buildIndex(decompressedIn)) { - Map frames = new HashMap<>(); + XComGameStateHistory history = historyIndex.mapObject(historyIndex.getEntry(0), null, NullXComObjectReferenceResolver.INSTANCE); + var frameRefs = history.History; + var currentFrameNum = history.NumArchivedFrames + 1; + if (!historyIndex.isCreatedByWOTC()) { + // before WOTC, NumArchivedFrames did not exist and archived frames were represented by -1 in the History array + for (int i = frameRefs.size() - 1; i >= 0; i--) { + if (frameRefs.get(i).index() == -1) { + frameRefs = frameRefs.subList(i + 1, frameRefs.size()); + currentFrameNum = i + 2; + break; + } + } + } + + // first pass to parse the state and detect singletons + var numFrames = frameRefs.size(); + var rawFrames = new XComGameState[numFrames]; + var seenObjectIndexes = new HashSet(); + var detectedSingletonTypes = new HashSet(); + for (int i = 0; i < numFrames; i++) { + progressTextCallback.accept("Parsing history frame " + currentFrameNum++); + XComGameState rawFrame = historyIndex.mapObject( + historyIndex.getEntry(frameRefs.get(i).index()), null, NullXComObjectReferenceResolver.INSTANCE); + rawFrames[i] = rawFrame; + for (var objRef : rawFrame.GameStates) { + if (!seenObjectIndexes.add(objRef.index())) { + // multiple frames pointing to same object index, so class must be a singleton + // note that in strategy saves, two versions of a singleton are written + // the first frame (archive frame) points to one version + // all other frames point to the other version + // this does not happen for tactical saves, where all frames point to a single version + detectedSingletonTypes.add(historyIndex.getEntry(objRef.index()).getType()); + } + } + } + + // second pass to parse the state objects + Map frames = new LinkedHashMap<>(); Map parsedObjects = new HashMap<>(); Map stateObjects = new HashMap<>(); - Set singletonStates = new HashSet<>(); + Map singletonStates = new HashMap<>(); List problemsDetected = new ArrayList<>(); var contextSummarizer = ScriptPreferences.CONTEXT_SUMMARY.getExecutable(); var objectSummarizer = ScriptPreferences.STATE_OBJECT_SUMMARY.getExecutable(); - - XComGameStateHistory history = historyIndex.mapObject(historyIndex.getEntry(0), null, NullXComObjectReferenceResolver.INSTANCE); - int numFrames = history.History.size(); - boolean foundFirstFrame = historyIndex.isCreatedByWOTC(); for (int i = 0; i < numFrames; i++) { - var frameRef = history.History.get(i); - if (!foundFirstFrame && frameRef.index() == -1) { - // before WOTC, NumArchivedFrames did not exist and archived frames were represented by -1 in the History array - continue; - } - XComGameState rawFrame = historyIndex.mapObject( - historyIndex.getEntry(frameRef.index()), null, NullXComObjectReferenceResolver.INSTANCE); + XComGameState rawFrame = rawFrames[i]; var parsedFrame = new HistoryFrame(rawFrame.HistoryIndex, rawFrame.TimeStamp); - progressTextCallback.accept("Parsing history frame " + rawFrame.HistoryIndex); + progressTextCallback.accept("Parsing objects for history frame " + rawFrame.HistoryIndex); var contextEntry = historyIndex.getEntry(rawFrame.StateChangeContext.index()); var contextVisitor = new GenericObjectVisitor(null); historyIndex.parseObject(contextEntry, contextVisitor); var parsedContext = new GameStateContext( - contextVisitor.getRootObject(), parsedFrame, frames, contextSummarizer, problemsDetected); + contextEntry.getLength(), contextVisitor.getRootObject(), parsedFrame, frames, contextSummarizer, problemsDetected); for (var stateObjectRef : rawFrame.GameStates) { var stateObjectEntry = historyIndex.getEntry(stateObjectRef.index()); - if (stateObjectEntry.isSingletonState()) { - singletonStates.add(stateObjectEntry); + if (detectedSingletonTypes.contains(stateObjectEntry.getType())) { + singletonStates.putIfAbsent(stateObjectEntry, rawFrame.HistoryIndex); continue; } @@ -94,7 +122,7 @@ public HistoryFile read(FileChannel in, DoubleConsumer progressPercentCallback, stateObject.properties.get(PREV_FRAME_HIST_INDEX)))); } else { parsedObjects.put(stateObjectRef.index(), stateObject); - new GameStateObject(stateObjects, stateObject, parsedFrame, objectSummarizer, problemsDetected); // adds itself to the map + new GameStateObject(stateObjectEntry.getLength(), stateObjects, stateObject, parsedFrame, objectSummarizer, problemsDetected); // adds itself to the map } } @@ -103,22 +131,27 @@ public HistoryFile read(FileChannel in, DoubleConsumer progressPercentCallback, progressPercentCallback.accept(((double) i + 1) / numFrames); } + progressTextCallback.accept("Parsing singletons"); var singletons = singletonStates + .entrySet() .stream() - .map(s -> { + .map(entry -> { + var key = entry.getKey(); var stateObjectVisitor = new GenericObjectVisitor(null); try { - historyIndex.parseObject(s, stateObjectVisitor); + historyIndex.parseObject(key, stateObjectVisitor); } catch (IOException e) { // should never happen throw new UncheckedIOException(e); } - return new HistorySingletonObject(stateObjectVisitor.getRootObject()); + return new HistorySingletonObject(key.getLength(), entry.getValue(), stateObjectVisitor.getRootObject()); }) - .sorted((a, b) -> a.getType().compareTo(b.getType())) + .sorted(Comparator + .comparing(s -> s.getType()) + .thenComparingInt(s -> s.getFirstFrame())) .toList(); - return new HistoryFile(history, frames.values().stream().sorted().toList(), singletons, problemsDetected); + return new HistoryFile(history, List.copyOf(frames.values()), singletons, problemsDetected); } } finally { Files.deleteIfExists(decompressedFile); diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistorySingletonObject.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistorySingletonObject.java index 368614a..706c3a2 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistorySingletonObject.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/HistorySingletonObject.java @@ -5,21 +5,34 @@ import com.github.rcd47.x2data.lib.unreal.mappings.UnrealName; -public class HistorySingletonObject { +public class HistorySingletonObject implements ISizedObject { private static final UnrealName OBJECT_ID = new UnrealName("ObjectID"); + private final int sizeInFile; + private final int firstFrame; private final int objectId; private final UnrealName type; private final Map fields; - public HistorySingletonObject(GenericObject object) { + public HistorySingletonObject(int sizeInFile, int firstFrame, GenericObject object) { + this.sizeInFile = sizeInFile; + this.firstFrame = firstFrame; objectId = (int) object.properties.get(OBJECT_ID); type = object.type; fields = new HashMap<>(); object.properties.forEach((k, v) -> fields.put(k, new NonVersionedField(v))); } + @Override + public int getSizeInFile() { + return sizeInFile; + } + + public int getFirstFrame() { + return firstFrame; + } + public int getObjectId() { return objectId; } diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/ISizedObject.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/ISizedObject.java new file mode 100644 index 0000000..64e273d --- /dev/null +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/file/ISizedObject.java @@ -0,0 +1,7 @@ +package com.github.rcd47.x2data.explorer.file; + +public interface ISizedObject { + + int getSizeInFile(); + +} diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/Main.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/Main.java index f767ade..d482b6a 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/Main.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/Main.java @@ -22,6 +22,8 @@ import com.github.rcd47.x2data.explorer.file.HistoryFileReader; import com.github.rcd47.x2data.explorer.file.NonVersionedField; import com.github.rcd47.x2data.explorer.jfx.ui.NonVersionedFieldUI; +import com.github.rcd47.x2data.explorer.jfx.ui.ProgressUtils; +import com.github.rcd47.x2data.explorer.jfx.ui.history.HistoryBloatAnalysisUI; import com.github.rcd47.x2data.explorer.jfx.ui.history.HistoryFramesUI; import com.github.rcd47.x2data.explorer.jfx.ui.history.HistoryGeneralUI; import com.github.rcd47.x2data.explorer.jfx.ui.history.HistoryProblemsUI; @@ -38,14 +40,12 @@ import com.github.rcd47.x2data.lib.unreal.mappings.UnrealBasicSaveObject; import com.github.rcd47.x2data.lib.unreal.mappings.UnrealName; import com.github.rcd47.x2data.lib.unreal.typings.UnrealTypingsBuilder; -import com.google.common.base.Throwables; import javafx.application.Application; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.concurrent.Task; import javafx.event.ActionEvent; -import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.geometry.Rectangle2D; import javafx.scene.Node; @@ -53,14 +53,11 @@ import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; import javafx.scene.control.Button; -import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; import javafx.scene.control.MenuButton; import javafx.scene.control.MenuItem; -import javafx.scene.control.ProgressBar; -import javafx.scene.control.ScrollPane; import javafx.scene.control.SplitPane; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; @@ -68,13 +65,9 @@ import javafx.scene.control.TabPane.TabDragPolicy; import javafx.scene.control.ToolBar; import javafx.scene.control.TreeItem; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; import javafx.scene.input.TransferMode; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; -import javafx.scene.text.Text; import javafx.stage.FileChooser; import javafx.stage.Screen; import javafx.stage.Stage; @@ -270,7 +263,7 @@ private void openFile(File selectedFile) { } catch (IOException e2) { e.addSuppressed(e2); } - tab.setContent(readingFileFailed(e)); + tab.setContent(ProgressUtils.createTaskFailureUi(e)); } tabPane.getTabs().add(tab); tabPane.getSelectionModel().select(tab); @@ -337,7 +330,7 @@ protected Void call() throws Exception { @Override protected void failed() { - splitPane.getItems().set(0, readingFileFailed(getException())); + splitPane.getItems().set(0, ProgressUtils.createTaskFailureUi(getException())); } }; @@ -365,7 +358,7 @@ protected void succeeded() { @Override protected void failed() { - splitLeft.getChildren().setAll(readingFileFailed(getException())); + splitLeft.getChildren().setAll(ProgressUtils.createTaskFailureUi(getException())); } }; @@ -377,8 +370,10 @@ private void loadHistoryFile(Tab tab, FileChannel in, boolean isSaveFile) { @Override protected Node call() throws Exception { try { + updateProgress(0, 1); // don't show indeterminate progress because we switch to determinate so quickly var header = isSaveFile ? new X2SaveGameReader().readHeader(in) : null; var history = new HistoryFileReader().read(in, p -> updateProgress(p, 1), this::updateMessage); + updateMessage("Setting up UI"); var generalTab = new Tab(HistoryFileTab.GENERAL.getTabTitle(), new HistoryGeneralUI(header, history).getNode()); generalTab.setClosable(false); @@ -386,6 +381,10 @@ protected Node call() throws Exception { var framesTab = new Tab(HistoryFileTab.FRAMES.getTabTitle(), new HistoryFramesUI(history).getNode()); framesTab.setClosable(false); + var bloatAnalysisTab = new Tab(HistoryFileTab.BLOAT_ANALYSIS.getTabTitle()); + new HistoryBloatAnalysisUI(history, bloatAnalysisTab); + bloatAnalysisTab.setClosable(false); + var problemsCount = history.getProblems().size(); var problemsTab = new Tab( HistoryFileTab.PROBLEMS.getTabTitle() + " (" + problemsCount + ")", new HistoryProblemsUI(history).getNode()); @@ -397,12 +396,13 @@ protected Node call() throws Exception { var defaultTabConfig = header == null ? GeneralPreferences.getEffective().getHistoryFileDefaultTab() : GeneralPreferences.getEffective().getSaveFileDefaultTab(); var defaultTab = switch (defaultTabConfig.get()) { + case BLOAT_ANALYSIS -> bloatAnalysisTab; case FRAMES -> framesTab; case GENERAL -> generalTab; case PROBLEMS -> problemsTab; }; - var tabPane = new TabPane(generalTab, framesTab, problemsTab); + var tabPane = new TabPane(generalTab, framesTab, bloatAnalysisTab, problemsTab); tabPane.getSelectionModel().select(defaultTab); return tabPane; @@ -418,47 +418,14 @@ protected void succeeded() { @Override protected void failed() { - tab.setContent(readingFileFailed(getException())); + tab.setContent(ProgressUtils.createTaskFailureUi(getException())); } }; - tab.setContent(readingFileStarted(task)); + tab.setContent(ProgressUtils.createProgressUi(task)); new Thread(task, "HistoryFileLoader").start(); } - - private static Node readingFileStarted(Task task) { - var progressBar = new ProgressBar(); - progressBar.setPrefWidth(400); - progressBar.progressProperty().bind(task.progressProperty()); - - var progressText = new Text(); - progressText.textProperty().bind(task.messageProperty()); - - var vbox = new VBox(5, progressBar, progressText); - vbox.setAlignment(Pos.BASELINE_CENTER); - vbox.setPadding(new Insets(10)); - - return vbox; - } - - private static Node readingFileFailed(Throwable t) { - var text = new Text(Throwables.getStackTraceAsString(t)); - text.setFill(Color.RED); - - var copyMenuItem = new MenuItem("Copy to clipboard"); - copyMenuItem.setOnAction(_ -> { - var content = new ClipboardContent(); - content.putString(text.getText()); - Clipboard.getSystemClipboard().setContent(content); - }); - - var scrollPane = new ScrollPane(text); - scrollPane.setPadding(new Insets(10)); - scrollPane.setContextMenu(new ContextMenu(copyMenuItem)); - - return scrollPane; - } public static void main(String[] args) { launch(args); diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/ProgressUtils.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/ProgressUtils.java new file mode 100644 index 0000000..ff94a06 --- /dev/null +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/ProgressUtils.java @@ -0,0 +1,54 @@ +package com.github.rcd47.x2data.explorer.jfx.ui; + +import com.google.common.base.Throwables; + +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.text.Text; + +public class ProgressUtils { + + public static Node createProgressUi(Task task) { + var progressBar = new ProgressBar(); + progressBar.setPrefWidth(400); + progressBar.progressProperty().bind(task.progressProperty()); + + var progressText = new Text(); + progressText.textProperty().bind(task.messageProperty()); + + var vbox = new VBox(5, progressBar, progressText); + vbox.setAlignment(Pos.BASELINE_CENTER); + vbox.setPadding(new Insets(10)); + + return vbox; + } + + public static Node createTaskFailureUi(Throwable t) { + var text = new Text(Throwables.getStackTraceAsString(t)); + text.setFill(Color.RED); + + var copyMenuItem = new MenuItem("Copy to clipboard"); + copyMenuItem.setOnAction(_ -> { + var content = new ClipboardContent(); + content.putString(text.getText()); + Clipboard.getSystemClipboard().setContent(content); + }); + + var scrollPane = new ScrollPane(text); + scrollPane.setPadding(new Insets(10)); + scrollPane.setContextMenu(new ContextMenu(copyMenuItem)); + + return scrollPane; + } + +} diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryBloatAnalysisUI.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryBloatAnalysisUI.java new file mode 100644 index 0000000..7578c66 --- /dev/null +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryBloatAnalysisUI.java @@ -0,0 +1,388 @@ +package com.github.rcd47.x2data.explorer.jfx.ui.history; + +import java.text.NumberFormat; +import java.util.Comparator; +import java.util.HashMap; +import java.util.IntSummaryStatistics; +import java.util.Map.Entry; +import java.util.PriorityQueue; +import java.util.Queue; + +import com.github.rcd47.x2data.explorer.file.GameStateContext; +import com.github.rcd47.x2data.explorer.file.GameStateObject; +import com.github.rcd47.x2data.explorer.file.HistoryFile; +import com.github.rcd47.x2data.explorer.file.HistorySingletonObject; +import com.github.rcd47.x2data.explorer.file.ISizedObject; +import com.github.rcd47.x2data.explorer.file.NonVersionedField; +import com.github.rcd47.x2data.explorer.jfx.ui.NonVersionedFieldUI; +import com.github.rcd47.x2data.explorer.jfx.ui.ProgressUtils; +import com.github.rcd47.x2data.explorer.jfx.ui.StandardCellFactoryHelper; +import com.github.rcd47.x2data.explorer.jfx.ui.prefs.GeneralPreferences; +import com.github.rcd47.x2data.lib.unreal.mappings.UnrealName; + +import javafx.beans.property.ReadOnlyDoubleWrapper; +import javafx.beans.property.ReadOnlyIntegerWrapper; +import javafx.beans.property.ReadOnlyLongWrapper; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.geometry.Orientation; +import javafx.scene.Node; +import javafx.scene.control.SplitPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; + +public class HistoryBloatAnalysisUI { + + private static final int LARGEST_LIMIT = 500; + + public HistoryBloatAnalysisUI(HistoryFile history, Tab parent) { + var task = new Task() { + @SuppressWarnings("unchecked") + @Override + protected Node call() throws Exception { + // analysis + + updateProgress(0, 1); + var perObjectStatsMap = new HashMap(); + var perContextClassStatsMap = new HashMap(); + var largestDeltaObjects = new LargestList(); + var largestFullObjects = new LargestList(); + var largestContexts = new LargestList(); + var frames = history.getFrames(); + var numFrames = frames.size(); + for (int i = 0; i < numFrames; i++) { + var frame = frames.get(i); + updateMessage("Analayzing frame " + frame.getNumber()); + var context = frame.getContext(); + perContextClassStatsMap.computeIfAbsent(context.getType(), _ -> new IntSummaryStatistics()).accept(context.getSizeInFile()); + largestContexts.add(context); + for (var gso : frame.getObjects().values()) { + if (gso.getFrame().equals(frame)) { + perObjectStatsMap.computeIfAbsent(gso.getObjectId(), _ -> new GameStateObjectStats()).add(gso); + (gso.getPreviousVersion() == null ? largestFullObjects : largestDeltaObjects).add(gso); + } + } + updateProgress(((double) i + 1) / numFrames, 1); + } + var perObjectStats = FXCollections.observableArrayList(perObjectStatsMap.values()); + var perContextClassStats = FXCollections.observableArrayList(perContextClassStatsMap.entrySet()); + + var perObjectClassStatsMap = new HashMap(); + for (var objStats : perObjectStats) { + perObjectClassStatsMap.computeIfAbsent(objStats.type, GameStateClassStats::new).add(objStats); + } + var perObjectClassStats = FXCollections.observableArrayList(perObjectClassStatsMap.values()); + + // object class stats table + + var colClassType = new TableColumn("Class"); + colClassType.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().type.getOriginal())); + + var colClassObjCount = new TableColumn("# Objects"); + colClassObjCount.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().objectCount).asObject()); + formatNumericColumn(colClassObjCount, null); + + var colClassTotalBytes = new TableColumn("Total Bytes"); + colClassTotalBytes.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().totalBytes).asObject()); + formatNumericColumn(colClassTotalBytes, null); + + var colClassDeltaBytesAvg = new TableColumn("Avg Bytes/Delta"); + colClassDeltaBytesAvg.setCellValueFactory(t -> new ReadOnlyDoubleWrapper(t.getValue().deltaByteStats.getAverage()).asObject()); + formatNumericColumn(colClassDeltaBytesAvg, null); + + var colClassDeltaBytesMin = new TableColumn("Min Bytes/Delta"); + colClassDeltaBytesMin.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().deltaByteStats.getMin()).asObject()); + formatNumericColumn(colClassDeltaBytesMin, Integer.MAX_VALUE); + + var colClassDeltaBytesMax = new TableColumn("Max Bytes/Delta"); + colClassDeltaBytesMax.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().deltaByteStats.getMax()).asObject()); + formatNumericColumn(colClassDeltaBytesMax, Integer.MIN_VALUE); + + var colClassDeltaCount = new TableColumn("Total Deltas"); + colClassDeltaCount.setCellValueFactory(t -> new ReadOnlyLongWrapper(t.getValue().deltaCountStats.getSum()).asObject()); + formatNumericColumn(colClassDeltaCount, null); + + var colClassDeltaAvg = new TableColumn("Avg Deltas"); + colClassDeltaAvg.setCellValueFactory(t -> new ReadOnlyDoubleWrapper(t.getValue().deltaCountStats.getAverage()).asObject()); + formatNumericColumn(colClassDeltaAvg, null); + + var colClassDeltaMin = new TableColumn("Min Deltas"); + colClassDeltaMin.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().deltaCountStats.getMin()).asObject()); + formatNumericColumn(colClassDeltaMin, null); + + var colClassDeltaMax = new TableColumn("Max Deltas"); + colClassDeltaMax.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().deltaCountStats.getMax()).asObject()); + formatNumericColumn(colClassDeltaMax, null); + + var classStatsTable = new TableView<>(perObjectClassStats); + classStatsTable.getColumns().addAll( + colClassType, colClassObjCount, colClassTotalBytes, colClassDeltaBytesAvg, colClassDeltaBytesMin, + colClassDeltaBytesMax, colClassDeltaCount, colClassDeltaAvg, colClassDeltaMin, colClassDeltaMax); + + // context class stats table + + var colContextType = new TableColumn, String>("Class"); + colContextType.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getKey().getOriginal())); + + var colContextCount = new TableColumn, Long>("# Frames"); + colContextCount.setCellValueFactory(t -> new ReadOnlyLongWrapper(t.getValue().getValue().getCount()).asObject()); + formatNumericColumn(colContextCount, null); + + var colContextBytesTotal = new TableColumn, Long>("Total Bytes"); + colContextBytesTotal.setCellValueFactory(t -> new ReadOnlyLongWrapper(t.getValue().getValue().getSum()).asObject()); + formatNumericColumn(colContextBytesTotal, null); + + var colContextBytesAvg = new TableColumn, Double>("Avg Bytes"); + colContextBytesAvg.setCellValueFactory(t -> new ReadOnlyDoubleWrapper(t.getValue().getValue().getAverage()).asObject()); + formatNumericColumn(colContextBytesAvg, null); + + var colContextBytesMin = new TableColumn, Integer>("Min Bytes"); + colContextBytesMin.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getValue().getMin()).asObject()); + formatNumericColumn(colContextBytesMin, null); + + var colContextBytesMax = new TableColumn, Integer>("Max Bytes"); + colContextBytesMax.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getValue().getMax()).asObject()); + formatNumericColumn(colContextBytesMax, null); + + var contextStatsTable = new TableView<>(perContextClassStats); + contextStatsTable.getColumns().addAll( + colContextType, colContextCount, colContextBytesTotal, colContextBytesAvg, colContextBytesMin, colContextBytesMax); + + // largest delta objects + + var colDeltaObjId = new TableColumn("Object ID"); + colDeltaObjId.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getObjectId()).asObject()); + + var colDeltaObjType = new TableColumn("Object Class"); + colDeltaObjType.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getType().getOriginal())); + + var colDeltaSize = new TableColumn("Delta Size"); + colDeltaSize.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getSizeInFile()).asObject()); + formatNumericColumn(colDeltaSize, null); + + var colDeltaObjSummary = new TableColumn("Object Summary"); + colDeltaObjSummary.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getSummary())); + + var colDeltaFrameNum = new TableColumn("Frame #"); + colDeltaFrameNum.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getFrame().getNumber()).asObject()); + + var colDeltaCtxSummary = new TableColumn("Context Summary"); + colDeltaCtxSummary.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getFrame().getContext().getSummary())); + + var deltaObjectsTable = new TableView<>(largestDeltaObjects.toList()); + deltaObjectsTable.getColumns().addAll( + colDeltaObjId, colDeltaObjType, colDeltaSize, colDeltaObjSummary, colDeltaFrameNum, colDeltaCtxSummary); + + var deltaSplitPane = new SplitPane(deltaObjectsTable, new ObjectPropertiesTable(null, deltaObjectsTable).getNode()); + deltaSplitPane.setOrientation(Orientation.HORIZONTAL); + + // largest full objects + + var colFullObjId = new TableColumn("Object ID"); + colFullObjId.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getObjectId()).asObject()); + + var colFullObjType = new TableColumn("Object Class"); + colFullObjType.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getType().getOriginal())); + + var colFullSize = new TableColumn("Object Size"); + colFullSize.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getSizeInFile()).asObject()); + formatNumericColumn(colFullSize, null); + + var colFullObjSummary = new TableColumn("Object Summary"); + colFullObjSummary.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getSummary())); + + var colFullFrameNum = new TableColumn("Frame #"); + colFullFrameNum.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getFrame().getNumber()).asObject()); + + var colFullCtxSummary = new TableColumn("Context Summary"); + colFullCtxSummary.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getFrame().getContext().getSummary())); + + var fullObjectsTable = new TableView<>(largestFullObjects.toList()); + fullObjectsTable.getColumns().addAll( + colFullObjId, colFullObjType, colFullSize, colFullObjSummary, colFullFrameNum, colFullCtxSummary); + + var fullSplitPane = new SplitPane(fullObjectsTable, new ObjectPropertiesTable(null, fullObjectsTable).getNode()); + fullSplitPane.setOrientation(Orientation.HORIZONTAL); + + // largest contexts + + var colCtxFrameNum = new TableColumn("Frame #"); + colCtxFrameNum.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getFrame().getNumber()).asObject()); + + var colCtxType = new TableColumn("Context Class"); + colCtxType.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getType().getOriginal())); + + var colCtxSize = new TableColumn("Context Size"); + colCtxSize.setCellValueFactory(t -> new ReadOnlyIntegerWrapper(t.getValue().getSizeInFile()).asObject()); + formatNumericColumn(colCtxSize, null); + + var colCtxSummary = new TableColumn("Context Summary"); + colCtxSummary.setCellValueFactory(t -> new ReadOnlyStringWrapper(t.getValue().getSummary())); + + var ctxTable = new TableView<>(largestContexts.toList()); + ctxTable.getColumns().addAll(colCtxFrameNum, colCtxType, colCtxSize, colCtxSummary); + + var ctxProperties = new NonVersionedFieldUI( + GeneralPreferences.getEffective().getHistoryContextPropsTreeExpanded(), + "Click a context to view its properties"); + ctxProperties.getRootProperty().bind( + ctxTable.getSelectionModel().selectedItemProperty().map(f -> NonVersionedField.convertToTreeItems(f.getFields()))); + + var ctxSplitPane = new SplitPane(ctxTable, ctxProperties.getNode()); + ctxSplitPane.setOrientation(Orientation.HORIZONTAL); + + // singletons + + var colSingletonId = new TableColumn("Singleton ID"); + colSingletonId.setCellValueFactory(f -> new ReadOnlyIntegerWrapper(f.getValue().getObjectId()).asObject()); + var colSingletonType = new TableColumn("Singleton Type"); + colSingletonType.setCellValueFactory(f -> new ReadOnlyStringWrapper(f.getValue().getType().getOriginal())); + StandardCellFactoryHelper.setFactoryForStringValueColumn(colSingletonType); + var colSingletonFirstFrame = new TableColumn("First Frame"); + colSingletonFirstFrame.setCellValueFactory(f -> new ReadOnlyIntegerWrapper(f.getValue().getFirstFrame()).asObject()); + var colSingletonSize = new TableColumn("Size"); + colSingletonSize.setCellValueFactory(f -> new ReadOnlyIntegerWrapper(f.getValue().getSizeInFile()).asObject()); + formatNumericColumn(colSingletonSize, null); + + var singletonsTable = new TableView<>(FXCollections.observableList(history.getSingletons())); + singletonsTable.getColumns().addAll(colSingletonId, colSingletonType, colSingletonFirstFrame, colSingletonSize); + singletonsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS); + + var singletonPropsUI = new NonVersionedFieldUI( + GeneralPreferences.getEffective().getHistorySingletonPropsTreeExpanded(), + "Click a singleton state to view its properties"); + singletonPropsUI.getRootProperty().bind( + singletonsTable.getSelectionModel().selectedItemProperty().map(f -> NonVersionedField.convertToTreeItems(f.getFields()))); + + var singletonSplitPane = new SplitPane(singletonsTable, singletonPropsUI.getNode()); + singletonSplitPane.setOrientation(Orientation.HORIZONTAL); + + // tab pane + + var tabObjClassStats = new Tab("Object Class Stats", classStatsTable); + tabObjClassStats.setClosable(false); + + var tabContextClassStats = new Tab("Context Class Stats", contextStatsTable); + tabContextClassStats.setClosable(false); + + var tabLargestDeltaObjects = new Tab("Largest Delta Objects", deltaSplitPane); + tabLargestDeltaObjects.setClosable(false); + + var tabLargestFullObjects = new Tab("Largest Full Objects", fullSplitPane); + tabLargestFullObjects.setClosable(false); + + var tabLargestContexts = new Tab("Largest Contexts", ctxSplitPane); + tabLargestContexts.setClosable(false); + + var tabSingletons = new Tab("Singletons", singletonSplitPane); + tabSingletons.setClosable(false); + + return new TabPane(tabObjClassStats, tabContextClassStats, tabLargestDeltaObjects, tabLargestFullObjects, tabLargestContexts, tabSingletons); + } + + @Override + protected void succeeded() { + parent.setContent(getValue()); + } + + @Override + protected void failed() { + parent.setContent(ProgressUtils.createTaskFailureUi(getException())); + } + }; + + parent.setContent(ProgressUtils.createProgressUi(task)); + parent.selectedProperty().addListener((_, _, selected) -> { + if (selected && !task.isDone() && !task.isRunning()) { + new Thread(task, "BloatAnalysis").start(); + } + }); + } + + private static void formatNumericColumn(TableColumn col, T nullValue) { + var numFmt = NumberFormat.getInstance(); + + col.setCellFactory(_ -> { + var cell = new TableCell(); + cell.textProperty().bind(cell.itemProperty().map(v -> v.equals(nullValue) ? "-" : numFmt.format(v))); + return cell; + }); + } + + private static class GameStateClassStats { + UnrealName type; + int objectCount; + int totalBytes; + IntSummaryStatistics deltaByteStats; + IntSummaryStatistics deltaCountStats; + + GameStateClassStats(UnrealName type) { + this.type = type; + deltaByteStats = new IntSummaryStatistics(); + deltaCountStats = new IntSummaryStatistics(); + } + + void add(GameStateObjectStats objStats) { + objectCount++; + totalBytes += objStats.totalBytes; + deltaByteStats.combine(objStats.deltaByteStats); + deltaCountStats.accept(objStats.deltaCount); + } + } + + private static class GameStateObjectStats { + int id; + UnrealName type; + IntSummaryStatistics deltaByteStats; + int totalBytes; + int deltaCount; + + void add(GameStateObject gso) { + int bytes = gso.getSizeInFile(); + if (id == 0) { + id = gso.getObjectId(); + type = gso.getType(); + deltaByteStats = new IntSummaryStatistics(); + totalBytes = bytes; + } else { + deltaByteStats.accept(bytes); + totalBytes += bytes; + deltaCount++; + } + } + } + + private static class LargestList { + final Queue queue; + int queueSize; + + LargestList() { + queue = new PriorityQueue<>(LARGEST_LIMIT, Comparator.comparingInt(ISizedObject::getSizeInFile)); + } + + void add(T obj) { + if (queueSize < LARGEST_LIMIT) { + queueSize++; + queue.add(obj); + } else if (obj.getSizeInFile() > queue.peek().getSizeInFile()) { + queue.poll(); + queue.add(obj); + } // else obj size is <= the smallest in queue, so nothing to do + } + + ObservableList toList() { + var list = FXCollections.observableArrayList(); + T obj; + while ((obj = queue.poll()) != null) { // PriorityQueue's iterator() is unordered + list.addFirst(obj); + } + return list; + } + } + +} diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryFramesTable.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryFramesTable.java index 808412b..dfa2d0a 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryFramesTable.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryFramesTable.java @@ -22,6 +22,7 @@ import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.ToolBar; +import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; @@ -124,6 +125,7 @@ public HistoryFramesTable(HistoryFile history) { table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS); table.itemsProperty().bind( filters.getProperty().map(p -> FXCollections.observableList(history.getFrames().stream().filter(p).toList()))); + VBox.setVgrow(table, Priority.ALWAYS); // top-level layout diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryGeneralUI.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryGeneralUI.java index ae73847..549ff6b 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryGeneralUI.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryGeneralUI.java @@ -77,9 +77,11 @@ public HistoryGeneralUI(X2SaveGameHeader saveHeader, HistoryFile historyFile) { var colSingletonType = new TableColumn("Singleton Type"); colSingletonType.setCellValueFactory(f -> new ReadOnlyStringWrapper(f.getValue().getType().getOriginal())); StandardCellFactoryHelper.setFactoryForStringValueColumn(colSingletonType); + var colSingletonFirstFrame = new TableColumn("First Frame"); + colSingletonFirstFrame.setCellValueFactory(f -> new ReadOnlyIntegerWrapper(f.getValue().getFirstFrame()).asObject()); var singletonsTable = new TableView<>(FXCollections.observableList(historyFile.getSingletons())); - singletonsTable.getColumns().addAll(colSingletonId, colSingletonType); + singletonsTable.getColumns().addAll(colSingletonId, colSingletonType, colSingletonFirstFrame); singletonsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS); // singleton state properties table diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryObjectsTable.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryObjectsTable.java index a96fe39..105b1ac 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryObjectsTable.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/HistoryObjectsTable.java @@ -26,6 +26,7 @@ import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.ToolBar; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; @@ -142,6 +143,7 @@ public HistoryObjectsTable(HistoryFile history, TableView framesTa .ifPresent(o -> Platform.runLater(() -> objectsTable.getSelectionModel().select(o))); } }); + VBox.setVgrow(objectsTable, Priority.ALWAYS); // top-level layout diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/ObjectPropertiesTable.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/ObjectPropertiesTable.java index 491bb56..e8a47b1 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/ObjectPropertiesTable.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/jfx/ui/history/ObjectPropertiesTable.java @@ -27,6 +27,7 @@ import javafx.scene.control.TreeTableCell; import javafx.scene.control.TreeTableColumn; import javafx.scene.control.TreeTableView; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; @@ -138,6 +139,7 @@ public ObjectPropertiesTable(TableView framesTable, TableView { - var path = new ArrayList(); - var treeItem = getTableRow().getTreeItem(); - while (true) { - path.add(treeItem.getValue().getName()); - treeItem = treeItem.getParent(); - if (treeItem.getValue() == null) { - // reached root node - break; + var frameNum = Integer.toString(frame.getNumber()); + if (framesTable == null) { + setText(frameNum); + } else { + var link = new Hyperlink(frameNum); + link.setOnAction(_ -> { + var path = new ArrayList(); + var treeItem = getTableRow().getTreeItem(); + while (true) { + path.add(treeItem.getValue().getName()); + treeItem = treeItem.getParent(); + if (treeItem.getValue() == null) { + // reached root node + break; + } } - } - Collections.reverse(path); - - framesTable.getSelectionModel().select(frame); - framesTable.scrollTo(framesTable.getSelectionModel().getSelectedIndex()); - objectsTable.getSelectionModel().select(state); - objectsTable.scrollTo(objectsTable.getSelectionModel().getSelectedIndex()); - - var newTreeItem = table.getRoot(); - for (var element : path) { - newTreeItem.setExpanded(true); - newTreeItem = newTreeItem.getChildren().stream().filter(i -> i.getValue().getName().equals(element)).findAny().get(); + Collections.reverse(path); - } - table.getSelectionModel().select(newTreeItem); - table.scrollTo(table.getSelectionModel().getSelectedIndex()); - }); - setGraphic(link); + framesTable.getSelectionModel().select(frame); + framesTable.scrollTo(framesTable.getSelectionModel().getSelectedIndex()); + objectsTable.getSelectionModel().select(state); + objectsTable.scrollTo(objectsTable.getSelectionModel().getSelectedIndex()); + + var newTreeItem = table.getRoot(); + for (var element : path) { + newTreeItem.setExpanded(true); + newTreeItem = newTreeItem.getChildren().stream().filter(i -> i.getValue().getName().equals(element)).findAny().get(); + + } + table.getSelectionModel().select(newTreeItem); + table.scrollTo(table.getSelectionModel().getSelectedIndex()); + }); + setGraphic(link); + } } } } diff --git a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/prefs/HistoryFileTab.java b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/prefs/HistoryFileTab.java index ca984b9..2e6445b 100644 --- a/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/prefs/HistoryFileTab.java +++ b/x2-data-explorer/src/main/java/com/github/rcd47/x2data/explorer/prefs/HistoryFileTab.java @@ -4,6 +4,7 @@ public enum HistoryFileTab { GENERAL("General"), FRAMES("Frames"), + BLOAT_ANALYSIS("Bloat Analysis"), PROBLEMS("Problems"), ; diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryIndexEntry.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryIndexEntry.java index e2fc2db..9f1f023 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryIndexEntry.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryIndexEntry.java @@ -9,7 +9,6 @@ public class X2HistoryIndexEntry { private int previousVersionIndex; private long position; private int length; - private boolean singletonState; private Class mappedType; X2HistoryIndexEntry(int arrayIndex, UnrealName type, int previousVersionIndex) { @@ -53,14 +52,6 @@ void setLength(int length) { this.length = length; } - public boolean isSingletonState() { - return singletonState; - } - - void setSingletonState(boolean singletonState) { - this.singletonState = singletonState; - } - public Class getMappedType() { return mappedType; } diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryReader.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryReader.java index 8a2de48..8f9a035 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryReader.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/history/X2HistoryReader.java @@ -208,7 +208,6 @@ public X2HistoryIndex buildIndex(FileChannel decompressedIn) throws IOException for (var entry : entries) { var entryTyping = typings.getOrDefault(entry.getType(), UnrealTypeInformer.UNKNOWN); entry.setMappedType(entryTyping.mappedType); - entry.setSingletonState(entryTyping.isSingletonStateType); } return new X2HistoryIndex(decompressedIn, createdByWOTC, entries, typings); diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/XComSingletonStateType.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/XComSingletonStateType.java deleted file mode 100644 index 1fdd8e4..0000000 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/XComSingletonStateType.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.rcd47.x2data.lib.unreal.mappings; - -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -@Documented -@Retention(RUNTIME) -@Target(TYPE) -public @interface XComSingletonStateType { - -} diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/CharacterStat.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/CharacterStat.java index a8ea403..bf5ca9c 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/CharacterStat.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/CharacterStat.java @@ -2,11 +2,14 @@ import java.util.List; +import com.github.rcd47.x2data.lib.unreal.mapper.ref.IXComStateObjectReference; + public class CharacterStat { public float BaseMaxValue; public float CurrentValue; public float MaxValue; public List StatModAmounts; + public List> StatMods; } diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Analytics.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Analytics.java index 6a45eec..0fcbffd 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Analytics.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Analytics.java @@ -4,9 +4,7 @@ import java.util.Map; import com.github.rcd47.x2data.lib.unreal.mappings.UnrealUntypedProperty; -import com.github.rcd47.x2data.lib.unreal.mappings.XComSingletonStateType; -@XComSingletonStateType public class XComGameState_Analytics extends XComGameState_BaseObject { @UnrealUntypedProperty(1) diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Item.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Item.java index 9acf471..f2c101a 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Item.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Item.java @@ -10,6 +10,7 @@ public class XComGameState_Item extends XComGameState_BaseObject { public List m_arrWeaponUpgradeNames; public IXComNameObjectReference m_TemplateName; public String Nickname; + public int Quantity = 1; // set in defaultproperties public List StatBoosts; } diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Unit.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Unit.java index 278898a..7e2688e 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Unit.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/mappings/base/XComGameState_Unit.java @@ -10,14 +10,17 @@ public class XComGameState_Unit extends XComGameState_BaseObject { + public List> Abilities; public List AbilityTree; public List AcquiredTraits; public List ActionPoints; public List AffectedByEffectNames; + public List> AffectedByEffects; public List AlertTraits; public List AllSoldierBonds; public List AppearanceStore; public List AppliedEffectNames; + public List> AppliedEffects; public List AWCAbilities; public boolean bIsSpecial; public Map CharacterStats; @@ -30,6 +33,7 @@ public class XComGameState_Unit extends XComGameState_BaseObject { public List EnemiesInteractedWithSinceLastTurn; public List HackRewards; public List HackRollMods; + public List> InventoryItems; public List KilledByDamageTypes; public IXComNameObjectReference m_SoldierClassTemplateName; public List m_SoldierProgressionAbilties; diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypeInformer.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypeInformer.java index 0a7fecc..9e24d5e 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypeInformer.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypeInformer.java @@ -7,21 +7,18 @@ public class UnrealTypeInformer { - public static final UnrealTypeInformer UNKNOWN = new UnrealTypeInformer(null, null, false, false, Map.of(), List.of()); + public static final UnrealTypeInformer UNKNOWN = new UnrealTypeInformer(null, null, false, Map.of(), List.of()); public final UnrealName unrealTypeName; public final Class mappedType; - public final boolean isSingletonStateType; public final boolean isUntypedStruct; public final Map arrayElementTypes; public final List untypedProperties; - UnrealTypeInformer(UnrealName unrealTypeName, Class mappedType, boolean isSingletonStateType, - boolean isUntypedStruct, Map arrayElementTypes, - List untypedProperties) { + UnrealTypeInformer(UnrealName unrealTypeName, Class mappedType, boolean isUntypedStruct, + Map arrayElementTypes, List untypedProperties) { this.unrealTypeName = unrealTypeName; this.mappedType = mappedType; - this.isSingletonStateType = isSingletonStateType; this.isUntypedStruct = isUntypedStruct; this.arrayElementTypes = arrayElementTypes; this.untypedProperties = untypedProperties; diff --git a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypingsBuilder.java b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypingsBuilder.java index c8c53d2..661497c 100644 --- a/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypingsBuilder.java +++ b/x2-data-lib/src/main/java/com/github/rcd47/x2data/lib/unreal/typings/UnrealTypingsBuilder.java @@ -29,7 +29,6 @@ import com.github.rcd47.x2data.lib.unreal.mappings.UnrealTypeName; import com.github.rcd47.x2data.lib.unreal.mappings.UnrealUntypedProperty; import com.github.rcd47.x2data.lib.unreal.mappings.UnrealUntypedStruct; -import com.github.rcd47.x2data.lib.unreal.mappings.XComSingletonStateType; public class UnrealTypingsBuilder { @@ -38,7 +37,6 @@ public class UnrealTypingsBuilder { private static final DotName TYPE_NAME_ANNOTATION = DotName.createSimple(UnrealTypeName.class.getName()); private static final DotName UNTYPED_PROPERTY_ANNOTATION = DotName.createSimple(UnrealUntypedProperty.class.getName()); private static final DotName UNTYPED_STRUCT_ANNOTATION = DotName.createSimple(UnrealUntypedStruct.class.getName()); - private static final DotName SINGLETON_STATE_ANNOTATION = DotName.createSimple(XComSingletonStateType.class.getName()); private static final UnrealName STATE_OBJ_REF_NAME = new UnrealName("StateObjectReference"); private static final Map DLC_PACKAGES = Map.ofEntries( Map.entry(new UnrealName("CovertInfiltration"), "covertinf"), @@ -84,7 +82,7 @@ private Map build(Function