436 lines
15 KiB
Java
436 lines
15 KiB
Java
/*
|
|
* Copyright (C) 2012 Brendan Robert (BLuRry) brendan.robert@gmail.com.
|
|
*
|
|
* This library is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU Lesser General Public
|
|
* License as published by the Free Software Foundation; either
|
|
* version 2.1 of the License, or (at your option) any later version.
|
|
*
|
|
* This library is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
* Lesser General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Lesser General Public
|
|
* License along with this library; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
|
* MA 02110-1301 USA
|
|
*/
|
|
package jace.state;
|
|
|
|
import java.awt.image.BufferedImage;
|
|
import java.lang.annotation.Annotation;
|
|
import java.lang.reflect.Field;
|
|
import java.lang.reflect.Type;
|
|
import java.util.HashSet;
|
|
import java.util.LinkedHashSet;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.WeakHashMap;
|
|
import java.util.logging.Level;
|
|
import java.util.logging.Logger;
|
|
|
|
import jace.Emulator;
|
|
import jace.apple2e.SoftSwitches;
|
|
import jace.config.ConfigurableField;
|
|
import jace.config.InvokableAction;
|
|
import jace.config.Reconfigurable;
|
|
import jace.core.Computer;
|
|
import jace.core.PagedMemory;
|
|
import jace.core.Video;
|
|
import javafx.scene.image.Image;
|
|
import javafx.scene.image.WritableImage;
|
|
|
|
/**
|
|
*
|
|
* @author Brendan Robert (BLuRry) brendan.robert@gmail.com
|
|
*/
|
|
public class StateManager implements Reconfigurable {
|
|
|
|
private static StateManager instance;
|
|
|
|
public static StateManager getInstance(Computer computer) {
|
|
if (instance == null) {
|
|
instance = new StateManager(computer);
|
|
}
|
|
return instance;
|
|
}
|
|
State alphaState;
|
|
Set<ObjectGraphNode> allStateVariables;
|
|
WeakHashMap<Object, ObjectGraphNode> objectLookup;
|
|
long maxMemory = -1;
|
|
long freeRequired = -1;
|
|
@ConfigurableField(category = "Emulator", name = "Max states", description = "How many states can be captured, oldest states are automatically truncated.", defaultValue = "150")
|
|
public int maxStates = 100;
|
|
@ConfigurableField(category = "Emulator", name = "Capture frequency", description = "How often states are captured, in relation to each VBL (1 = 60 states/second, 2 = 30 states/second, 3 = 15 states/second, etc", defaultValue = "3")
|
|
public int captureFrequency = 3;
|
|
private ObjectGraphNode<BufferedImage> imageGraphNode;
|
|
|
|
Computer computer;
|
|
|
|
private StateManager(Computer computer) {
|
|
this.computer = computer;
|
|
}
|
|
|
|
private void buildStateMap() {
|
|
allStateVariables = new LinkedHashSet<>();
|
|
objectLookup = new WeakHashMap<>();
|
|
ObjectGraphNode emulator = new ObjectGraphNode(Emulator.instance);
|
|
emulator.name = "Emulator";
|
|
Set visited = new HashSet();
|
|
buildStateMap(emulator, visited);
|
|
|
|
// Also track all softswitches
|
|
for (SoftSwitches s : SoftSwitches.values()) {
|
|
final SoftSwitches ss = s;
|
|
ObjectGraphNode switchNode = new ObjectGraphNode(s);
|
|
switchNode.name = s.toString();
|
|
ObjectGraphNode switchVar = new ObjectGraphNode(s.getSwitch()) {
|
|
@Override
|
|
/**
|
|
* This is a more efficient way of updating the softswitch
|
|
* states And really in a way this works out much better to
|
|
* ensure that memory and graphics pages are set up correctly
|
|
* when resuming states.
|
|
*/
|
|
public void setCurrentValue(Object value) {
|
|
Boolean b = (Boolean) value;
|
|
ss.getSwitch().setState(b);
|
|
}
|
|
|
|
@Override
|
|
public Object getCurrentValue() {
|
|
return ss.getSwitch().getState();
|
|
}
|
|
};
|
|
switchVar.name = "switch";
|
|
switchVar.parent = switchNode;
|
|
allStateVariables.add(switchVar);
|
|
objectLookup.put(s, switchNode);
|
|
objectLookup.put(s.getSwitch(), switchVar);
|
|
}
|
|
}
|
|
|
|
private void buildStateMap(ObjectGraphNode node, Set visited) {
|
|
if (visited.contains(node)) {
|
|
return;
|
|
}
|
|
Object currentValue = node.getCurrentValue();
|
|
if (currentValue == null || visited.contains(currentValue)) {
|
|
return;
|
|
}
|
|
visited.add(node);
|
|
visited.add(currentValue);
|
|
objectLookup.put(node.getCurrentValue(), node);
|
|
for (Field f : node.getCurrentValue().getClass().getFields()) {
|
|
try {
|
|
Object o = f.get(node.getCurrentValue());
|
|
if (o == null) {
|
|
continue;
|
|
}
|
|
Annotation a = f.getAnnotation(Stateful.class);
|
|
ObjectGraphNode child = new ObjectGraphNode(o);
|
|
child.name = f.getName();
|
|
child.parent = node;
|
|
if (a != null) {
|
|
child.isStateful = true;
|
|
addStateVariable(child, f);
|
|
}
|
|
if (!f.getType().isPrimitive() && !f.getType().isArray()) {
|
|
// This is not stateful, but examine its children just in case
|
|
buildStateMap(child, visited);
|
|
}
|
|
} catch (IllegalArgumentException | IllegalAccessException ex) {
|
|
Logger.getLogger(StateManager.class.getName()).log(Level.SEVERE, null, ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The node and field correspond to an object member field that has a
|
|
*
|
|
* @Stateful annotation. This method has to make sense of this and plan out
|
|
* how states should be captured for this field.
|
|
* @param node
|
|
* @param f
|
|
*/
|
|
private void addStateVariable(ObjectGraphNode node, Field f) {
|
|
// if (f == null) {
|
|
// System.out.println("State var: "+node.parent.name+"."+node.name+ ">>" + node.index);
|
|
// } else {
|
|
// System.out.println("State var: "+node.parent.name+"."+node.name+ ">>" + f.getDeclaringClass().getName() + "." + f.getName());
|
|
// }
|
|
// For paged memory, video and softswiches we might have to do something
|
|
// more sophosticated.
|
|
Class type = node.getCurrentValue().getClass();
|
|
if (PagedMemory.class.isAssignableFrom(type)) {
|
|
addMemoryPages(node, f);
|
|
} else if (BufferedImage.class.isAssignableFrom(type)) {
|
|
addVideoFrame(node, f);
|
|
} else if (List.class.isAssignableFrom(type)) {
|
|
List l = (List) node.getCurrentValue();
|
|
Type fieldGenericType = f.getGenericType();
|
|
// Class genericType = Object.class;
|
|
// if (fieldGenericType instanceof ParameterizedType) {
|
|
// genericType = (Class) ((ParameterizedType) fieldGenericType).getActualTypeArguments()[0];
|
|
// } else {
|
|
// System.out.println("NOT PARAMATERIZED!");
|
|
// }
|
|
for (int i = 0; i < l.size(); i++) {
|
|
if (l.get(i) != null) {
|
|
// ObjectGraphNode inode = new ObjectGraphNode(genericType);
|
|
Object obj = l.get(i);
|
|
if (obj == null) {
|
|
continue;
|
|
}
|
|
ObjectGraphNode inode = new ObjectGraphNode(obj);
|
|
// inode.source = new WeakReference();
|
|
inode.parent = node;
|
|
inode.name = String.valueOf(i);
|
|
inode.index = i;
|
|
// Build this recursively because it might be a nested type (e.g. a list of maps)
|
|
// If it isn't a nested type then the default case shoud apply.
|
|
addStateVariable(inode, null);
|
|
}
|
|
}
|
|
} else if (Map.class.isAssignableFrom(type)) {
|
|
// TODO:
|
|
// Walk through members of the map, etc.
|
|
// This will at least let RamWorks memory pages work with state management
|
|
} else {
|
|
// This is the default case, just capture the field and move on
|
|
allStateVariables.add(node);
|
|
node.isPrimitive = f.getType().isPrimitive();
|
|
// Since we can only guess how state changes just assume we have to check for changes every time.
|
|
node.forceCheck = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track a stateful video framebuffer.
|
|
*
|
|
* @param node
|
|
* @param f
|
|
*/
|
|
private void addVideoFrame(ObjectGraphNode<BufferedImage> node, Field f) {
|
|
imageGraphNode = node;
|
|
}
|
|
|
|
/**
|
|
* Track a stateful set of memory.
|
|
*
|
|
* @param node
|
|
* @param f
|
|
*/
|
|
private void addMemoryPages(ObjectGraphNode<PagedMemory> node, Field f) {
|
|
PagedMemory mem = node.getCurrentValue();
|
|
ObjectGraphNode<byte[][]> internalmem = new ObjectGraphNode<>(mem.internalMemory);
|
|
internalmem.parent = node;
|
|
internalmem.name = "internalMemory";
|
|
for (int i = 0; i < mem.internalMemory.length; i++) {
|
|
byte[] memPage = mem.internalMemory[i];
|
|
if (memPage == null) {
|
|
continue;
|
|
}
|
|
ObjectGraphNode<byte[]> page = new ObjectGraphNode<>(memPage);
|
|
page.parent = internalmem;
|
|
page.name = String.valueOf(i);
|
|
page.index = i;
|
|
page.forceCheck = false;
|
|
allStateVariables.add(page);
|
|
objectLookup.put(mem.internalMemory[i], page);
|
|
}
|
|
}
|
|
|
|
public static void markDirtyValue(Object o, Computer computer) {
|
|
StateManager manager = getInstance(computer);
|
|
if (manager.objectLookup == null) {
|
|
return;
|
|
}
|
|
ObjectGraphNode node = manager.objectLookup.get(o);
|
|
if (node == null) {
|
|
return;
|
|
}
|
|
node.markDirty();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @return
|
|
*/
|
|
@Override
|
|
public String getName() {
|
|
return "State Manager";
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @return
|
|
*/
|
|
@Override
|
|
public String getShortName() {
|
|
return "state";
|
|
}
|
|
|
|
/**
|
|
* If reconfigure is called, it means the emulator state has changed too
|
|
* greatly and we need to abandon captured states and start from scratch.
|
|
*/
|
|
@Override
|
|
public void reconfigure() {
|
|
boolean resume = computer.pause();
|
|
isValid = false;
|
|
|
|
// Now figure out how much memory we're allowed to eat
|
|
maxMemory = Runtime.getRuntime().maxMemory();
|
|
// If we have less than 2% heap remaining then states will be recycled
|
|
freeRequired = maxMemory / 50L;
|
|
frameCounter = captureFrequency;
|
|
if (resume) {
|
|
computer.resume();
|
|
}
|
|
}
|
|
boolean isValid = false;
|
|
|
|
public void invalidate() {
|
|
isValid = false;
|
|
}
|
|
int stateCount = 0;
|
|
|
|
public void captureState() {
|
|
// If the state graph is invalidated it means we have to abandon all
|
|
// previously captured states. This helps ensure that rewinding will
|
|
// not result in an unintended or invalid state.
|
|
if (!isValid) {
|
|
alphaState = null;
|
|
if (allStateVariables != null) {
|
|
allStateVariables.clear();
|
|
}
|
|
allStateVariables = null;
|
|
// This will probably result in a lot of invalidated objects
|
|
// so it's a good idea to suggest to the JVM to reclaim memory now.
|
|
System.gc();
|
|
|
|
if (Emulator.instance == null) {
|
|
return;
|
|
}
|
|
|
|
// Re-examine the object structure of the emulator in case it changed
|
|
buildStateMap();
|
|
System.out.println(allStateVariables.size() + " variables tracked per state");
|
|
System.out.println(objectLookup.entrySet().size() + " objects tracked in emulator model");
|
|
isValid = true;
|
|
stateCount = 0;
|
|
}
|
|
if (alphaState == null) {
|
|
System.gc();
|
|
alphaState = captureAlphaState();
|
|
alphaState.tail = alphaState;
|
|
} else {
|
|
if (Runtime.getRuntime().freeMemory() <= freeRequired) {
|
|
invalidate();
|
|
return;
|
|
}
|
|
while (stateCount >= maxStates) {
|
|
removeOldestState();
|
|
stateCount--;
|
|
}
|
|
|
|
State newState = captureDeltaState(alphaState.tail);
|
|
// State newState = (stateCount % 2 == 0) ? captureDeltaState(alphaState.tail) : captureAlphaState();
|
|
alphaState.tail.addState(newState);
|
|
alphaState.tail = newState;
|
|
}
|
|
// Now capture the current screen
|
|
alphaState.tail.screenshot = getScreenshot();
|
|
stateCount++;
|
|
}
|
|
|
|
private Image getScreenshot() {
|
|
Image screen = computer.getVideo().getFrameBuffer();
|
|
return new WritableImage(screen.getPixelReader(), (int) screen.getWidth(), (int) screen.getHeight());
|
|
}
|
|
|
|
private State captureAlphaState() {
|
|
State s = new State();
|
|
s.deltaState = false;
|
|
allStateVariables.stream().map((node) -> {
|
|
s.put(node, new StateValue(node));
|
|
return node;
|
|
}).forEach((node) -> {
|
|
node.markClean();
|
|
});
|
|
return s;
|
|
}
|
|
|
|
private State captureDeltaState(State tail) {
|
|
State s = new State();
|
|
s.deltaState = true;
|
|
allStateVariables.stream().filter((node) -> !(!node.valueChanged(tail))).map((node) -> {
|
|
// If there are no changes to this node value, don't waste memory on it.
|
|
s.put(node, new StateValue(node));
|
|
return node;
|
|
}).forEach(ObjectGraphNode::markClean);
|
|
return s;
|
|
|
|
}
|
|
|
|
private void removeOldestState() {
|
|
if (alphaState == null) {
|
|
return;
|
|
}
|
|
if (alphaState.nextState == null) {
|
|
alphaState = null;
|
|
} else {
|
|
alphaState = alphaState.deleteNext();
|
|
}
|
|
}
|
|
// Don't capture for the first few seconds of emulation. This is sort of a
|
|
// hack but is also a very elegant way to help the emulator avoid wasting
|
|
// time at start since the emulator state changes a lot at first.
|
|
int frameCounter = 200;
|
|
|
|
/**
|
|
* Every time the Apple reaches VBL there is a screen update event The rest
|
|
* of the emulator is notified after the video frame was redrawn, so this is
|
|
* the best time to capture the state.
|
|
*/
|
|
public void notifyVBLActive() {
|
|
frameCounter--;
|
|
if (frameCounter > 0) {
|
|
return;
|
|
}
|
|
frameCounter = captureFrequency;
|
|
captureState();
|
|
}
|
|
|
|
@InvokableAction(
|
|
name = "Rewind",
|
|
alternatives = "Timewarp",
|
|
description = "Go back 1 second",
|
|
defaultKeyMapping = {"ctrl+shift+Open Bracket"}
|
|
)
|
|
public static void beKindRewind() {
|
|
StateManager manager = Emulator.withComputer(StateManager::getInstance, null);
|
|
new Thread(()->manager.rewind(60 / manager.captureFrequency)).start();
|
|
}
|
|
|
|
public void rewind(int numStates) {
|
|
boolean resume = computer.pause();
|
|
State state = alphaState.tail;
|
|
while (numStates > 0 && state.previousState != null) {
|
|
state = state.previousState;
|
|
numStates--;
|
|
}
|
|
state.apply();
|
|
alphaState.tail = state;
|
|
state.nextState = null;
|
|
Video.forceRefresh();
|
|
System.gc();
|
|
if (resume) {
|
|
computer.resume();
|
|
}
|
|
}
|
|
}
|