Skip to content

RFC 0014: CHASM Processor #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added rfc/0014-assets/asmr_class_diagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions rfc/0014-assets/asmr_class_diagram.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rfc/0014-assets/asmr_hello_world.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rfc/0014-assets/asmr_object_diagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
144 changes: 144 additions & 0 deletions rfc/0014-chasm-processor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
## Summary

This RFC is part of a series aiming to define and prototype the CHASM (Collision-Handling ASM, formerly referred to as ASMR) project. It expands upon [RFC-0003](https://github.com/QuiltMC/rfcs/pulls/3) by proposing a design for the processor, a backend module that loads transformers and applies them as needed to a given classpath.

## Motivation

CHASM would be based on two key kinds of components:

- **Processor** - a backend library which applies *transformers* to the application's bytecode.
- **Compiler** - a piece of software which can take in some form of input (annotated classes, domain-specific language, etc.) and turn them into *transformers* usable by the processor.

*Transformers* present themselves as classes loadable by the JVM that can transform the CHASM tree provided by the processor. Those would be invisible to modders, being only generated by compilers as intermediary objects to be executed by the processor.

### Requirements

*The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [tools.ietf.org RFC 2119](https://tools.ietf.org/html/rfc2119)*

- The processor **must** generate a data structure representing the bytecode from all classes belonging to a given classpath
- The processor **must** load a predefined list of transformers
- The processor **must** offer ways to tweak the order in which transformers are loaded and applied
- The processor **must** allow transformers to factor other transformations into their own
- The bytecode processing system **must** be deterministic given a game version, a set of mods, and a set of config properties
- The processor **should not** verify the output bytecode; this task is left to the JVM's verifier. Compilers should ensure the generated transformers generate bytecode that is valid and well-formed.

## Explanation

### Implementation

#### Class representation

![Class diagram of the tree API](0014-assets/asmr_class_diagram.png)

Many nodes in the tree representation of a class (`org.objectweb.asm.tree`) count as "symbols" that can be matched against and replaced. Mixin has the concept of matching an `AbstractInsnNode` using custom injection points, here we generalize this concept to be able to match against and transform everything from whole `MethodNode`s, all the way down to individual fields of an instruction, such as the operand to an `ALOAD` instruction.

The granularity of the tree structure is a key point of this design. Individual operands to instructions are treated the same conceptually to a whole instruction node, which is treated the same as a whole method node in a class.

![Hello World Example Diagram](0014-assets/asmr_hello_world.png)

A "capture" is a pointer to one or more sequential nodes. It can be one of two types:

- `AsmrCapture`: references a single node
- `AsmrListCapture`: references a parent list, a start node within the list, and an end node within the list.
- To better account for changes to the node list made by other transformers, list captures can choose whether they include the start and end nodes. For example here, pointing right after the field instruction node is not the same thing as pointing right before the load constant instruction node because, if another transformer injects between those two nodes, the new nodes will be captured in the former case but not in the latter case.

For a capture of instructions in a method (similar to what Mixin has), the parent is the method instruction list ("body"), and the start and end indexes are into that instruction list. There can also be a capture of fields or methods in a class, and a capture of operands in a single instruction. If a single symbol needs to be targeted, it can be either represented as a basic capture, or as a list capture of length 1. There can also be a list capture of length 0, where the start index equals the end index.

#### Processor phases

// 2 orderings : general order (read+write) calculated from a dependency graph, and write order

The processor phases are split into two phases (actually it's more complicated than this, more on that later). We first have the read phase and then we have the write phase.

When a class is transformed, the read phase is first invoked for all transformers. This is when all transformers inspect the tree of the class and collect a set of captures they are interested in. Also in this phase, the transformers are allowed to tell the processor certain things about the slices they identify. For example, a transformer could tell the processor that a certain slice must not be modified by any other transformer during the write phase. No modification to the class tree happens in this phase.

In the read phase, each transformer also tells the processor about the writes it plans to do during the write phase (along with which write function to use in each case, see below). Note that this means that between the read phase and the write phase, the processor can detect inter-mod conflicts, which are defined as one processor marking a slice for write which overlaps a slice that another processor marked as cannot-be-modified.

In the write phase, all planned writes are invoked. A "write" is defined as replacing a slice with another "replacement" tree. The replacement tree can be constructed from literal values or can have subtrees substituted with slices that were gathered during the read phase (this is called "capturing"). A tree can of course be constructed from literal values in some places and captured slices in others. Note that you can inject without replacing any code by replacing an empty slice.

For example, let's say you want to capture a local variable. In the read phase, you can identify a local variable by an `aload x` instruction somewhere (which may be nearby to some interesting piece of code), and capture the `x` operand in a slice. Then, in a write, you can read and write that variable by substituting the captured slice containing `x` into an `aload` or `astore` instruction.

#### Order of operations

With the aforementioned features, you can implement most of what Mixin supports (and a lot more too). However there are one or two things unanswered, and that's "priority", and what about if some transformer is designed to read or modify the code *after* some or all transformation has taken place?

As of writing this draft, the basic idea is that write operations can be given some way to declare their ordering, so that the writes can be applied in a way that honours something equivalent to Mixin's priority system. Additionally, there may be a number of read phases and write phases happening in alternation for transformers that declare they want to work on partially or fully transformed code.

#### Format

Transformers in the backend need to be very flexible to be able to support all these features. The initial idea is that the read phase, and probably the write phase too, need something close to a Turing machine to be implemented properly. But at that point, since they are basically executing code anyway, why not make the backend format... Java bytecode? A transformer implements a Java interface, the read method is called during the read phase, which passes method references back to the transformer to be executed during the write phase. The implementation of a transformer may capture slices using fields in that class.

There is one clear disadvantage to using Java bytecode as the target format for the backend, and that's that it means you could implement a transformer directly in Java if you wanted to. The way to discourage this would be to offer attractive frontend solutions that do the same thing as what they would want to do with the backend, so why do it the hard way?

In addition, what is the *real* problem with people doing what they're not supposed to and compiling Java to the backend directly? After all, the read-write paradigm in the backend is designed to have inter-mod safety, so even if someone manages to write a transformer in Java they are not likely to break anything that way. No, the *real* problem is that the backend isn't designed to be easy to write code in, and it may be easy to forget to do things, such as marking slices as unmodifiable. Also, there aren't rigorous checks in the backend against creating broken bytecode. Both of these checks are the responsibility of the compiler.



The problem could also be mitigated by putting illegal Java identifier characters in the interface method(s) that need to be implemented.

#### Transformer Verifier

To ensure bytecode modifications that are functionally pure, transformers' own bytecode is scanned and verified. An exception is thrown if a transformer does not respect the following criteria:

- All references to non-whitelisted or blacklisted classes are disallowed.
- All classes are blacklisted by default
- The transformer class itself (the one that's currently being verified) is whitelisted.
- All classes that ship with the CHASM processor are whitelisted by default.
- This check is *not* the same as checking the package prefix, as this restriction may be circumvented by putting a mod's own classes in the same package.
- Certain JDK classes are whitelisted, such as `String`, the full list of them may be added to over time.
- All classes, methods, etc. annotated with `@ApiStatus.Internal` or `@HideFromTransformers` are blacklisted.
- Some JDK methods inside blacklisted classes may be whitelisted, such as `System.arraycopy`, the full list of them may be added to over time.
- Some JDK methods inside whitelisted classes may be blacklisted, such as `Math.random`, the full list of them may be added to over time.
- Certain bytecode instructions are disallowed, such as `MONITORENTER` and `MONITOREXIT`.
- Certain method modifiers are disallowed, such as `ACC_SYNCHRONIZED` and `ACC_NATIVE`.
- All usages of the `INVOKEDYNAMIC` instruction will be disallowed except:
- If the bootstrap method is a member of `java/lang/invoke/StringConcatFactory`.
- If the bootstrap method is `java/lang/invoke/LambdaMetafactory.metafactory`, and:
- The return type additionally is not annotated as `@ApiStatus.NotExtendable`.
- The lambda captures are either primitive types, from a short list of acceptable JDK classes (e.g. `String`), or annotated with `@AllowLambdaCapture`.
- More allowable `INVOKEDYNAMIC` calls may be added in the future.
- All verified classes shall extend `java/lang/Object`.
- Verified classes may only have fields with the `ACC_FINAL` modifier. In addition, these fields shall only be from a short list of allowable types, such as primitive types and `String`.
- If the class file version is 52 (Java 8) or lower, then all `PUTFIELD` and `PUTSTATIC` instructions whose owner is the current transformer class will be disallowed, unless they are respectively in a `<init>` or `<clinit>` method.
- This explicit check is needed in addition to the `ACC_FINAL` rule because Java 8 JVMs do not throw a `VerifyError` when final field is modified outside the appropriate initialization method.

<!--For technical changes, such as changes to APIs, first give an overview of how
this proposed change would work. Explain how it would be used, with code
examples. Then, give a more in depth explanation of how it would be implemented
and how it would interact with other parts of the project and other Quilt
projects.-->


## Drawbacks

Why should we not do this?


## Rationale and Alternatives

- Instead of using a dedicated processor, transformers could be self-contained executables that each scan the classpath
- Being able to create transformers in Java seems like a relatively small price to pay. While there is always the option of a Turing machine, it would require writing and maintaining a separate parser library.

<!--Why is this the best possible design?/What other designs are possible and why should we choose this one instead?/What other designs have benefits over this one? Why should we choose an alternative instead?/What is the impact of not doing this?-->

## Prior Art

- Mixin
- Forge's patches
- ASM's Tree API

<!--If this has been done before by some other project, explain it here. This could be positive or negative. Discuss what worked well for them and what didn't. There may not always be prior art, and that's fine.-->

## Unresolved Questions

- Labels. We need a way to create labels and substitute them. Maybe this could mean that the actual substitution trees are created in the read phase, but not actually substituted until the write phase?

### Out of scope for this RFC

- What should the frontend look like?
- How are compilers implemented?
- This document's purpose is to propose a design for the processor backend. The task of designing one or more compilers is left to a future proposal.

## Expected Response

<!--How do might the wider community respond to this change? Who will be affected by it and how? Who has advocated for this change? Who has advocated against it?-->