From 14c8ac934745458d5e915a191ef7de403e16eb5b Mon Sep 17 00:00:00 2001 From: Jennifer Taylor Date: Sun, 23 May 2021 20:32:21 +0000 Subject: [PATCH] Implement the first bits of bytecode processing, including the ability to go to an animation frame. --- bemani/format/afp/render.py | 334 ++++++++++++++++++++++++---- bemani/format/afp/swf.py | 29 +-- bemani/format/afp/types/__init__.py | 2 + bemani/format/afp/types/ap2.py | 17 ++ 4 files changed, 329 insertions(+), 53 deletions(-) diff --git a/bemani/format/afp/render.py b/bemani/format/afp/render.py index 929ef38..1b21d10 100644 --- a/bemani/format/afp/render.py +++ b/bemani/format/afp/render.py @@ -1,9 +1,10 @@ -from typing import Dict, List, Tuple, Optional, Union +from typing import Any, Dict, List, Tuple, Optional, Union from PIL import Image # type: ignore from .blend import affine_composite from .swf import SWF, Frame, Tag, AP2ShapeTag, AP2DefineSpriteTag, AP2PlaceObjectTag, AP2RemoveObjectTag, AP2DoActionTag, AP2DefineFontTag, AP2DefineEditTextTag, AP2PlaceCameraTag -from .types import Color, Matrix, Point, Rectangle +from .decompile import ByteCode +from .types import Color, Matrix, Point, Rectangle, AP2Trigger, AP2Action, PushAction, StoreRegisterAction, StringConstant, Register, THIS, UNDEFINED from .geo import Shape, DrawParams from .util import VerboseOutput @@ -47,7 +48,7 @@ class RegisteredDummy: class PlacedObject: # An object that occupies the screen at some depth. - def __init__(self, object_id: int, depth: int, rotation_offset: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int) -> None: + def __init__(self, object_id: int, depth: int, rotation_offset: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, mask: Optional[Rectangle]) -> None: self.__object_id = object_id self.__depth = depth self.rotation_offset = rotation_offset @@ -55,6 +56,7 @@ class PlacedObject: self.mult_color = mult_color self.add_color = add_color self.blend = blend + self.mask = mask @property def source(self) -> Union[RegisteredClip, RegisteredShape, RegisteredDummy]: @@ -75,8 +77,19 @@ class PlacedObject: class PlacedShape(PlacedObject): # A shape that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag # referencing an AP2ShapeTag. - def __init__(self, object_id: int, depth: int, rotation_offset: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, source: RegisteredShape) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend) + def __init__( + self, + object_id: int, + depth: int, + rotation_offset: Point, + transform: Matrix, + mult_color: Color, + add_color: Color, + blend: int, + mask: Optional[Rectangle], + source: RegisteredShape, + ) -> None: + super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) self.__source = source @property @@ -90,11 +103,24 @@ class PlacedShape(PlacedObject): class PlacedClip(PlacedObject): # A movieclip that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag # referencing an AP2DefineSpriteTag. Essentially an embedded movie clip. - def __init__(self, object_id: int, depth: int, rotation_offset: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, source: RegisteredClip) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend) + def __init__( + self, + object_id: int, + depth: int, + rotation_offset: Point, + transform: Matrix, + mult_color: Color, + add_color: Color, + blend: int, + mask: Optional[Rectangle], + source: RegisteredClip, + ) -> None: + super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) self.placed_objects: List[PlacedObject] = [] self.frame: int = 0 self.__source = source + self.playing: bool = True + self.requested_frame: Optional[int] = None @property def source(self) -> RegisteredClip: @@ -111,11 +137,49 @@ class PlacedClip(PlacedObject): def __repr__(self) -> str: return f"PlacedClip(object_id={self.object_id}, depth={self.depth}, source={self.source}, frame={self.frame}, total_frames={len(self.source.frames)}, finished={self.finished})" + # The following are attributes and functions necessary to support some simple bytecode. + def gotoAndPlay(self, frame: Any) -> None: + if not isinstance(frame, int): + # TODO: Technically this should also allow string labels to frames as identified in the + # SWF specification, but we don't support that here. + print(f"WARNING: Non-integer frame {frame} to gotoAndPlay function!") + return + if frame <= 0 or frame > len(self.source.frames): + return + self.requested_frame = frame + self.playing = True + + @property + def frameOffset(self) -> int: + return self.requested_frame or self.frame + + @frameOffset.setter + def frameOffset(self, val: Any) -> None: + if not isinstance(val, int): + # TODO: Technically this should also allow string labels to frames as identified in the + # SWF specification, but we don't support that here. + print(f"WARNING: Non-integer frameOffset {val} to frameOffset attribute!") + return + if val < 0 or val >= len(self.source.frames): + return + self.requested_frame = val + 1 + class PlacedDummy(PlacedObject): # A reference to an object we can't find because we're missing the import. - def __init__(self, object_id: int, depth: int, rotation_offset: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, source: RegisteredDummy) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend) + def __init__( + self, + object_id: int, + depth: int, + rotation_offset: Point, + transform: Matrix, + mult_color: Color, + add_color: Color, + blend: int, + mask: Optional[Rectangle], + source: RegisteredDummy, + ) -> None: + super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) self.__source = source @property @@ -123,6 +187,50 @@ class PlacedDummy(PlacedObject): return self.__source +class Movie: + def __init__(self, root: PlacedClip) -> None: + self.__root = root + + def getInstanceAtDepth(self, depth: Any) -> Any: + if not isinstance(depth, int): + return UNDEFINED + + # For some reason, it looks like internally the depth of all objects is + # stored added to -0x4000, so let's reverse that. + depth = depth + 0x4000 + + for obj in self.__root.placed_objects: + if obj.depth == depth: + return obj + + print(f"WARNING: Could not find object at depth {depth}!") + return UNDEFINED + + +class AEPLib: + def __init__(self, this: PlacedObject, movie: Movie) -> None: + self.__this = this + self.__movie = movie + + def aep_set_rect_mask(self, thisptr: Any, left: Any, right: Any, top: Any, bottom: Any) -> None: + if not isinstance(left, (int, float)) or not isinstance(right, (int, float)) or not isinstance(top, (int, float)) or not isinstance(bottom, (int, float)): + print("WARNING: Ignoring aeplib.aep_set_rect_mask call with invalid parameters!") + return + if thisptr is THIS: + self.__this.mask = Rectangle( + left=float(left), + right=float(right), + top=float(top), + bottom=float(bottom), + ) + else: + print("WARNING: Ignoring aeplib.aep_set_rect_mask call with unrecognized target!") + + def aep_set_set_frame(self, thisptr: Any, frame: Any) -> None: + # I have no idea what this should do, so let's ignore it. + pass + + class AFPRenderer(VerboseOutput): def __init__(self, shapes: Dict[str, Shape] = {}, textures: Dict[str, Image.Image] = {}, swfs: Dict[str, SWF] = {}, single_threaded: bool = False) -> None: super().__init__() @@ -137,6 +245,7 @@ class AFPRenderer(VerboseOutput): # Internal render parameters. self.__registered_objects: Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredDummy]] = {} + self.__movie: Optional[Movie] = None def add_shape(self, name: str, data: Shape) -> None: # Register a named shape with the renderer. @@ -193,6 +302,119 @@ class AFPRenderer(VerboseOutput): return paths + def __execute_bytecode(self, bytecode: ByteCode, clip: PlacedClip) -> None: + if self.__movie is None: + raise Exception("Logic error, executing bytecode outside of a rendering movie clip!") + + location: int = 0 + stack: List[Any] = [] + variables: Dict[str, Any] = { + 'aeplib': AEPLib(clip, self.__movie), + 'GLOBAL': self.__movie, + } + registers: List[Any] = [UNDEFINED] * 256 + + while location < len(bytecode.actions): + action = bytecode.actions[location] + + if action.opcode == AP2Action.END: + # End the execution. + self.vprint("Ending bytecode execution.") + break + elif action.opcode == AP2Action.GET_VARIABLE: + varname = stack.pop() + + # Look up the variable, put it on the stack. + if varname in variables: + stack.append(variables[varname]) + else: + stack.append(UNDEFINED) + elif action.opcode == AP2Action.SET_MEMBER: + # Grab what we're about to do. + set_value = stack.pop() + attribute = stack.pop() + obj = stack.pop() + + if not hasattr(obj, attribute): + print(f"WARNING: Tried to set attribute {attribute} on {obj} but that attribute doesn't exist!") + else: + setattr(obj, attribute, set_value) + elif action.opcode == AP2Action.CALL_METHOD: + # Grab the method name. + methname = stack.pop() + + # Grab the object to perform the call on. + obj = stack.pop() + + # Grab the parameters to pass to the function. + num_params = stack.pop() + if not isinstance(num_params, int): + raise Exception("Logic error, cannot get number of parameters to method call!") + params = [] + for _ in range(num_params): + params.append(stack.pop()) + + # Look up the python function we're calling. + try: + meth = getattr(obj, methname) + + # Call it, set the return on the stack. + stack.append(meth(*params)) + except AttributeError: + # Function does not exist! + print(f"WARNING: Tried to call {methname}({', '.join(repr(s) for s in params)}) on {obj} but that method doesn't exist!") + stack.append(UNDEFINED) + elif action.opcode == AP2Action.CALL_FUNCTION: + # Grab the method name. + funcname = stack.pop() + + # Grab the object to perform the call on. + obj = variables['GLOBAL'] + + # Grab the parameters to pass to the function. + num_params = stack.pop() + if not isinstance(num_params, int): + raise Exception("Logic error, cannot get number of parameters to function call!") + params = [] + for _ in range(num_params): + params.append(stack.pop()) + + # Look up the python function we're calling. + try: + func = getattr(obj, funcname) + + # Call it, set the return on the stack. + stack.append(func(*params)) + except AttributeError: + # Function does not exist! + print(f"WARNING: Tried to call {funcname}({', '.join(repr(s) for s in params)}) on {obj} but that function doesn't exist!") + stack.append(UNDEFINED) + elif isinstance(action, PushAction): + for obj in action.objects: + if isinstance(obj, Register): + stack.append(registers[obj.no]) + elif isinstance(obj, StringConstant): + if obj.alias: + stack.append(obj.alias) + else: + stack.append(StringConstant.property_to_name(obj.const)) + else: + stack.append(obj) + elif isinstance(action, StoreRegisterAction): + set_value = stack.pop() + if action.preserve_stack: + stack.append(set_value) + + for reg in action.registers: + registers[reg.no] = set_value + elif action.opcode == AP2Action.POP: + stack.pop() + else: + print(f"WARNING: Unhandled opcode {action} with stack {stack}") + + # Next opcode! + location += 1 + def __place(self, tag: Tag, operating_clip: PlacedClip, prefix: str = "") -> Tuple[Optional[PlacedClip], bool]: # "Place" a tag on the screen. Most of the time, this means performing the action of the tag, # such as defining a shape (registering it with our shape list) or adding/removing an object. @@ -253,6 +475,7 @@ class AFPRenderer(VerboseOutput): new_mult_color, new_add_color, new_blend, + obj.mask, newobj, ) @@ -267,6 +490,7 @@ class AFPRenderer(VerboseOutput): new_mult_color, new_add_color, new_blend, + obj.mask, newobj, ) operating_clip.placed_objects[i] = new_clip @@ -282,6 +506,7 @@ class AFPRenderer(VerboseOutput): new_mult_color, new_add_color, new_blend, + obj.mask, newobj, ) @@ -320,6 +545,7 @@ class AFPRenderer(VerboseOutput): tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, + None, newobj, ) ) @@ -327,14 +553,6 @@ class AFPRenderer(VerboseOutput): # Didn't place a new clip, changed the parent clip. return None, True elif isinstance(newobj, RegisteredClip): - # TODO: Handle ON_LOAD triggers for this object. Many of these are just calls into - # the game to set the current frame that we're on, but sometimes its important. - for flags, code in tag.triggers.items(): - for bytecode in code: - print("WARNING: Unhandled PLACE_OBJECT trigger!") - if self.verbose: - print(bytecode.decompile()) - placed_clip = PlacedClip( tag.object_id, tag.depth, @@ -343,10 +561,18 @@ class AFPRenderer(VerboseOutput): tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, + None, newobj, ) operating_clip.placed_objects.append(placed_clip) + for flags, code in tag.triggers.items(): + if flags & AP2Trigger.ON_LOAD: + for bytecode in code: + self.__execute_bytecode(bytecode, placed_clip) + else: + print("WARNING: Unhandled PLACE_OBJECT trigger with flags {flags}!") + # Placed a new clip, changed the parent. return placed_clip, True elif isinstance(newobj, RegisteredDummy): @@ -359,6 +585,7 @@ class AFPRenderer(VerboseOutput): tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, + None, newobj, ) ) @@ -402,13 +629,14 @@ class AFPRenderer(VerboseOutput): if not removed_objects: print(f"WARNING: Couldn't find object to remove by ID {tag.object_id} and depth {tag.depth}!") + # TODO: Handle ON_UNLOAD triggers for this object. I don't think I've ever seen one + # on any object so this might be a pedantic request. + # Didn't place a new clip, changed parent clip. return None, True elif isinstance(tag, AP2DoActionTag): - print("WARNING: Unhandled DO_ACTION tag!") - if self.verbose: - print(tag.bytecode.decompile()) + self.__execute_bytecode(tag.bytecode, operating_clip) # Didn't place a new clip. return None, False @@ -457,6 +685,9 @@ class AFPRenderer(VerboseOutput): if parent_blend not in {0, 1, 2} and blend in {0, 1, 2}: blend = parent_blend + if renderable.mask: + print(f"WARNING: Unsupported mask Rectangle({renderable.mask})!") + # Render individual shapes if this is a sprite. if isinstance(renderable, PlacedClip): if only_depths is not None: @@ -553,30 +784,50 @@ class AFPRenderer(VerboseOutput): # Track whether anything in ourselves or our children changes during this processing. changed = False - # Clips that are part of our own placed objects which we should handle. - child_clips = [c for c in clip.placed_objects if isinstance(c, PlacedClip)] + while True: + # See if this clip should actually be played. + if clip.requested_frame is None and not clip.playing: + break - # Execute each tag in the frame. - if not clip.finished: - frame = clip.source.frames[clip.frame] - tags = clip.source.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)] + # See if we need to fast forward to a frame. + if clip.requested_frame is not None: + if clip.frame > clip.requested_frame: + # Rewind this clip to the beginning so we can replay until the requested frame. + clip.placed_objects = [] + clip.frame = 0 + elif clip.frame == clip.requested_frame: + # We played up through the requested frame, we're done! + clip.requested_frame = None + break - for tagno, tag in enumerate(tags): - # Perform the action of this tag. - self.vprint(f"{prefix} Sprite Tag ID: {clip.source.tag_id}, Current Tag: {frame.start_tag_offset + tagno}, Num Tags: {frame.num_tags}") - new_clip, clip_changed = self.__place(tag, clip, prefix=prefix) - changed = changed or clip_changed + # Clips that are part of our own placed objects which we should handle. + child_clips = [c for c in clip.placed_objects if isinstance(c, PlacedClip)] - # If we create a new movie clip, process it as well for this frame. - if new_clip: - changed = self.__process_tags(new_clip, prefix=prefix + " ") or changed + # Execute each tag in the frame. + if not clip.finished: + frame = clip.source.frames[clip.frame] + tags = clip.source.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)] - # Now, handle each of the existing clips. - for child in child_clips: - changed = self.__process_tags(child, prefix=prefix + " ") or changed + for tagno, tag in enumerate(tags): + # Perform the action of this tag. + self.vprint(f"{prefix} Sprite Tag ID: {clip.source.tag_id}, Current Tag: {frame.start_tag_offset + tagno}, Num Tags: {frame.num_tags}") + new_clip, clip_changed = self.__place(tag, clip, prefix=prefix) + changed = changed or clip_changed - # Now, advance the frame for this clip. - clip.advance() + # If we create a new movie clip, process it as well for this frame. + if new_clip: + changed = self.__process_tags(new_clip, prefix=prefix + " ") or changed + + # Now, handle each of the existing clips. + for child in child_clips: + changed = self.__process_tags(child, prefix=prefix + " ") or changed + + # Now, advance the frame for this clip. + clip.advance() + + # See if we should bail. + if clip.requested_frame is None: + break self.vprint(f"{prefix}Finished handling placed clip {clip.object_id} at depth {clip.depth}") @@ -687,12 +938,14 @@ class AFPRenderer(VerboseOutput): Color(1.0, 1.0, 1.0, 1.0), Color(0.0, 0.0, 0.0, 0.0), 0, + None, RegisteredClip( None, swf.frames, swf.tags, ), ) + self.__movie = Movie(root_clip) # These could possibly be overwritten from an external source of we wanted. actual_mult_color = Color(1.0, 1.0, 1.0, 1.0) @@ -726,4 +979,7 @@ class AFPRenderer(VerboseOutput): # Allow ctrl-c to end early and render a partial animation. print(f"WARNING: Interrupted early, will render only {len(frames)}/{len(root_clip.source.frames)} frames of animation!") + # Clean up + self.movie = None + return int(spf * 1000.0), frames diff --git a/bemani/format/afp/swf.py b/bemani/format/afp/swf.py index 62d6f9f..3a4f91c 100644 --- a/bemani/format/afp/swf.py +++ b/bemani/format/afp/swf.py @@ -8,6 +8,7 @@ from .types import Matrix, Color, Point, Rectangle from .types import ( AP2Action, AP2Tag, + AP2Trigger, DefineFunction2Action, InitRegisterAction, StoreRegisterAction, @@ -1235,33 +1236,33 @@ class SWF(TrackedCoverage, VerboseOutput): self.add_coverage(dataoffset + evt_offset, 8) events: List[str] = [] - if evt_flags & 0x1: + if evt_flags & AP2Trigger.ON_LOAD: events.append("ON_LOAD") - if evt_flags & 0x2: + if evt_flags & AP2Trigger.ON_ENTER_FRAME: events.append("ON_ENTER_FRAME") - if evt_flags & 0x4: + if evt_flags & AP2Trigger.ON_UNLOAD: events.append("ON_UNLOAD") - if evt_flags & 0x8: + if evt_flags & AP2Trigger.ON_MOUSE_MOVE: events.append("ON_MOUSE_MOVE") - if evt_flags & 0x10: + if evt_flags & AP2Trigger.ON_MOUSE_DOWN: events.append("ON_MOUSE_DOWN") - if evt_flags & 0x20: + if evt_flags & AP2Trigger.ON_MOUSE_UP: events.append("ON_MOUSE_UP") - if evt_flags & 0x40: + if evt_flags & AP2Trigger.ON_KEY_DOWN: events.append("ON_KEY_DOWN") - if evt_flags & 0x80: + if evt_flags & AP2Trigger.ON_KEY_UP: events.append("ON_KEY_UP") - if evt_flags & 0x100: + if evt_flags & AP2Trigger.ON_DATA: events.append("ON_DATA") - if evt_flags & 0x400: + if evt_flags & AP2Trigger.ON_PRESS: events.append("ON_PRESS") - if evt_flags & 0x800: + if evt_flags & AP2Trigger.ON_RELEASE: events.append("ON_RELEASE") - if evt_flags & 0x1000: + if evt_flags & AP2Trigger.ON_RELEASE_OUTSIDE: events.append("ON_RELEASE_OUTSIDE") - if evt_flags & 0x2000: + if evt_flags & AP2Trigger.ON_ROLL_OVER: events.append("ON_ROLL_OVER") - if evt_flags & 0x4000: + if evt_flags & AP2Trigger.ON_ROLL_OUT: events.append("ON_ROLL_OUT") bytecode_offset += evt_offset diff --git a/bemani/format/afp/types/__init__.py b/bemani/format/afp/types/__init__.py index 875b55b..eaced8e 100644 --- a/bemani/format/afp/types/__init__.py +++ b/bemani/format/afp/types/__init__.py @@ -4,6 +4,7 @@ from .ap2 import ( AP2Action, AP2Object, AP2Pointer, + AP2Trigger, DefineFunction2Action, Expression, GenericObject, @@ -39,6 +40,7 @@ __all__ = [ 'AP2Action', 'AP2Object', 'AP2Pointer', + 'AP2Trigger', 'DefineFunction2Action', 'Expression', 'GenericObject', diff --git a/bemani/format/afp/types/ap2.py b/bemani/format/afp/types/ap2.py index cd5640c..047fbd4 100644 --- a/bemani/format/afp/types/ap2.py +++ b/bemani/format/afp/types/ap2.py @@ -2935,3 +2935,20 @@ class AP2Pointer: display_P = 0xb4 geom_P = 0xb5 filtesr_P = 0xb6 + + +class AP2Trigger: + ON_LOAD = 0x1 + ON_ENTER_FRAME = 0x2 + ON_UNLOAD = 0x4 + ON_MOUSE_MOVE = 0x8 + ON_MOUSE_DOWN = 0x10 + ON_MOUSE_UP = 0x20 + ON_KEY_DOWN = 0x40 + ON_KEY_UP = 0x80 + ON_DATA = 0x100 + ON_PRESS = 0x400 + ON_RELEASE = 0x800 + ON_RELEASE_OUTSIDE = 0x1000 + ON_ROLL_OVER = 0x2000 + ON_ROLL_OUT = 0x4000