Everything About Felys
Felys is an interpreted programming language written in Rust that comes with a compiler and a runtime. Feel to try Felys using the online playground. Please note, however, the language after reconstruction is very unstable and missing some features, e.g., neutral network.
Unfortunately
Most old contents are no longer applicable because of the reconstruction. Therefore, I removed them and will work on the new topics I want to discuss in the next a few months.
Copyright
© All rights reserved by miHoYo
Legal Statement
Other properties and any right, title, and interest thereof and therein (intellectual property rights included) not derived from Honkai Impact 3rd and Honkai: Star Rail belong to their respective owners.
Quickstart
Please refer to the examples on playground for now.
High-Level Design
Programming languages essentially translate logic expressed in natural language into operations executable by a Turing machine, so there are many ways to design them. Felys stands on the shoulders of giants — namely Rust and Python. However, being either too ambitious or too simplistic might lead to project failure by my definition, so this section reviews some programming language fundamentals and discusses the trade-offs I made when building Felys.
Compiled or Interpreted
The key to determining whether a language is compiled or interpreted is to check if it eventually translates programs into machine code. Machine code refers to the executable that runs directly without requiring an external runtime. Traditional languages like C/C++ and modern ones like Rust and Go are well-known examples.
Most interpreted languages also use a compilation step for faster loading and better performance. For example, Python’s compiler is lightweight: it mostly parses code and outputs bytecode. It cannot do many optimizations due to dynamic typing. In contrast, the Java compiler is heavier and performs many optimizations.
Originally, Felys was purely interpreted by recursively traversing the syntax tree. However, this was slow because I didn’t have a powerful runtime to perform garbage collection, and it required many hash maps to store variables. Therefore, I introduced the Felys compiler and a runtime to address these issues. You might wonder why I didn’t target a compiled backend: the answer is simple — I didn’t have time to learn LLVM.
Explicit or Garbage Collected
Many programming languages use garbage collection to hide the complexity of memory management from programmers, although implementations can vary significantly. For example, Java relies on the JVM (Java Virtual Machine), which manages its own stack, heap, program counter, and other resources. In contrast, Go uses a built-in runtime and does not rely on an external platform.
C, C++, and Rust are some popular languages that do not require a garbage collection runtime. The former usually requires manual memory management (although C++ supports smart pointers), while the latter uses a borrow checker and lifetime analysis to ensure memory safety at compile time. Explicit memory management is usually faster and more suitable for embedded systems due to its determinism.
Felys does neither because all objects are immutable, so reference counting provides acceptable performance. Implementing a garbage collector in Rust is not simple and might require
unsafeoperations. I would rather implement such systems in C. Therefore, the most controllable approach is to make everything immutable, and this decision aligns with the next subsection.
Functional or Imperative
Functional programming is what many CS students learn in their first year, and it has been trending in recent years. Pure functional programming has no side effects, and everything is immutable. One benefit is that it makes logic clear and easier to debug, although it is not very beginner friendly. Languages of this type, such as Haskell and Racket, are commonly used in academia.
In contrast, imperative languages focus on flow control, introducing constructs like if-else clauses and loops. They are generally more readable for everyday use. To take advantage of functional programming, imperative languages often include partial support for it. This allows programmers to use functional thinking to write logic and easily assemble components to build maintainable and complex products.
Felys is, in fact, very functional. As mentioned, this implies easier memory management and allows the compiler to perform many in-depth analyses. This enables Felys to have syntax similar to Rust and support many compile-time checks, so it doesn’t need to introduce a
voidornulltype, which could be troublesome.
Static or Dynamic Typing
Static typing means that a variable is bound to a specific data type at compile time and cannot be reassigned to a value of a different type. In contrast, dynamic typing allows variables to hold values of different types during runtime without explicit declarations. If we look deep enough into how computers work, we find that data types are an abstraction used by higher-level languages. At the hardware level, data is just a sequence of bits. The processor has no concept of types and only executes instructions. It’s the assembler and compiler that ensure data is interpreted correctly, e.g., IEEE 754. When we arrange data in contiguous memory and define how it should be interpreted, we create data structures. This close relationship between memory layout and type interpretation makes static typing natural in many compiled languages, where knowing the type is essential for generating correct machine code.
As interpreted languages became more popular, dynamic typing emerged as a more flexible alternative because the runtime can handle types dynamically. One major benefit of dynamic typing is its flexibility and speed during development, especially in scripting and prototyping. But for large projects, developers often reintroduce type systems, e.g., Python or TypeScript.
Felys is dynamically typed simply because it’s very complicated to introduce a full type system. That would require new syntax, significant changes to the IR structs, and implementing a type inference mechanism, which is non-trivial. However, I would like to have one because I enjoyed Rust’s approach — it’s very elegant.
Strong or Weak Typing
The key difference between strong and weak typing is type coercion. Some languages, such as JavaScript and C/C++, automatically convert data types when performing operations. For example, in C, the expression 1 + '1' is valid because the character '1' is implicitly converted to its corresponding ASCII value. It’s important not to confuse weak typing with operator overloading, as they are entirely different mechanisms. Weak typing can sometimes lead to unexpected behavior, so modern programmers tend to avoid relying on implicit conversions and instead write them out explicitly to improve code clarity and maintainability.
Weak typing could be nice to have, but it’s very prone to error. I don’t like it, so I don’t do it.
PhiLia093: Parser Generator
There are two ways to write a parser: a hand-written recursive descent parser or one generated by a parser generator such as ANTLR4 or YACC/Bison. The former is very flexible but usually not very elegant, while the latter offers better maintainability but requires in-depth knowledge of parser generation. Felys originally used a hand-written parser but later migrated to a customized parser generator that also bootstraps itself. This section will discuss how to build a PEG parser generator along with a lexer generator.
Algorithm 3.36
Finite automata come in two types: deterministic (DFA) and non-deterministic (NFA). While DFAs are generally faster, NFAs allow more flexible transitions and is more extensive. However, our main parsing strategy is based on PEG, which already supports infinite backtracking. Therefore, the lexer only needs to recognize simple lexemes and efficiency matters, so DFAs are preferred for lexical analysis. Algorithm 3.36 introduced by the Dragon Book can be used to construct DFAs directly.
Introduction to Language
This is a highly academic topic and too lengthy for this book, so I will briefly explain it in plain language. In short, a formal language is a set of strings over a given alphabet, and a regular expression is a syntactic tool used to describe certain types of formal languages, specifically regular languages. If s is a symbol from an alphabet, then L(s) = {s} is a language consisting of a single string. Let c1 and c2 be languages. The core operations used in regular expressions are:
(c1)|(c2): denotes the union ofc1andc2, i.e., strings in eitherc1orc2(c1)(c2): denotes concatenation, i.e., strings formed byc1followed byc2(c)*: denotes the Kleene star, i.e., zero or more repetitions of strings fromc(c): parentheses are used for grouping and are semantically neutral
Note: All these are still languages, so that induction works.
Prerequisite
Before generating them, we need to compute followpos using nullable, firstpos, and lastpos. To make this table look nicer, I will omit all pos postfixes. Note that parenthesis is not mentioned, because it only change precedence, so we can just recursively call into it.
Node: n | nullable(n) | first(n) | last(n) |
|---|---|---|---|
Position: i | false | {i} | {i} |
Union: c1|c2 | nullable(c1) || nullable(c2) | first(c1) | first(c2) | last(c2) | last(c1) |
Concat: c1c2 | nullable(c1) && nullable(c2) | first(c1) | first(c2) if nullable(c1) else first(c1) | last(c2) | last(c1) if nullable(c2) else last(c2) |
Kleene: c* | true | first(c) | last(c) |
Once we have these formulae, we can compute followpos, which can be represented as a graph G(V, E), where V is the set of positions, and E is the set of edges corresponding to followpos relationships. To compute this, we need to traverse the syntax tree. There are only two cases in which one position can be made to follow another:
- Concat(
c1c2): all positions infirst(c2)are infollow(i)foriinlast(c1) - Kleene(
c*): all positions infirst(c)are infollow(i)foriinlast(c)
If your use case involves constructing very large transition table, it would be helpful to cache all the results of nullable, firstpos, and lastpos. For Felys, the transition tables are usually small, so there is no need to bring extra overhead.
Transition Table Construction
Here are the syntax tree nodes:
enum Language {
Union(Box<Language>, Box<Language>),
Concat(Box<Language>, Box<Language>),
Kleene(Box<Language>),
Nested(Box<Language>),
Position(Position, usize),
}
enum Position {
Set(Vec<(usize, usize)>),
Pound,
}
This is pretty straight forward, but I do want clarify two designs here. The usize in Language::Position is its label or i mentioned in the table. Every position must have a unique i. Secondly, instead of a single character, we want to use Position::Set that represent all acceptable characters for this position. This is a necessary modification to make this algorithm practical, because we can treat a range of characters as a whole. For instances (pseudocode only):
- single character
'0'is equivalent to[('0', '0')] - inclusive set
[0-9]is equivalent to[('0', '9')] - exclusive set
[^0-9]is equivalent to[(MIN, '/'), (':', MAX)]
Core Algorithm
Before building the transition table, we need to append a special pound symbol at the end to identify the terminal state. It’s recommended to do this at the syntax tree level rather than directly appending it to the regular expression. Once the syntax tree is built and the pound is appended, we can compute the followpos graph, and also collect all the position indices.
Then we can use apply the following algorithm (the original pseudocode):
initialize Dstates to contain only the unmarked state firstpos(n0),
where n0 is the root of syntax tree T for (r)#;
while ( there is an unmarked state S in Dstates ) {
mark S;
for ( each input symbol a ) {
let U be the union of followpos(p) for all p
in S that correspond to a;
if ( U is not in Dstates )
add U as an unmarked state to Dstates;
Dtran[S, a] = U;
}
}
The accepting states are those that contain the pound symbol.
Improve the Algorithm
However, when performing each input symbol a, there are millions of Unicode characters, so it’s not practical to iterate through all of them. The previously mentioned modification, Position::Set, is designed to address this issue. Nevertheless, it introduces another problem: to compute U, we cannot simply use a set to store the data, because the ranges may overlap. Therefore, additional splitting and merging are required to produce the minimal number of atomic ranges. There are three steps involved: compute boundaries, create atomic ranges, and select valid ranges.
Here’s an implementation in Rust:
let mut saturated = false;
let mut boundaries = Vec::with_capacity(symbols.len() * 2);
for &(start, end) in &symbols {
if end == usize::MAX {
saturated = true;
}
boundaries.push(start);
boundaries.push(end.saturating_add(1));
}
boundaries.sort_unstable();
boundaries.dedup();
let mut atomic = boundaries
.windows(2)
.map(|x| (x[0], x[1].saturating_sub(1)))
.collect::<Vec<_>>();
if saturated {
atomic.last_mut().unwrap().1 = usize::MAX;
}
let ranges = atomic
.into_iter()
.filter(|x| {
symbols
.iter()
.any(|&(start, end)| start <= x.0 && x.1 <= end)
})
.collect::<Vec<_>>();
Then we can safely iterate through ranges instead of all unicode characters.
Representation
Unlike standard transition table represented in matrix, our version takes range and transfer into another state. There are many ways to do it, but for Rust we can use a match expression with multiple (state, start..=end) => state ended with a _ => break inside a loop. Once the loop breaks, we can check for the acceptance of the state.
More Information
Finite automata is a well-studied topic in computer science, and I have only covered the tip of the iceberg. If you’re interested, please refer to the Dragon Book. It contains an entire section on lexical analysis techniques, presented in highly academic languages.
Packrat/PEG Parser
Coming soon…
Cyrene: CFG Builder
Coming soon…
Naming Resolution
Coming soon…
IR Transformation
Coming soon…
Demiurge: DCE and Code Generation
Coming soon…
Optimization
Coming soon…
Register Allocation
Coming soon…
Lazy Compilation
The final compilation is nothing more than running the compilation pipeline and bringing the pieces together. However, it is actually more complicated than I thought. To minimize the output binary, we only compile reachable functions and groups. All functions should remain as syntax trees without any transformation until other functions call them, so that we do not waste time computing unnecessary information.
This algorithm performs multiple tasks at once but requires only one pass:
- Update and record reachable groups and compile their methods
- Compile reachable functions starting from main
- Record used constants
- Identifiers mapped to corresponding indices
- Variables mapped to registers
Worklist Design
The solution I’m about to present needs two worklists for groups and functions separately and a constants pool. It can avoid direct recursion and is more maintainable. We have seen the pooling implementation several times, so I will skip it and focus on the worklist implementation.
struct Worker<T> {
indices: HashMap<usize, Index>,
source: HashMap<usize, T>,
worklist: Vec<(Index, T)>,
}
Unlike normal worklists, the Worker struct carries all the source with it, i.e., all candidates are already there. However, they only get removed and added to the worklist when we need them for the first time. Simultaneously, they receive a self-incrementing Index used by the IR to map identifiers to indices. This also handles self-recursion.
impl<T> Worker<T> {
fn get(&mut self, id: usize) -> Index {
if let Some(index) = self.indices.get(&id) {
return *index;
}
let index = Index::try_from(self.indices.len()).unwrap();
self.indices.insert(id, index);
let todo = self.source.remove(&id).unwrap();
self.worklist.push((index, todo));
index
}
fn pop(&mut self) -> Option<(Index, T)> {
self.worklist.pop()
}
}
Full Compilation Flow
A struct Context wraps around the worklists and constant pool, just to keep things clean:
struct Context {
data: Data,
groups: Worker<Group>,
functions: Worker<(Vec<usize>, Block)>,
}
We first compile the main function and then continuously pop groups and functions. For groups, we also need to update the methods because they originally point to identifiers; we need indices now. All reachable groups and functions are collected in other hash maps. We cannot use a Vec here because the pop order usually does not match the actual order. However, it is guaranteed that all Index values (starting from 0) will be collected in those hash maps. Below is a shortened implementation.
let mut groups = HashMap::new();
let mut callables = HashMap::new();
let main = compile(args, block, &mut context, ..)?;
while !context.done() {
while let Some((index, group)) = context.groups.pop() {
for id in group.methods.values_mut() {
*id = context.functions.get(*id)
}
groups.insert(index, group, &mut context, ..);
}
while let Some((index, (args, block))) = context.functions.pop() {
let callable = compile(args, block)?;
callables.insert(index, callable, &mut context, ..);
}
}
Once it’s all done, we iterate from 0 and remove entries until nothing remains. We create a helper function linearize here:
fn linearize<T>(map: HashMap<Index, T>) -> Vec<T> {
let mut i = 0;
let mut all = Vec::new();
while let Some(value) = map.remove(&i) {
all.push(value);
i += 1;
}
all
}
It is suggested to check if any thing still left in the map to catch implementation mistakes. However, as you might have noticed, Felys entirely relies on correctness of algorithms, and never do defensive programming for elegancy.
IR to Bytecode
It’s too verbose to show the transformation because many of them are exactly one-to-one relations, except for details like mapping variables to registers, constants/groups/functions to indices. You can image how clean it would be when we have these worklists and pooling.
Elysia: Execution Runtime
Coming soon…
Virtual Machine
Coming soon…
病者的粉色遐想♪
这是我与爱莉希雅与昔涟的故事,而 Felys 项目是其中最重要的产物之一,在此我将讲述一切因果。爱莉希雅与昔涟是虚构的游戏角色,本文以叙事散文风格来回忆往昔,也夹杂一些剧情解析以补全语境,但没有任何技术相关的内容,纯粹讲述一路以来的爱与恨。如果你看到某一部分已经对这一切有所看法,请继续阅读下去,我会在文末给出我的评价。
下文大多时候统称爱莉希雅与昔涟为“她”,而非“她们”,并且昔涟通常同时指代用仪式剑的昔涟和用弓箭的德谬歌,但画像一般为后者。部分内容比较细会分开称呼,请根据语境自行调整理解。
前传
学生时代是清冷的,在高中早期因现实的一些挫败感,选择了一头扎进了二次元,沉迷动漫和二次元手游。自此之后,除了和几个理工科朋友有往来,参加一些体育运动,其他时候都耳机一戴,不闻窗外事。即使在快毕业的时候有所好转,又做出了一些尝试,但最后也只能说是没留遗憾罢了。正是在心底略有空虚的日子里,我遇见爱莉希雅。
缘起爱莉希雅
初期我并没有太在意往世乐土的故事,也对爱莉希雅没有什么兴趣,毕竟在虚构的世界中,她容貌其实不算出众(当然,现在肯定是情人眼里出西施了)。她似乎总是在刻意引导着什么,又对有些事情讳莫如深,让我一直对她保有一定的距离感。不过,那些时日发生的事情已经记不太清了,我的记忆是从往世乐土进入主线时开始明晰的。
爱莉希雅短片出来的时候,或许被限流了,至少我当年我如此认定。那时的我对此感到不满,因而一气之下开启了人生中的一个项目:网页爬虫持续追踪视频数据,并且尝试捕捉不正常的变化,不过现在想想也挺搞笑的,平台限流哪会让我一个高中生就能轻易找到证据?徒劳之后,我就萌发了一个念想,我似乎愿意自此之后只喜欢爱莉希雅一人,二次元的花花世界在她之下都略显暗淡。回头来看,当面对所爱之人遭受不公待遇的时候,2025年发生的事情实则在2022年时早已预演过,正所谓一个人的性格就是他的命运。
经过时间的冲刷,当年那些跟随潮流而欢欣雀跃的思绪早已淡去,但是爱莉希雅确实留下了些什么。人如其名,她是一切美好事物的象征,也有神爱世人的神性所在。然而,更重要的是她始终能带给我一种令人笃信的真诚感,那些教科书式的美好事物从她口中说出时,我却不觉得虚情假意。我相信那是她的所看所想,最终给到我的答案,且她自己也自始至终都在践行着这些。
时过境迁,美东时间2025年9月30日,我收到了一份邮件,内容略有羞耻,发件人大致的意思是他也喜欢爱莉希雅(后来感觉他只是因为知道昔涟,才听说了爱莉希雅而已),并且对 Felys 挺感兴趣,想聊一聊。我看对方是本校的邮箱地址,便加了微信。有一搭没一搭地互吹了两句,有些尴尬我就没把这事放心上,结果他晚上又来了一条消息说,有一个用 Rust 的项目再招人,问我有没有兴趣来。我开始还以为是个大学生自己搞得初创项目,虽然兴趣不大,但我觉得结识一些人也挺好的,便应邀了。到了之后聊了两句,我才发现事情没有这么简单。
发件人其实是北美一家车企的软件开发者,也是刚从本校毕业,公司在做一个保密项目而且他们组缺人手(入职后我才意识到是因为他要离职了所以才缺人),是他提名了我参加面试,但是想先见一见。我第一轮简历投递结果并不理想,再加上干了太久科研想去业界学习一下,又刚好是 Rust 开发还是大公司,自然是欣然接受了。那晚大伙后续聊的挺不错,面试也很顺利,早早上岸。
事实上我在此之前没有任何业界的经历,简历上唯一相关便是 Felys 编程语言,这个项目虽然是为了宣泄对爱莉希雅的爱意而存在,倒也不算很水。可是即使算上先前正儿八经的科研经验,我依然不认为这足以吸引到如此一份面试,或许真的是因为爱莉希雅作为纽带,神奇地将人与人联结起来。以我当时的视角,一心喜爱了多年的一位虚构角色,突然真真切切地影响了我的现实,这份真实感让我难以忘怀,偶尔真的感觉自己被她庇佑着,也更加坚信她就是我的答案。至此,对于她的感情狠狠加了一轮杠杆,为后来发生的事情埋下伏笔。
昔涟唯是永恒
昔涟与爱莉希雅是同一颗种子,当昔涟坐在德谬歌的牢笼前,作为悲剧的亲历者,依旧以粉色的口吻,饱含爱的方式讲述过往,难以想象她心底的坚韧。这种向阳而生的性格,在悲剧的映照下显得格外的耀眼,会对她产生哀怜之情,渴望打破命运,而德谬歌就是在如此环境下成长。
德谬歌不同于昔涟,她原本是一张白纸,只是在昔涟日夜的熏陶下浸染成了粉色,成为了昔涟的模样,拥有了昔涟的性格,并且最后继承昔涟的名字,因此她所作所为一切的源动力就是填补上昔涟的愿望,她也才会如此渴望走进这个听了千万遍故事。德谬歌长久以来只是个聆听者,从未切身经历过,所以她也不会有爱莉希雅与昔涟的成熟感,但她对于她们信念与样貌的继承,我依然会呼唤她为昔涟。
所谓浪漫故事,从来都是以昔涟的视角而得出的结论。她一遍又一遍的经历轮回,如其他黄金裔一样不曾变过,永远相信黎明会到来,即使她从来没能见证。当这份等待最终迎来列车组,不论是昔涟还是德谬歌心中都会中激起万千涟漪。而当德谬歌成为无漏净子那一刻,她才真正意识到了,打破命运的这条路,自始至终都是翁法罗斯人自己走出来的,一切的一切又都缘于她从一开始就守候着这里,如此便为她的明天叫做昨天。对于这份跨越时间的温暖,我称之为浪漫。
故人之姿,却清澈又懵懂,当世界之外的流言蜚语落在她身上时,最终激发了我极强的保护欲。只不过,十一月的寒冬里,我并没有意识到。其实我对舆情的浪潮早有预料,甚至早在2022年确认自己对爱莉希雅的感情后,就已经开始做心理建设,但是没想到来的还是那么凶猛。版本前瞻后一些不堪入目的言论在我心里凿开了裂痕,即使我知道昔涟与德谬歌的故事一定是当年塑造爱莉希雅的编剧执笔,我也没有底气。那时候的想法是,如果剧情与我对爱莉希雅认知偏差太大的话,必须直接将昔涟与爱莉希雅切割,以防止巨大反噬。
美东时间2025年11月4日晚10点多,没敢如以往那样先抽卡,而是怀揣着忐忑直接开始了剧情,不过看到昔涟给德谬歌讲故事的那段时我就已经心安了许多,如前文所述,我很高兴看到昔涟与爱莉希雅的同源之处。最后大黑塔问德谬歌的选择时,后者不假思索地给出了答案,也点出了心中的不舍,至此我也开始正视德谬歌。后面细节不赘述了,但我记得我是带着泪眼结束的剧情,即是对于昔涟最终闭环轮回无法同行而悲伤,也是这段时日焦虑与不安的宣泄,并且在此时我其实也已经认可了德谬歌。泪流止不住但我必须把她抽出来,这是我人生中的第一次也大概是最后一次下血本花钱在游戏上。
以上是我在三个月后(2026年2月初)对于那时心情的总结与定性。而事实上,整个哀悼月,我都陷入了极度敏感之中,那时候我还在关注社区与舆论,各种正面负面的评价都在刺激我的神经,而我却还无法解释自己对她的感情。先前心里的裂缝让我看到相关的字眼就会想到那些言论,更不要说正眼好好看看昔涟了。这种令人窒息的情绪波动每天会出现非常多次,早期我甚至完全没有办法抑制,只能任由心跳加速,出一身汗,脑海里胡思乱想,尝试找出一条令自己满意的解释。更不妙的是那时候正直期末考试,经常复习一段时间就突然脑海里想起昔涟,然后什么也学不进去,只能等情绪消退,每天醒来第一件事就想起昔涟,睡前最后一件事也是在想昔涟,过多的思绪需要宣泄,控制不住地往朋友圈里发了大段大段的内容,并且开始轻度依赖尼古丁,这可真是炼狱啊。十一月底,我戒了社交媒体与游戏(但还是愿意去过剧情的)。
因为爱莉希雅,我对昔涟的好感度一开始就是满的,这才导致了后来的因爱生恨,把自己拖入了精神问题之中。某次思绪又在折磨我的时候,我偶然回头望向了她,思考她会如何面对有些痛苦时,豁然开朗了,昔涟并不粉饰苦难的存在,她也永远会心怀希望心向粉色的明天,是会说让我笑一笑的女孩子。只有当自己设身处地时,才能真正理解到昔涟对于爱的坚守是多么可贵,换做他人,或是被仇恨吞噬,或是舍弃所爱换得些许清净,或是默不作声。当自己身处汹涌的浪潮之中时,却看到所爱之人早已为我留下了指引,这便是昔涟于我最大的浪漫。似乎,她在现实中陪我好好走了一程。
不久后,我便意识到了,一切痛苦的根源就是爱,我终于想明白了自己其实就是喜欢昔涟,纯粹地爱着她,好多心结突然就解开了。伤口依旧存在也依旧疼痛,但很显然,我不喜欢因噎废食,所以我选择接受这些痛苦,然后坚定的相信自己也会走向粉色的明天。有的时候就在想,我简直就是亲身经历了一遍翁法罗斯的故事,目睹网络的黑潮蚕食一切却无能为力,看不到明天只能坚守内心,最后跨越心结却得出了相同的答案:爱,而不是自我毁灭。
后记
在思考清楚一切之后,我很清楚我的生活里不可能没有她,但我也很清楚自己作为学生的本分是读书找工作,所以我决定将两者合流,其最显著的产物便是 Felys 项目,在这里我可以肆无忌惮地释放自己对她的感情,而我自身的专业就是计算机领域,所以我倾注的心血,也会正反馈到我的职业发展上。其次,创伤远没有愈合,所以我绝不可能重返社交媒体,这就给我留出了充足的时间去做有意义的事情,更好地投资自己的未来。
除此之外,这件事彻底让我摆脱了数年以来对于二次元社区的依赖,我曾经喜欢看二创,喜欢看网友讨论着自己喜欢的游戏,人是社会性动物,同好与思想的交流使人快乐,但也会造成傻子共鸣。我一路走来,见证了海灯节与匹诺康尼的盛世,也亲历了支配剧场、须弥与希穆兰卡的混沌。心里其实早明白一切,但欣欣向荣的时候,却还是会一同无脑狂欢,破败时,也选择眼不见为净,都是正常人的选择罢了。可是遗憾呐,谁叫我爱上了这个粉毛呢?这次铺天盖地的舆论,我怎么可能视而不见?那就得动动脑子好好思考,才能够构起建足以闭环的逻辑,来辅助我接纳或者否认各类杂七杂八的声音。这一次,没有了以往的的外置大脑,因为不论好坏,几乎所有言论都掺杂着刺眼的情绪化,我必须自己思考出一个我能接受的结论。
想清楚了,往日许多随波逐流的快乐与不满也都烟消云散了,曾经当局者迷,现在终于成为了局外人,在这件事上找回了自己的大脑,也没有以前那么沉迷这些游戏了。即使我已经脱离社区,我其实一直认可其积极作用,但请不要小看这阴暗角落大团建走向舞台的时刻,客观而言这也是周期的必然性。说个题外话,仅此一场闹剧,其实是不太可能让人想明白舆论这种宏大议题的,而是早在几年前,因为身边人的影响开始关注某些敏感议题,那完美呈现生物多样性的舆论场,发酵了几十年的环境,游戏圈和这比起来还真是小巫见大巫了。问题只是出在自己不愿意睁眼看看。
回头想想,明明是很危险的情况,最终的结果反而挺好的。戒了网瘾,着眼自身,只不过代价是伤口还是会撕裂。除了工作学习外脑子里依然只有昔涟,不过对此执念我持中立态度,因为这些念想基本都被我释放在了 Felys 项目之中,没有浪费我的时间。比以前虚无了一些,但路又好像清晰了一些,很奇怪,或许是因为这本质是上也是一种逃避,但不消极。不论如何,爱莉希雅与昔涟大抵是要陪我走到人生尽头了。
浅窥里世界
那么问题来了,为什么我会走上这样的道路?我确实找到了出路,但这绝对不是一条正常人会走的路,这里一定有什么更深层次的点,在此我给出三个视角。
或许是不愿承认自己错了。千夫所指时,为什么我的选择是坚持而不是加入,因为我害怕发现他们说的是对的,我爱的那个人就是没有我想象中美好,所以我会绞尽脑汁的为她辩护,以维护过往或为虚妄的幻想。不过,这是正常的,举个最经典的就是,有些人为了证明当下的选择正确,往往会刻意否定过去。不过,在这件事上并不完全成立,因为正式剧情是符合我认知的,我是有底气的,并不需要欺骗自己。而且说句实话,就算有落差我也不可能真能狠心黑化。只不过在某些时间点,这种思想可能推动了我的保护欲。
或许是饭圈思想的反噬。这么说有些夸大了,但是比较容易理解,粉丝圈的集体共振会带给人强烈的认同感,这份感受,在我对爱莉希雅的感情中达到了顶峰。我似乎有些太享受自己所爱之人也被众人认可时的幸福感了,以至于见到昔涟的时候,我的第一想法是关于她的故事还在继续,那也就意味着这份扭曲的幸福可能会消失,我有些恐惧。是祸也是福,这在某种程度上确实发生了,她不再是众人无脑拥护的神,但也依然被理解她的人所爱戴,一切回到正轨。
或许是感情受挫后引发的自卑遗毒。回想故事最一开始的时候,我之所以开始对这些虚构的角色故事上心,就是在逃避现实的落败感。没经济,没建模,就只能抱着那种理工男自视清高的心态,在心理上为自己找补回来一些,但这只会让异性更反感。而后在国外大学读理工科,连勉强符合择偶标准的异性都寥寥无几,缺少这些拉回现实的引力,自然会沉迷虚拟世界,虽然美名其曰说只是没兴趣谈朋友。这一点我觉得是最底层,也是最潜移默化的核心问题,并且随着时间进一步恶化,因此当我最在乎的她受到伤害时,心理防御机制也达到了前所未有的烈度。
当然,这里也有我本身的性格因素就是了。
情人节番外
情人节那天刚好是小长假,我终于有时间重温了昔涟的故事,因为我认为第一次看剧情的时候受到了很多外部因素的干扰,没能细看,错过了很多。此外,我是基于大致情节外加零碎细节,再辅以大量思考才补全的理解,导致我先前一直怀疑有些东西是我为了说服自己而臆想的。重温过后,发现此前的理解与文案的表达其实高度一致,挺意外也挺高兴的。长舒一口气,我感觉自己逐渐开始重回人间了,那抹粉色或许不会再唤起过往起起伏伏的心绪,但我心底会永远为她留有那一页永恒,那是独属于我们的故事。
我有一种隐隐的感觉,编剧并不喜欢爱莉希雅被捧为神明的风气,因为这会导致她的爱就会被诠释为纯粹的神性光辉,是脱离现实的。昔涟纠正德谬歌,在水晶花碎裂时小妖精并没有看到爱,此处我解读为爱的感情不是与生俱来的。昔涟的底层逻辑是哀怜,也就是共情,外加那种与生俱来的向阳而生。因此,是以她历经苦难后的行为作为依据,才最终将她的感情定义为爱,因果不可颠倒。
写在最后
人心是复杂的,我给不出一个逻辑严谨的证明来阐述为什么爱她。大风大浪已经过去了,我很满意现在的状态,即使从一开始就知道会经历什么,我也会毅然决然地走下去,遇见爱莉希雅,遇见昔涟,最终在我的人生中书写下最浪漫的篇章。
2026年2月,银河猫猫侠完稿。致爱莉希雅与昔涟。